JavaScript 中的服务器渲染:优化性能、渲染时获取数据、流式渲染(渐进式渲染)结论

2025-06-07

JavaScript 中的服务器渲染:优化性能

渲染时获取

流式传输(渐进式渲染)

结论

如果你读过《JavaScript 服务器渲染:尺寸优化》,你可能会好奇还剩下什么。毕竟,尺寸是性能的重要组成部分,而部分渲染实际上会降低执行速度。

还有一些事情可以做。良好的负载性能的关键在于减少通信导致的等待时间。显然,缓存可以发挥巨大作用,但总有无法缓存的情况。那么我们还能做什么呢?

渲染时获取

现在,就像代码拆分一样,这项技术并不局限于服务器渲染。这是迄今为止任何应用程序都能做的最重要的事情,可以减少瀑布流,而且随着时间的推移,这种做法已经变得不那么常见了。

这个想法很简单。导航到新路由时,在开始渲染组件时预先触发所有异步数据加载。很简单。然而,组件架构迫使我们将数据请求与需要它们的领域组件放在一起。这种模块化设计使代码简洁易维护。

我指的不仅仅是嵌套请求。它可能是发送到全局存储的事件。它可能是将数据需求表示为 GraphQL 的片段。基本上,没有人比使用它们的组件更了解 UI 部分的数据需求。

瀑布!!

然而,后来我们添加了代码拆分,现在这些请求直到相应部分的代码加载完毕后才会触发。在《JavaScript 中的服务器渲染:为什么要使用服务器渲染?》一文中,我解释了预加载页面资源可以消除这种级联,但这对下一次导航并没有什么帮助。好吧,也预加载一下吧……或许吧。

这里有一个替代方案。将数据加载与视图组件分离。让这个包装组件触发数据加载,并延迟加载视图组件,并在加载完成后进行渲染。React Suspense 是一个很好的例子,但有很多方法可以实现类似的效果。

// ProfilePage.js
const ProfileDetails = lazy(() => import("./ProfileDetails.js"));

function ProfilePage() {
  // This is not a Promise. It's a special object
  // from a Suspense integration.
  const resource = fetchProfileData();
  return (
    <Suspense fallback={<h1>Loading profile...</h1>}>
      <ProfileDetails user={resource.user} />
    </Suspense>
  );
}

// ProfileDetails.js
function ProfileDetails(props) {
  // Try to read user info, although it might not have loaded yet
  const user = props.user.read();
  return <h1>{user.name}</h1>;
}
Enter fullscreen mode Exit fullscreen mode

以上示例基于 React 的实验性数据获取文档。但您可以看到,我们可以同时触发惰性组件和数据获取,并且无论它们的顺序如何,它都会自行解决。

这样做的好处是它可以通用,无论是客户端渲染还是服务端渲染。与路由预加载不同,它也适用于未来的导航。但代价是主包中会因为包装页面组件(HOC)而稍微增加一点体积。

更棘手的部分可能是,极端情况下,每个组件都定义了各自的数据需求,这需要一种特殊的 API 来避免级联调用。在上面的例子中,我只是在页面级别加载数据。如果我想从不同的 API 端点显示该用户的帖子,我要么必须将它们带到父页面,要么想办法让子页面注册它们的需求。

我想到了GraphQL Fragments。虽然它并非唯一的解决方案,但它确实对 API 客户端服务提出了很高的要求。Facebook的 Relay就是一个典型的例子,它试图让最终用户轻松使用,但并非没有考虑采用。这足以让 React 考虑使用React Server Components来提供无 API 的解决方案。

但关键在于,这并非 React 独有的方法。我在我的Solid项目中大量使用了这种模式,因为它是一个非常好的同构解决方案,并且与下一个主题配合得非常好……

流式传输(渐进式渲染)

我还想讨论一个话题。不是 WebSocket 或其他什么花哨的东西,只是老式的分块传输编码。这个话题没有得到足够的重视。我们可以尽可能地将 HTML 字符串以流式传输的方式发送给浏览器,而不是一次性地将响应发送回浏览器。

虽然你可能已经听说过这个说法一段时间了,但几乎没有 JavaScript 框架以有意义的方式支持流式传输。它们或许有自己的功能,renderToNodeStreams但如果没有在服务端进行真正的异步渲染的能力,那么效果就大打折扣了。它们或许会提前发送文档头,以加快资源加载速度,但其他好处就白费了。

