Web 开发者必须做出的核心决策之一是应用程序中逻辑和渲染的实现位置。这可能很困难,因为构建网站的方法有很多种。
我们对这个领域的理解来自于过去几年我们在 Chrome 中与大型网站的交流工作。 广义上讲,我们鼓励开发者考虑使用服务器端渲染或静态渲染,而不是完全重新水合的方法。
为了更好地理解我们在做出此决定时选择的架构,我们需要扎实地理解每种方法以及在谈论它们时使用一致的术语。从页面性能的角度来看,渲染方法之间的差异有助于说明 Web 渲染的权衡。
术语
首先,我们定义一些我们将使用的术语。
渲染
- 服务器端渲染 (SSR)
- 在服务器上渲染应用以发送 HTML 而不是 JavaScript 到客户端。
- 客户端渲染 (CSR)
- 在浏览器中渲染应用,使用 JavaScript 修改 DOM。
- 重新水合
- 在客户端“启动”JavaScript 视图,以便它们重用服务器渲染的 HTML 的 DOM 树和数据。
- 预渲染
- 在构建时运行客户端应用程序,以捕获其初始状态作为静态 HTML。
性能
- 首字节时间 (TTFB)
- 单击链接和新页面上加载的第一个内容字节之间的时间。
- 首次内容绘制 (FCP)
- 请求的内容(文章正文等)变为可见的时间。
- 与下一次绘制的交互 (INP)
- 一个代表性的指标,用于评估页面是否始终快速响应用户输入。
- 总阻塞时间 (TBT)
- INP 的代理指标,用于计算页面加载期间主线程被阻止的时间。
服务器端渲染
服务器端渲染在服务器上生成页面的完整 HTML 以响应导航。 这避免了在客户端进行数据提取和模板化的额外往返,因为渲染器会在浏览器获得响应之前处理它们。
服务器端渲染通常会产生快速的 FCP。 在服务器上运行页面逻辑和渲染可以让您避免向客户端发送大量 JavaScript。 这有助于减少页面的 TBT,这也可能导致更低的 INP,因为在页面加载期间主线程不会经常被阻止。 当主线程被阻止的频率较低时,用户交互有更多机会更快运行。 这是有道理的,因为使用服务器端渲染,您实际上只是向用户的浏览器发送文本和链接。 这种方法适用于各种设备和网络条件,并开辟了有趣的浏览器优化,例如流式文档解析。

使用服务器端渲染,用户不太可能因为 CPU 绑定的 JavaScript 运行之前而无法使用您的网站而等待。 即使您无法避免 第三方 JS,使用服务器端渲染来降低您自己的第一方 JavaScript 成本 可以为您提供更多 预算 用于其余部分。 但是,这种方法存在一个潜在的权衡:在服务器上生成页面需要时间,这可能会增加页面的 TTFB。
服务器端渲染是否足以满足您的应用程序需求,很大程度上取决于您正在构建的体验类型。 关于服务器端渲染与客户端渲染的正确应用存在长期争论,但您可以始终选择对某些页面使用服务器端渲染,而对其他页面不使用。 一些网站已成功采用混合渲染技术。 例如,Netflix 服务器渲染其相对静态的着陆页,同时 预取 用于交互密集型页面的 JS,使这些较重的客户端渲染页面更有机会快速加载。
许多现代框架、库和架构允许您在客户端和服务器上渲染相同的应用程序。 您可以将这些技术用于服务器端渲染。 但是,渲染同时发生在服务器和客户端的架构是它们自己的一类解决方案,具有非常不同的性能特征和权衡。 React 用户可以使用 服务器 DOM API 或基于它们构建的解决方案(如 Next.js)进行服务器端渲染。 Vue 用户可以使用 Vue 的 服务器端渲染指南 或 Nuxt。 Angular 有 Universal。 大多数流行的解决方案都使用某种形式的水合,因此请注意您的工具使用的方法。
静态渲染
静态渲染发生在构建时。 这种方法提供快速的 FCP,以及更低的 TBT 和 INP,只要您限制页面上的客户端 JS 数量即可。 与服务器端渲染不同,它还实现了始终快速的 TTFB,因为页面的 HTML 不必在服务器上动态生成。 通常,静态渲染意味着预先为每个 URL 生成单独的 HTML 文件。 借助预先生成的 HTML 响应,您可以将静态渲染部署到多个 CDN,以利用边缘缓存。

