发布时间: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 响应到达客户端后,客户端仍有大量工作要做,这仍可能导致网站的 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 和依赖项。
Rehydration 结合了服务器端和客户端渲染
水合是一种通过同时执行客户端和服务器端渲染来缓解两者之间权衡的方法。导航请求(例如完整网页加载或重新加载)由服务器处理,服务器会将应用呈现为 HTML。然后,用于呈现的 JavaScript 和数据会嵌入到生成的文档中。如果操作得当,这可以实现快速 FCP(如服务器端渲染),然后通过在客户端上再次渲染来“拾取”。
这是一种有效的解决方案,但可能会产生严重的性能缺陷。
采用再水合的服务器端渲染的主要缺点是,即使它能改善 FCP,也可能会对 TBT 和 INP 产生显著的负面影响。 服务器端呈现的网页可能看起来已加载且可互动,但实际上无法响应输入,直到组件的客户端脚本执行完毕且事件处理脚本已附加。在移动设备上,这可能需要几分钟的时间,从而让用户感到困惑和沮丧。
重新水合问题:一个应用,两份价格
为了让客户端 JavaScript 能够准确地从服务器中断的位置继续执行,而无需重新请求服务器用于呈现其 HTML 的所有数据,大多数服务器端呈现解决方案都会将界面数据依赖项的响应序列化为文档中的脚本标记。由于这会复制大量 HTML,因此补水可能会导致更多问题,而不仅仅是延迟互动。
服务器会返回应用界面的描述来响应导航请求,但也会返回用于构建该界面的源数据,以及界面实现的完整副本,该副本随后会在客户端上启动。在 bundle.js 完成加载和执行之前,界面不会变为可交互状态。
从使用服务器端渲染和 rehydration 的真实网站收集的效果指标表明,这很少是最佳选择。最重要的原因是它对用户体验的影响,当网页看起来已准备就绪,但其所有互动功能都无法正常工作时,用户体验会受到影响。
采用 rehydration 的服务器端渲染有望解决这个问题。在短期内,仅对高度可缓存的内容使用服务器端渲染可以缩短 TTFB,从而产生与预渲染类似的结果。增量、渐进或部分重新水合可能是未来让这项技术更可行的关键。
流式传输服务器端渲染和逐步 rehydrate
过去几年,服务器端渲染技术取得了长足发展。
流式服务器端渲染可让您以分块形式发送 HTML,以便浏览器在收到 HTML 时逐步渲染。这样可以更快地将标记提供给用户,从而加快 FCP。在 React 中,renderToPipeableStream() 中的流是异步的,而 renderToString() 中的流是同步的,这意味着背压得到了很好的处理。
渐进式再水合也值得考虑(React 已实现该功能)。通过这种方法,服务器渲染的应用的各个部分会随着时间的推移而“启动”,而不是像当前常见的方法那样一次性初始化整个应用。这有助于减少使网页具有互动性所需的 JavaScript 量,因为这样您就可以推迟网页低优先级部分的客户端升级,以防止其阻塞主线程,从而让用户互动在用户发起后更快发生。
渐进式补水还可以帮助您避免最常见的服务器端渲染补水陷阱之一:服务器渲染的 DOM 树被销毁,然后立即重建,这通常是因为初始同步客户端渲染需要的数据尚未准备就绪,通常是尚未解析的 Promise。
部分置换更新
事实证明,部分补水很难实现。这种方法是渐进式补水的一种扩展,它会分析页面的各个部分(组件、视图或树),并识别出互动性较低或没有响应性的部分。对于这些大部分为静态的部分,相应的 JavaScript 代码随后会转换为惰性引用和装饰性功能,从而将其客户端占用空间减少到几乎为零。
部分置换更新方法也有其自身的问题和妥协。这给缓存带来了一些有趣的挑战,而客户端导航意味着,我们不能假设应用中不活跃部分的服务器端呈现 HTML 可以在不进行网页加载的情况下可用。
三态渲染
如果服务工作线程是您的一个选项,请考虑三态渲染。借助此技术,您可以对初始导航或非 JavaScript 导航使用流式服务器端渲染,然后在 Service Worker 安装后让其负责导航的 HTML 渲染。这样可以使缓存的组件和模板保持最新状态,并启用 SPA 样式的导航,以便在同一会话中呈现新视图。当您可以在服务器、客户端页面和 Service Worker 之间共享相同的模板和路由代码时,此方法效果最佳。
SEO 注意事项
在选择 Web 渲染策略时,团队通常会考虑 SEO 的影响。 服务器端渲染是一种热门选择,可提供抓取工具可以解读的“完整外观”体验。抓取工具可以理解 JavaScript,但其呈现方式通常存在限制。客户端渲染可以正常运行,但通常需要额外的测试和开销。最近,如果您的架构严重依赖客户端 JavaScript,动态呈现也成为值得考虑的选项。
总结
在决定渲染方法时,请衡量并了解您的瓶颈所在。考虑静态渲染或服务器端渲染是否能满足您的大部分需求。您可以主要提供 HTML,并使用最少的 JavaScript 来实现互动体验。下面这张信息图展示了 服务器-客户端范围:
赠金
感谢所有人的审核和启发:
Jeffrey Posnick、Houssein Djirdeh、Shubhie Panicker、Chris Harrelson 和 Sebastian Markbåge。