这样做的好处非常显著。首先,我们无需等待将内容发送给用户。早期的视觉反馈可以让网站看起来响应更快。此外,浏览器可以更快地开始加载资源,因为它可以更快地开始解析 HTML,这包括页面上的图片。

替代文本

工作原理

这一切之所以成为可能,是因为浏览器会积极渲染甚至绘制尚未收到结束标记的元素,并在你向页面发送这些元素时以内联方式执行脚本。我将在Marko中描述它的工作原理。

我们首先渲染同步内容,并在异步边界上渲染占位符。许多库已经提供了使用SuspenseAwait标签执行此操作的方法。然后,当数据从异步请求返回时,您将在服务器上渲染内容,并将其与上一个内容一起发送到页面<div>display: none然后,我们编写一个<script>标签,在占位符所在的位置插入新节点,并引导序列化数据进行数据融合。当所有异步数据完成后,我们发送页面结尾并关闭流。

Marko 作者在2014 年发表的一篇文章更详细地阐述了它的工作原理。结合部分水合技术,页面通常可以立即实现交互,无需等待更多 JavaScript 加载。除了性能优势之外,即使这是一种动态体验,当页面上没有 JavaScript 执行时(内容只是乱序),它仍然有利于 SEO。

流媒体性能

那么它的性能到底有多高呢?我使用Solid以多种不同的技术渲染了同一个简单应用程序。比较一下在 Nextjs、Nuxt、SvelteKit 等常见框架中等待资源的情况:

异步

使用流式传输加载同一页面:
替代文本

不仅首次绘制速度大幅提升,从 450 毫秒缩短至 180 毫秒。而且由于用于水合的 JavaScript 已经加载完毕,整体加载速度也显著缩短。流式传输示例基本上都在 260 毫秒内完成,而我们等待的那个示例则需要长达 500 毫秒才能完成执行。

这就是为什么人们常常误以为客户端渲染的性能会优于服务器端渲染 (SSR)。同一个页面使用纯客户端渲染加载,可以大大减少服务器端渲染 (SSR) 方案中典型的数据等待时间。而且,如果你先渲染同步应用外壳,它看起来与流式渲染的时间线大致相同。

替代文本

现在你可能会想,好吧,那我不需要流式传输了,我可以直接惰性渲染 shell 并从客户端获取数据。我的意思是,这个例子甚至包含了来自浏览器的级联数据请求。对于客户端来说,情况不会比这更糟。

是的,关于这一点……到目前为止,这些测试都是在快速网络上进行的。在较慢的网络上,客户端的情况就不同了。服务器方法之间的差异会逐渐减小,但客户端的表现却远远落后,正如我们在“快速 3G”网络上的流媒体对比中看到的那样:

替代文本

对于我们的客户端渲染版本:

替代文本

现在情况变得更糟了。我们的流式示例现在需要 1320 毫秒才能加载所有内容(除了那个需要很长时间才能加载的图标)。但我们之前性能相当的客户端抓取器却完全不同。它要到 2600 毫秒才能完成所有 JavaScript 的加载和执行。没错,在一个相当简单的页面上慢了一秒多。这是一个显而易见的差别,而且它甚至不是最慢的网络。

只有流媒体才能为新鲜动态内容提供最佳的全面性能。截至撰写本文时,据我所知,只有MarkoSolid拥有此功能。

但预计其他库也会支持此功能。首先是React Server Components。我相信其他库也会跟进。

结论

过去的一年对我来说是一段疯狂的旅程,我不断学习服务器渲染的方方面面。一开始我几乎一无所知,但在实验、学习其他库以及编写自己的实现的过程中,我学到了很多东西。

我最大的收获是,JavaScript 服务器渲染解决方案还有大量工作要做。流式渲染、部分渲染、子组件渲染、服务器组件、同构异步模式等等。在接下来的一年左右,我们将看到一些令人惊喜的成果。

因此,虽然我的探索就此结束,但显然这仅仅是一个开始。

文章来源:https://dev.to/ryansolid/server-rendering-in-javascript-optimizing-performance-1jnk
PREV
固体状态 - 2021 年 9 月
NEXT
我要加入 Sentry