静态渲染解决方案有各种形状和大小。 像 Gatsby 这样的工具旨在让开发者感觉他们的应用程序正在动态渲染,而不是作为构建步骤生成。 静态站点生成工具,如 11ty、Jekyll 和 Metalsmith 接受其静态性质,提供更模板驱动的方法。
静态渲染的缺点之一是它必须为每个可能的 URL 生成单独的 HTML 文件。 当您无法提前预测这些 URL 将是什么,或者对于具有大量独特页面的站点来说,这可能具有挑战性,甚至不可行。
React 用户可能熟悉 Gatsby、Next.js 静态导出 或 Navi,所有这些都使从组件创建页面变得方便。 但是,静态渲染和预渲染的行为有所不同:静态渲染的页面是交互式的,无需执行太多客户端 JavaScript,而预渲染改进了单页应用程序的 FCP,该应用程序必须在客户端启动才能使页面真正具有交互性。
如果您不确定给定的解决方案是静态渲染还是预渲染,请尝试禁用 JavaScript 并加载您要测试的页面。 对于静态渲染的页面,大多数交互功能在没有 JavaScript 的情况下仍然存在。 预渲染的页面在禁用 JavaScript 的情况下可能仍然具有一些基本功能(如链接),但页面的大部分是惰性的。
另一个有用的测试是使用 Chrome DevTools 中的网络限速,看看在页面变得交互式之前下载了多少 JavaScript。 预渲染通常需要更多 JavaScript 才能变得交互式,并且该 JavaScript 往往比静态渲染中使用的 渐进增强 方法更复杂。
服务器端渲染与静态渲染
服务器端渲染并非适用于所有情况的最佳解决方案,因为其动态特性可能具有显著的计算开销成本。 许多服务器端渲染解决方案不会提前刷新,会延迟 TTFB,或者使发送的数据加倍(例如,客户端 JavaScript 使用的内联状态)。 在 React 中,renderToString()
可能会很慢,因为它是同步和单线程的。 较新的 React 服务器 DOM API 支持流式处理,这可以在服务器上仍在生成 HTML 响应的其余部分时,更快地将 HTML 响应的初始部分发送到浏览器。
“正确”地进行服务器端渲染可能涉及寻找或构建 组件缓存、管理内存消耗、使用 memoization 技术以及其他问题的解决方案。 您通常会两次处理或重建相同的应用程序,一次在客户端,一次在服务器上。 服务器端渲染更快地显示内容不一定意味着您可以减少工作量。 如果在服务器生成的 HTML 响应到达客户端后,您在客户端有很多工作要做,这仍然可能导致您的网站的 TBT 和 INP 更高。
服务器端渲染按需为每个 URL 生成 HTML,但它可能比仅提供静态渲染内容慢。 如果您可以投入额外的精力,服务器端渲染加上 HTML 缓存 可以显著减少服务器渲染时间。 服务器端渲染的优势在于能够提取更多“实时”数据并响应比静态渲染可能实现的更完整的请求集。 需要个性化的页面是不太适合静态渲染的请求类型的具体示例。
在构建 PWA 时,服务器端渲染也可能带来有趣的决策:使用全页 service worker 缓存,还是仅服务器渲染各个内容片段更好?
客户端渲染
客户端渲染意味着直接在浏览器中使用 JavaScript 渲染页面。 所有逻辑、数据提取、模板化和路由都在客户端而不是服务器上处理。 有效的结果是更多数据从服务器传递到用户的设备,这带来了其自身的一系列权衡。
客户端渲染可能难以快速制作并保持移动设备的快速。 通过少量工作来保持 严格的 JavaScript 预算 并在尽可能少的 往返 中交付价值,您可以使客户端渲染几乎复制纯服务器端渲染的性能。 您可以通过使用 <link rel=preload>
交付关键脚本和数据来更快地让解析器为您工作。 我们还建议考虑使用 PRPL 等模式,以确保初始和后续导航感觉即时。

客户端渲染的主要缺点是,随着应用程序的增长,所需的 JavaScript 量往往会增加,这可能会影响页面的 INP。 随着新 JavaScript 库、polyfill 和第三方代码的添加,这种情况变得尤为困难,这些代码争夺处理能力,并且通常必须在页面内容可以渲染之前进行处理。
使用客户端渲染并依赖大型 JavaScript 包的体验应考虑 积极的代码拆分 以降低页面加载期间的 TBT 和 INP,以及延迟加载 JavaScript 以仅在需要时提供用户需要的内容。 对于交互性很少或没有交互性的体验,服务器端渲染可以代表解决这些问题的更可扩展的解决方案。
对于构建单页应用程序的人员,识别大多数页面共享的用户界面的核心部分可以让您应用 应用程序外壳缓存 技术。 结合 service worker,这可以显著提高重复访问时的感知性能,因为页面可以从 CacheStorage
非常快速地加载其应用程序外壳 HTML 和依赖项。
重新水合结合了服务器端和客户端渲染
重新水合是一种尝试通过同时执行客户端渲染和服务器端渲染来消除两者之间权衡的方法。 诸如完整页面加载或重新加载之类的导航请求由服务器处理,该服务器将应用程序渲染为 HTML,然后将用于渲染的 JavaScript 和数据嵌入到结果文档中。 如果小心地完成,这将实现像服务器端渲染一样的快速 FCP,然后在客户端上再次渲染来“拾取”。 这是一个有效的解决方案,但它可能具有相当大的性能缺陷。
使用重新水合的服务器端渲染的主要缺点是,即使它可以改善 FCP,它也可能对 TBT 和 INP 产生显着的负面影响。 服务器端渲染的页面可能看起来已加载且可交互,但在组件的客户端脚本执行并且事件处理程序已附加之前,实际上无法响应输入。 在移动设备上,这可能需要几分钟,这会使用户感到困惑和沮丧。
重新水合问题:一个应用程序的价格购买两个应用程序
为了使客户端 JavaScript 能够准确地“拾取”服务器停止的位置,而无需重新请求服务器渲染其 HTML 所用的所有数据,大多数服务器端渲染解决方案将来自 UI 数据依赖项的响应序列化为文档中的脚本标签。 因为这会复制大量 HTML,所以重新水合可能会导致比仅延迟交互性更多的问题。

