Web 开发者必须做出的一个核心决策是,在应用中何处实现逻辑和渲染。这可能很难,因为构建网站的方法有很多。
我们对这个领域的理解基于过去几年在 Chrome 中与大型网站沟通的工作。一般来说,我们建议开发者考虑使用服务器端呈现或静态呈现,而不是完全重新 hydration 方法。
为了在做出此决定时更好地了解我们要从中选择的架构,我们需要对每种方法有充分的了解,并在讨论这些方法时使用一致的术语。渲染方法之间的差异有助于从网页性能的角度说明在 Web 上渲染的权衡取舍。
术语
首先,我们来定义一些术语。
渲染
- 服务器端渲染 (SSR)
- 在服务器上呈现应用,以向客户端发送 HTML(而非 JavaScript)。
- 客户端呈现 (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 会将相对静态的着陆页服务器呈现,同时为互动量较高的网页prefetching JS,让这些由客户端呈现的较大网页更有可能快速加载。
许多现代框架、库和架构都支持您在客户端和服务器上呈现相同的应用。您可以将这些方法用于服务器端渲染。不过,在服务器和客户端上同时进行渲染的架构是各自的一类解决方案,其性能特性和权衡点截然不同。React 用户可以使用服务器 DOM API 或基于这些 API 构建的解决方案(例如 Next.js)进行服务器端渲染。Vue 用户可以使用 Vue 的服务器端渲染指南或 Nuxt。Angular 具有 Universal。不过,大多数热门解决方案都使用某种形式的补充,因此请注意您的工具使用的方法。
静态渲染
静态渲染发生在构建时。只要您限制网页上的客户端 JS 数量,这种方法就能提供快速的 FCP,并降低 TBT 和 INP。与服务器端呈现不同,这种呈现方式还能实现始终如一的快速 TTFB,因为网页的 HTML 不必在服务器上动态生成。通常,静态呈现意味着提前为每个网址生成单独的 HTML 文件。通过预生成 HTML 响应,您可以将静态呈现部署到多个 CDN,以充分利用边缘缓存。
静态渲染解决方案的形状和大小各不相同。Gatsby 等工具旨在让开发者感觉其应用是动态呈现的,而不是作为构建步骤生成的。11ty、Jekyll 和 Metalsmith 等静态网站生成工具充分利用其静态特性,提供更模板化的方法。
静态呈现的一个缺点是,它必须为每个可能的网址生成单独的 HTML 文件。如果您无法预测这些网址,或者网站包含大量唯一网页,这可能会很困难,甚至是不可行。
React 用户可能熟悉 Gatsby、Next.js 静态导出或 Navi,这些工具都可以让您轻松地通过组件创建网页。不过,静态呈现和预呈现的行为有所不同:静态呈现的网页无需执行大量客户端 JavaScript 即可实现互动,而预呈现可提高单页应用的 FCP,因为单页应用必须在客户端上启动才能真正实现互动。
如果您不确定给定解决方案是静态呈现还是预呈现,请尝试停用 JavaScript 并加载要测试的网页。对于静态呈现的网页,大多数交互功能在没有 JavaScript 的情况下仍然可用。预渲染的网页可能仍具有一些基本功能(例如已停用 JavaScript 的链接),但网页的大部分内容都处于不活跃状态。
另一项实用的测试是使用 Chrome DevTools 中的网络节流功能,看看网页在变为交互状态之前下载了多少 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 缓存技术。与服务工件结合使用时,这可以显著提高重复访问时的感知性能,因为网页可以非常快速地从 CacheStorage
加载其应用 shell HTML 和依赖项。
重新水合可结合使用服务器端和客户端呈现
重新水合是一种方法,它尝试通过同时进行客户端呈现和服务器端呈现来平衡两者之间的权衡。导航请求(例如完整网页加载或重新加载)由将应用呈现为 HTML 的服务器处理,然后用于呈现的 JavaScript 和数据会嵌入到生成的文档中。如果谨慎操作,这种方法可以实现与服务器端呈现类似的快速 FCP,然后通过在客户端上重新呈现来“接收”内容。这是一种有效的解决方案,但可能会存在明显的性能缺陷。
启用重新水合的服务器端渲染的主要缺点是,即使可以提高 FCP,但也可能会对 TBT 和 INP 产生严重的负面影响。服务器端呈现的网页可能看起来已加载且可互动,但在执行组件的客户端脚本并附加事件处理脚本之前,实际上无法响应输入。在移动设备上,这可能需要几分钟的时间,会让用户感到困惑和沮丧。
重新激活问题:一款应用的价格是两款应用的价格
为了让客户端 JavaScript 能够准确地“接着”服务器上次中断的位置继续执行,而无需重新请求服务器用于呈现 HTML 的所有数据,大多数服务器端呈现解决方案都会将来自界面数据依赖项的响应序列化为文档中的脚本标记。由于这会复制大量 HTML,因此重新激活可能会导致的问题不仅仅是延迟互动。
服务器会响应导航请求返回应用界面的说明,但也会返回用于组成该界面的源数据,以及界面实现的完整副本,该副本随后会在客户端上启动。只有在 bundle.js
加载并执行完毕后,界面才会变为交互式。
从使用服务器端呈现和重新水合功能的真实网站收集的性能指标表明,这很少是最佳选择。最重要的原因是,如果网页看起来已准备就绪,但其互动功能全部无法使用,这会对用户体验产生影响。
不过,服务器端渲染结合重新水化还是有希望的。从短期来看,仅对高度可缓存的内容使用服务器端渲染即可缩短 TTFB,从而产生与预渲染类似的结果。未来,若要让这种技术更可行,关键在于增量、渐进或部分重新水化。
流式传输服务器端呈现内容并逐步重新激活
服务器端渲染在过去几年里取得了诸多进展。
借助流式服务器端渲染,您可以分块发送 HTML,以便浏览器在接收时逐渐渲染。这样可以更快地向用户提供标记,从而加快 FCP 速度。在 React 中,与同步 renderToString()
相比,renderToPipeableStream()
中的流是异步的,这意味着系统会妥善处理回压。
渐进重新加水也值得考虑,React 已实现了它。采用这种方法,服务器呈现的应用的各个部分会随着时间的推移而“启动”,而不是采用当前常见的方法,即一次初始化整个应用。这有助于减少使网页可互动所需的 JavaScript 量,因为它可让您推迟对网页低优先级部分的客户端升级,以防止其阻塞主线程,从而让用户在发起互动后更快地实现互动。
渐进式重新水化还可以帮助您避免服务器端呈现重新水化最常见的陷阱之一:服务器呈现的 DOM 树被销毁,然后立即重建,这通常是因为初始同步客户端呈现所需的数据尚未准备就绪,通常是尚未解析的 Promise
。
部分重构
事实证明,部分重新水合很难实现。这种方法是渐进式重新激活的扩展,可分析网页的各个部分(组件、视图或树),并识别互动性较低或没有任何响应的部分。然后,对于这些大部分是静态的部分,相应的 JavaScript 代码会转换为惰性引用和装饰性功能,从而将其客户端占用空间几乎降为零。
部分水合方法本身也存在问题和妥协。这对缓存提出了一些有趣的挑战,而客户端导航意味着我们不能假定在未完全加载网页的情况下,应用中不活跃部分的服务器呈现 HTML 是可用的。
三态渲染
如果您可以使用服务工件,请考虑使用三态渲染。借助此技术,您可以对初始导航或非 JS 导航使用流式服务器端渲染,然后在服务工件安装后让其负责导航的 HTML 渲染。这样可以让缓存的组件和模板保持最新状态,并启用 SPA 风格的导航,以便在同一会话中呈现新视图。如果您可以在服务器、客户端页面和服务工件之间共享相同的模板和路由代码,此方法最适合。
SEO 注意事项
在选择 Web 呈现策略时,团队通常会考虑 SEO 的影响。服务器端呈现是一种常见的选择,可提供可供抓取工具解读的“完整外观”体验。抓取工具可以理解 JavaScript,但在呈现方式上通常存在限制。客户端渲染可以正常运行,但通常需要进行额外的测试并增加开销。最近,如果您的架构严重依赖于客户端 JavaScript,动态呈现也成为一个值得考虑的选项。
如有疑问,您可以使用移动设备适合性测试工具来测试您选择的方法是否能达到预期效果。它会直观地预览任何网页在 Google 抓取工具中的显示方式、抓取工具在执行 JavaScript 后找到的序列化 HTML 内容,以及呈现过程中遇到的任何错误。
总结
在确定渲染方法时,请衡量并了解瓶颈问题。考虑静态呈现或服务器端呈现是否可以解决大部分问题。您可以主要使用 HTML 和少量 JavaScript 来实现互动体验。下面是一个实用的信息图,展示了服务器-客户端谱系:
赠金
感谢以下所有人提供反馈和启发:
Jeffrey Posnick、Houssein Djirdeh、Shubhie Panicker、Chris Harrelson 和 Sebastian Markbåge