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