服务器正在返回应用程序 UI 的描述以响应导航请求,但它也返回用于组成该 UI 的源数据,以及 UI 实现的完整副本,然后在客户端上启动。 UI 在 bundle.js
完成加载和执行后才变为交互式。
从使用服务器端渲染和重新水合的真实网站收集的性能指标表明,它很少是最佳选择。 最重要的原因是其对用户体验的影响,当页面看起来已准备就绪但其交互功能均不起作用时。

不过,使用重新水合的服务器端渲染仍然有希望。 在短期内,仅对高度可缓存的内容使用服务器端渲染可以减少 TTFB,从而产生与预渲染相似的结果。 增量、渐进或部分重新水合可能是使此技术在未来更可行的关键。
流式服务器端渲染和渐进式重新水合
服务器端渲染在过去几年中取得了许多进展。
流式服务器端渲染允许您以块的形式发送 HTML,浏览器可以在接收到 HTML 时逐步渲染。 这可以更快地将标记发送给您的用户,从而加快您的 FCP。 在 React 中,与同步 renderToString()
相比,renderToPipeableStream()
中的流是异步的,这意味着可以很好地处理背压。
渐进式重新水合也值得考虑,React 已经 实现 了它。 使用这种方法,服务器渲染的应用程序的各个部分会随着时间的推移“启动”,而不是当前一次初始化整个应用程序的常用方法。 这可以帮助减少使页面具有交互性所需的 JavaScript 量,因为它允许您推迟页面低优先级部分的客户端升级,以防止它阻止主线程,从而使用户交互在用户启动它们之后更快发生。
渐进式重新水合还可以帮助您避免最常见的服务器端渲染重新水合陷阱之一:服务器渲染的 DOM 树被销毁,然后立即重建,最常见的原因是初始同步客户端渲染需要的数据尚未完全准备就绪,通常是尚未解析的 Promise
。
部分重新水合
部分重新水合已被证明难以实现。 这种方法是渐进式重新水合的扩展,它分析页面的各个部分(组件、视图或树),并识别交互性很少或没有交互性的部分。 对于每个主要静态部分,相应的 JavaScript 代码然后被转换为惰性引用和装饰性功能,从而将其客户端占用空间减少到几乎为零。
部分水合方法有其自身的问题和妥协。 它对缓存提出了一些有趣的挑战,客户端导航意味着我们不能假设应用程序惰性部分的服务器渲染 HTML 在没有完整页面加载的情况下可用。
同构渲染
如果 service worker 对您来说是一个选项,请考虑同构渲染。 这是一种技术,可让您对初始或非 JS 导航使用流式服务器端渲染,然后在安装 service worker 后,让 service worker 承担导航的 HTML 渲染。 这可以使缓存的组件和模板保持最新,并为同一会话中渲染新视图启用 SPA 风格的导航。 当您可以在服务器、客户端页面和 service worker 之间共享相同的模板和路由代码时,这种方法效果最佳。

SEO 考虑因素
在选择 Web 渲染策略时,团队通常会考虑 SEO 的影响。 服务器端渲染是交付爬虫可以解释的“完整外观”体验的流行选择。 爬虫 可以理解 JavaScript,但它们如何渲染通常存在 限制。 客户端渲染可以工作,但通常需要额外的测试和开销。 最近,如果您的架构严重依赖客户端 JavaScript,动态渲染 也已成为值得考虑的选项。
如有疑问,移动设备友好性测试工具 是测试您选择的方法是否达到您期望效果的好方法。 它显示了任何页面在 Google 爬虫中显示的可视预览、JavaScript 执行后找到的序列化 HTML 内容以及渲染期间遇到的任何错误。

结论
在决定渲染方法时,请衡量并了解您的瓶颈是什么。 考虑静态渲染或服务器端渲染是否可以帮助您达到目标。 主要发送带有最少 JavaScript 的 HTML 以获得交互式体验是可以的。 这是一个方便的信息图,显示了服务器-客户端频谱

鸣谢
感谢大家的评论和启发
Jeffrey Posnick、Houssein Djirdeh、Shubhie Panicker、Chris Harrelson 和 Sebastian Markbåge