Web 开发者必须做出的一项核心决策是,在应用中的何处实现逻辑和呈现。这可能很难做到,因为构建网站的方法有很多。
我们对这一领域的了解源自于过去几年与大型网站合作的 Chrome 工作。从广义上讲,我们建议开发者考虑采用服务器端渲染或静态渲染,而非完全重构 (rehydration) 方法。
为了更好地了解我们在做出此决策时选择的架构,我们需要对每种方法有充分的了解,以及在谈论它们时要使用的一致术语。不同呈现方法之间的差异有助于我们从网页性能的角度展现在网页上进行呈现的利弊。
术语
渲染
- 服务器端渲染 (SSR)
- 将客户端或通用应用渲染为服务器上的 HTML。
- 客户端呈现 (CSR)
- 在浏览器中渲染应用,使用 JavaScript 修改 DOM。
- 补水
- 在客户端上“启动”JavaScript 视图,以便它们重复使用服务器呈现的 HTML 的 DOM 树和数据。
- 预渲染
- 在构建时运行客户端应用,以静态 HTML 形式捕获其初始状态。
性能
- 首字节时间 (TTFB)
- 从点击链接到新网页上加载内容的第一个字节间隔的时间。
- First Contentful Paint (FCP)
- 请求的内容(文章正文等)变为可见的时间。
- Interaction to Next Paint (INP)
- 一个代表性指标,用于评估网页是否一致地快速响应用户输入。
- Total Blocking Time (TBT)
- 一个 INP 的代理指标,用于计算在页面加载期间主线程被阻塞的时长。
服务器端渲染
服务器端渲染会为服务器上的网页生成完整的 HTML,以响应导航。这样可以避免在客户端上执行数据提取和模板设置额外的往返,因为渲染程序会在浏览器收到响应之前对其进行处理。
服务器端渲染通常会生成快速 FCP。在服务器上运行页面逻辑和呈现,您就可以避免向客户端发送大量 JavaScript。这有助于减少页面的 TBT,这也会导致 INP 下降,因为主线程在网页加载期间阻塞频率较低。如果主线程阻塞的频率较低,用户互动就会有更多机会更快地运行。这是合理的,因为使用服务器端呈现时,您实际上只是将文本和链接发送到用户浏览器。这种方法非常适用于各种设备和网络条件,并允许进行流式文档解析等有趣的浏览器优化。
使用服务器端渲染,用户不太可能等待受 CPU 限制的 JavaScript 运行完毕,然后才能使用您的网站。即使您无法避免第三方 JS,使用服务器端渲染来降低自己的第一方 JavaScript 费用也可以让您在其余工作上获得更多预算。不过,此方法有一个可能的权衡之处:在服务器上生成网页需要花费时间,这可能会增加网页的 TTFB。
服务器端渲染是否足以满足您的应用在很大程度上取决于您要构建的体验类型。对于服务器端渲染与客户端渲染的正确应用之间,存在着长期争论,但您可以随时选择对某些网页使用服务器端渲染,而不对另一些网页使用服务器端渲染。一些网站采用了混合渲染技术并取得了成功。例如,Netflix 服务器呈现其相对静态的着陆页,同时为互动较多的网页预提取 JS,从而为这些由客户端呈现、较大的网页提供更快的加载速度。
借助许多现代框架、库和架构,您可以在客户端和服务器上渲染同一个应用。您可以使用这些技术进行服务器端渲染。但是,同时在服务器和客户端上进行渲染的架构属于自己的一类解决方案,具有截然不同的性能特征和权衡。React 用户可以使用服务器 DOM API 或基于这些 API 构建的解决方案(例如 Next.js)进行服务器端渲染。Vue 用户可以使用 Vue 的服务器端渲染指南或 Nuxt。Angular 具备 Universal。不过,大多数流行的解决方案都会使用某种形式的水合,因此请注意您的工具使用的方法。
静态渲染
静态渲染发生在构建时。此方法可提供快速 FCP 和更低的 TBT 和 InP,但前提是您要限制网页上的客户端 JS 量。与服务器端渲染不同的是,由于网页的 HTML 不必在服务器上动态生成,因此它还可实现始终快速的 TTFB。 通常,静态呈现意味着提前为每个网址生成单独的 HTML 文件。借助预先生成的 HTML 响应,您可以将静态渲染部署到多个 CDN,以充分利用边缘缓存。
静态渲染的解决方案具有各种形状和大小。Gatsby 等工具旨在让开发者感觉他们的应用是动态渲染的,而不是作为构建步骤生成的。静态网站生成工具(例如 11ty、Jekyll 和 Metalsmith)支持其静态性质,因此提供了一种更加由模板驱动的方法。
静态呈现的缺点之一是,必须为每个可能的网址生成单独的 HTML 文件。如果您无法提前预测这些网址是什么,或者对于包含大量唯一网页的网站来说,这可能极具挑战性,甚至不可行。
React 用户可能熟悉 Gatsby、Next.js 静态导出或 Navi,所有这些都可以方便您根据组件创建页面。不过,静态呈现和预呈现的行为有所不同:静态呈现的网页是互动式的,无需执行很多客户端 JavaScript,而预呈现可以改善单页应用的 FCP;单页应用必须在客户端上启动,才能真正实现可交互的网页。
如果您不确定给定的解决方案是静态呈现还是预呈现,请尝试停用 JavaScript 并加载要测试的网页。对于静态呈现的网页,即使没有 JavaScript,大多数互动功能仍然存在。预渲染的网页可能仍然具有一些基本功能,例如停用了 JavaScript 的链接,但大多数网页处于非活动状态。
另一个有用的测试是使用 Chrome 开发者工具中的网络节流功能,并查看页面在进入可交互状态之前下载了多少 JavaScript。预渲染通常需要更多的 JavaScript 才能具有交互性,而且 JavaScript 往往比静态渲染中使用的渐进式增强方法更复杂。
服务器端呈现与静态呈现
服务器端渲染并不是万无一失的最佳解决方案,因为它的动态特性可能会产生高昂的计算开销费用。许多服务器端呈现解决方案不会提前刷新、延迟 TTFB,也不会将发送的数据加倍(例如,客户端上的 JavaScript 使用的内嵌状态)。在 React 中,renderToString()
可能很慢,因为它是同步且单线程的。较新的 React 服务器 DOM API 支持流式传输,以便更早地将 HTML 响应的初始部分提供给浏览器,同时让其他部分仍在服务器上生成。
为了“正确”服务器端渲染,可能需要查找或构建组件缓存解决方案、管理内存消耗、使用记忆技术以及其他问题。您通常需要处理或重新构建同一应用两次,一次是在客户端上,另一次是在服务器上。尽早显示内容的服务器端渲染不一定会让您工作量更少。如果在客户端生成的 HTML 响应到达客户端后,客户端上会执行大量工作,这仍然可能会导致网站的 TBT 和 INP 增加。
服务器端呈现会根据需要为每个网址生成 HTML,但与仅提供静态呈现内容相比,速度可能会慢一些。如果您需要投入更多精力,那么服务器端呈现和 HTML 缓存可以显著缩短服务器呈现时间。与静态呈现相比,服务器端渲染的优势在于能够拉取更多“实时”数据并响应更完整的请求集。需要个性化的网页就是此类请求的具体示例,这类请求在静态呈现方面表现不佳。
构建 PWA 时,服务器端渲染也会做出一些有趣的决策:使用整页 Service Worker 缓存更好,还是仅使用服务器渲染个别内容?
客户端渲染
客户端呈现是指使用 JavaScript 直接在浏览器中呈现网页。所有逻辑、数据提取、模板化和路由都是在客户端(而不是在服务器上)处理。有效的结果是将更多数据从服务器传递到用户设备,这有其自己的权衡。
在移动设备上进行客户端渲染可能很难实现和保持快速运行。只需付出一些努力,就能保持严格的 JavaScript 预算,并尽可能减少往返次数,从而实现客户端渲染的效果,几乎可以复制纯服务器端渲染的性能。通过使用 <link rel=preload>
提供关键脚本和数据,您可以让解析器更快地为您工作。我们还建议考虑使用 PRPL 等模式,以确保提供即时导航和后续导航体验。
客户端渲染的主要缺点是,随着应用规模的扩大,所需的 JavaScript 数量往往也会增加,这可能会影响网页的 INP。随着新的 JavaScript 库、polyfill 和第三方代码的加入,这一点变得尤为困难,它们会争夺处理能力,并且通常需要在页面内容呈现之前进行处理。
如果体验使用客户端渲染并依赖于大型 JavaScript 软件包,则应考虑激进式代码拆分以在网页加载期间降低 TBT 和 INP,同时应延迟加载 JavaScript 以仅在需要时提供用户所需的内容。对于互动性很少或根本没有互动的体验,服务器端渲染可以代表这些问题的可扩展性更强的解决方案。
对于构建单页应用的人员,通过确定大多数页面所共享的界面核心部分,您可以应用 Application Shell 缓存技术。与 Service Worker 结合使用可以显著提升重复访问的感知性能,因为页面可以非常快速地从 CacheStorage
加载其 App Shell HTML 和依赖项。
Rehydration 结合使用服务器端渲染和客户端渲染
重构 (Rehydration) 是一种尝试同时处理客户端渲染与服务器端渲染之间的权衡的方法。导航请求(例如全屏加载或重新加载)由将应用呈现为 HTML 的服务器处理,然后将用于呈现的 JavaScript 和数据嵌入到生成的文档中。如果小心谨慎,这样做可以实现快速 FCP(如服务器端渲染),然后通过在客户端上再次渲染来“接续”。这是一个有效的解决方案,但可能存在相当大的性能缺陷。
使用 Rehydration 进行服务器端渲染的主要缺点是,即使可以提高 FCP,也会对 TBT 和 INP 产生显著的负面影响。服务器端呈现的页面可以看似是已加载的互动式页面,但在执行组件的客户端脚本并附加事件处理脚本之前,页面实际上无法响应输入。在移动设备上,这可能需要几分钟的时间,让用户感到困惑和沮丧。
补水问题:一款应用只买两个
为了让客户端 JavaScript 准确“接续”服务器在停止运行时的位置,而无需重新请求服务器渲染 HTML 的所有数据,大多数服务器端渲染解决方案都会将界面数据依赖项的响应序列化为文档中的脚本标记。由于这会复制大量 HTML,因此重构 (rehydration) 不仅会导致互动延迟,还会导致更多问题。
服务器会返回应用界面的说明来响应导航请求,但也会返回用于构成该界面的源数据,以及界面实现的完整副本(随后会在客户端上启动)。在 bundle.js
完成加载和执行之前,界面不会变为互动状态。
使用服务器端渲染和重构从真实网站收集的性能指标表明,这并不是最佳选择。最重要的原因是:当页面看起来已准备就绪,但其互动功能都不起作用时,这种错误会给用户体验造成影响。
不过,通过 Rehydration 进行服务器端渲染是有希望的。在短期内,仅对可缓存程度较高的内容使用服务器端渲染可以减少 TTFB,产生与预渲染类似的结果。逐步、逐步或部分重构,可能是提高此方法将来可行性的关键。
流式传输服务器端渲染,逐步进行重构
服务器端渲染在过去几年间有了许多改进。
借助流式服务器端呈现,您可以分块发送 HTML,浏览器可以在收到数据块后逐步进行渲染。这样可以更快地向用户提供标记,从而加快 FCP。在 React 中,与同步 renderToString()
相比,在 renderToPipeableStream()
中流是异步的,意味着背压得到妥善处理。
渐进式补位也值得考虑,React 已实现此功能。通过这种方法,服务器渲染的应用的各个部分将随时间的推移“启动”,而不是采用目前常用的方法(即一次性初始化整个应用)。这有助于减少使页面具有互动性所需的 JavaScript 数量,因为它可以让您推迟页面低优先级部分的客户端升级,以防止其阻塞主线程,从而使用户互动可在用户发起互动后更快地发生。
渐进式重新合成还可以帮助您避免一种最常见的服务器端渲染补救陷阱:服务器渲染的 DOM 树被销毁,然后立即重新构建,这通常是因为初始同步客户端渲染需要尚未准备好的数据,通常是尚未解析的 Promise
。
部分补水
事实证明,部分补液很难实现。这种方法是对渐进式重构的扩展,它会分析网页的各个部分(组件、视图或树),并识别互动性很少或没有反应的部分。对于其中每个大部分是静态的部分,相应的 JavaScript 代码随后都会转换为 inert 引用和装饰性功能,从而将其客户端占用空间减少到接近零。
部分水合方法自身也存在一些问题和折衷问题。这给缓存带来了一些有趣的挑战,而客户端导航意味着我们不能假设没有完全加载页面的情况下,应用闲置部分的服务器渲染的 HTML 可用。
三态渲染
如果您希望使用 Service Worker,请考虑采用三态渲染。通过此方法,您可以使用流式服务器端渲染进行初始或非 JS 导航,然后让 Service Worker 在安装后对导航进行 HTML 渲染。这可以使缓存的组件和模板保持最新状态,并实现 SPA 式导航,以便在同一会话中呈现新视图。当您可以在服务器、客户端页面和 Service Worker 之间共享相同的模板和路由代码时,此方法效果最佳。
搜索引擎优化 (SEO) 注意事项
在选择 Web 呈现策略时,团队通常会考虑 SEO 的影响。若要提供抓取工具可解读的“完整”体验,服务器端呈现是一种热门选择。抓取工具可以理解 JavaScript,但它们的呈现方式通常存在限制。客户端渲染可以正常工作,但通常需要额外的测试和开销。最近,如果您的架构高度依赖于客户端 JavaScript,动态呈现也已成为一个值得考虑的选项。
如有疑问,移动设备适合性测试工具是测试您选择的方法是否达到预期效果的绝佳方式。它直观地预览了 Google 抓取工具看到的任何网页、执行 JavaScript 后找到的序列化 HTML 内容,以及在呈现过程中遇到的任何错误。
总结
在决定渲染方法时,请测量并了解有哪些瓶颈。考虑静态呈现或服务器端呈现能否助您取得最佳成效。一般来说,大部分情况下都只发布 HTML 代码,而 JavaScript 数量极少,这样才能获得交互式体验。以下方便的信息图展示了服务器-客户端的范围:
赠金
感谢所有人的评价和灵感:
Jeffrey Posnick、Houssein Djirdeh、Shubhie Panicker、Chris Harrelson 和 Sebastian Markbåge