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。 这有助于缩短网页的 TTBT,还可以降低 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 中,renderToPipeableStream() 中的流是异步的,而 renderToString() 中的流是同步的,这意味着可以很好地处理反压。
渐进式重新水合也值得考虑(React 已实现此功能)。采用这种方法,服务器渲染的应用的各个部分会随着时间的推移“启动”,而不是像当前常用的方法那样一次性初始化整个应用。这有助于减少使网页具有互动性所需的 JavaScript 代码量,因为您可以延迟对网页低优先级部分进行客户端升级,以防止其阻塞主线程,从而让用户在发起互动后更快地进行互动。
渐进式重新水合还可以帮助您避免最常见的服务器端渲染重新水合陷阱之一:服务器渲染的 DOM 树被销毁,然后立即重建,这通常是因为初始同步客户端渲染需要的数据尚未完全准备就绪,通常是尚未解析的 Promise。
部分水合
事实证明,部分重新水合很难实现。此方法是渐进式重新水合的扩展,可分析网页的各个部分(组件、视图或树),并识别互动性低或无反应的部分。对于每个此类基本静态部分,相应的 JavaScript 代码随后会转换为惰性引用和装饰性功能,从而将其客户端占用空间减少到几乎为零。
部分水合方法也有其自身的问题和缺点。这给缓存带来了一些有趣的挑战,而客户端导航意味着我们不能假设应用中非活跃部分的服务器端呈现 HTML 在没有完整网页加载的情况下可用。
三同构渲染
如果您可以选择使用服务工作线程,请考虑使用三态渲染。借助此技术,您可以针对初始导航或非 JavaScript 导航使用流式服务器端渲染,然后在服务工作线程安装完毕后,让它负责渲染导航的 HTML。这样可以使缓存的组件和模板保持最新状态,并支持 SPA 风格的导航,以便在同一会话中渲染新视图。当您可以在服务器、客户端网页和服务工作线程之间共享相同的模板和路由代码时,此方法效果最佳。
SEO 注意事项
在选择 Web 呈现策略时,团队通常会考虑 SEO 的影响。服务器端渲染是一种热门选择,可提供“完整”的体验,以便抓取工具进行解读。抓取工具可以理解 JavaScript,但它们在呈现方面通常存在限制。客户端渲染可以正常运行,但通常需要进行额外的测试并产生额外的开销。最近,如果您的架构严重依赖客户端 JavaScript,动态渲染也成为值得考虑的选项。
如有疑问,不妨使用移动设备适合性测试工具测试所选方法是否能达到预期效果。它会直观地预览任何网页在 Google 抓取工具中的显示效果、在执行 JavaScript 后找到的序列化 HTML 内容,以及在呈现过程中遇到的任何错误。
总结
在决定渲染方法时,请先衡量并了解瓶颈所在。考虑一下静态渲染或服务器端渲染是否能让您实现大部分目标。您完全可以主要交付 HTML,并使用最少的 JavaScript 来实现互动体验。以下信息图表直观地展示了服务器-客户端频谱:
点数 {:#credits}
感谢所有人的评价和灵感:
Jeffrey Posnick、Houssein Djirdeh、Shubhie Panicker、Chris Harrelson 和 Sebastian Markbåge。