Web 页面渲染

网页渲染

2019-02-19

译注:本文是 Rendering on the Web 的中文翻译。 By Jason Miller
By Addy Osmani

# 前言

作为开发人员,我们经常面临将影响应用程序整个体系结构的决策。Web 开发人员必须做的核心决策之一是在应用程序中实现逻辑和渲染。这可能很困难,因为有很多不同的方法来构建网站。 我们对这一领域的理解,来自于我们在 Chrome 的工作,在过去的几年里,我们与很多大型网站进行了讨论。从广义上讲,我们鼓励开发人员考虑服务器渲染或静态渲染,而不是完全 rehydration 的方法。 为了更好地理解我们在做出这一决定时所选择的体系结构,我们需要对每种方法有充分的理解,并在谈到它们时使用一致的术语。这些方法之间的差异有助于说明 Web 渲染在性能方面的权衡。

译注:rehydration 这一词未有合适的中文术语,后文会解释其含义。

# 术语

渲染

  • SSR(Server-Side Rendering): 服务器端渲染 - 在服务器上将客户端或通用应用程序渲染为 HTML。
  • CSR(Client-Side Rendering): 客户端渲染 - 通常使用 DOM 在浏览器中渲染应用程序。
  • Rehydration: “引导”客户端上的 JavaScript 视图,以便重用服务器渲染的 HTML 的 DOM 树和数据。
  • Prerendering: 在构建时运行客户端应用程序,将其初始状态捕获为静态 HTML。

性能

  • TTFB(Time to First Byte): 第一个字节的时间 - 从点击链接到收到第一个字节内容的时间。
  • FP(First Paint): 第一次绘制 - 用户第一次看到任何像素内容的时间。
  • FCP(First Contentful Paint): 第一次内容绘制 - 用户第一次看到请求内容(比如,文章正文)的时间。
  • TTI(Time To Interactive): 可交互时间 - 页面变为可交互的时间(比如,可响应事件)。

# 服务器渲染

服务器渲染是为服务器上的页面生成完整的 HTML 以响应导航。 这避免了在客户端上进行数据获取和模板化的额外往返时间,因为它是在浏览器获得响应之前处理的。

服务器渲染通常会让 FP 和 FCP 变快。 在服务器上运行页面逻辑和渲染可以避免向客户端发送大量 JavaScript,这有助于实现快速的可交互时间(TTI)。 这是有意义的,因为通过服务器渲染,只需向用户的浏览器发送文本和链接。这种方法可以很好地适用于大量设备和网络条件,并打开类似流文档解析的浏览器优化。 1.png

有了服务器渲染,用户就不需要在依赖 CPU 的 Javascript 执行完毕后才能访问你的站点。即使在第三方 JS 无法避免的情况下,使用服务器渲染也可以降低你自己的一方 JS 成本,也可以为其他部分提供更多的性能“预算”。然而,这种方法的一个主要缺点是:在服务器上生成页面需要时间,这通常会导致 TTFB 较慢。 服务器渲染是否能满足应用程序需求,很大程度上取决于正在构建的体验类型。关于服务器渲染与客户端渲染的正确应用存在长期争论,但重要的是要记住,你可以选择某些页面使用服务器渲染,其他页面不使用服务器渲染。一些网站已成功采用混合渲染技术。 Netflix 使用服务器渲染其相对静态的登陆页面,而交互较多的页面则使用预取 JS 技术,该技术使得这种客户端渲染页面更快速的加载。 很多现代框架、库和体系结构使得在客户端和服务器上渲染相同的应用程序成为可能。这些技术可用于服务器渲染,但需要注意的是,无论在服务器上还是在客户端上进行渲染的体系结构,都是他们自己的一类解决方案,具有非常不同的性能特征和权衡。 React 用户可以使用 renderToString() 或构建在其上的解决方案,比如 Next.js 用于服务器渲染。Vue 用户可以查看 Vue 的服务器渲染指南或 Nuxt。Angular 有 Universal。然而,大多数流行的解决方案会采用某种形式的 hydration,因此在选择工具之前要注意使用的方法。

# 静态渲染

静态渲染在构建时发生,并提供快速的 FP,FCP和TTI - 假设客户端 JS 的数量有限。与服务器渲染不同,因为页面的 HTML 不必动态生成,它还可以实现非常快的首字节时间。通常,静态渲染意味着提前为每个 URL 生成单独的 HTML 文件。通过预先生成 HTML 响应,可以将静态渲染文件部署到多个 CDN 以利用边缘缓存。 2.png

有各种各样的静态渲染解决方案。像 Gatsby 这样的工具,旨在让开发人员感觉他们的应用程序是动态渲染的,而不是作为构建步骤生成的。其他如 Jekyl 和 Metalsmithes 则采用了它们的静态特性,提供了一种更加模板驱动的方法。静态渲染的一个缺点是必须为每个可能的 URL 生成单独的 HTML 文件。如果你无法提前预测这些 URL 的内容,或者对于具有大量不同页面的网站,这很有挑战性甚至是不可行的。React 用户可能熟悉 Gatsby,Next.js 静态导出或 Navi - 所有这些都使得编写人员使用组件更加方便。但是,理解静态渲染和预渲染之间的区别非常重要:静态渲染页面无需执行太多客户端 JS 就可交互,而预渲染提升了单页应用程序的 FP 或 FCP,这些应用程序必须在客户端上引导才能使页面真正具有交互性。 如果不确定给定的解决方案是静态渲染还是预渲染,请尝试此测试:禁用 JavaScript 并加载创建的网页。对于静态渲染的页面,在没有启用 JavaScript 的情况下,大多数功能仍然存在。对于预渲染的页面,可能仍然有一些基本的功能,如链接,但大部分页面功能将是无效的。另一个有用的测试是使用 Chrome DevTools 减缓您的网络速度,并观察在页面变得可交互之前已经下载了多少 JavaScript。预渲染通常需要更多的 JavaScript 来实现交互,而且 JavaScript 往往比静态渲染所使用的渐进增强方法更复杂。

# 服务器渲染与静态渲染

服务器渲染不是一颗灵丹妙药 - 它的动态特性可以带来巨大的计算开销。许多服务器渲染解决方案不会提前刷新,可能会延迟 TTFB 或发送两倍的数据(例如在页面上内联 JS)。在 React 中,renderToString() 可以是缓慢的,因为它是同步的和单线程的。要使服务器渲染“正确”,可以找到或构建用于组件缓存的解决方案、管理内存消耗、应用 memoization 技术以及许多其他关注事项。通常需要多次处理/重新构建同一个应用程序 - 一次在客户机上,一次在服务器上。仅仅因为服务器呈渲染可以使某些东西更快地显示出来,并不意味着您有更少的工作要做。服务器渲染为每个 URL 按需生成 HTML,但比只提供静态渲染的内容要慢。如果您可以进行额外的工作,服务器渲染 + HTML缓存(HTML caching) 可以大大减少服务器渲染时间。服务器渲染的好处是能够提取更多的“活动”数据,并响应一组比静态渲染更完整的请求。需要个性化的页面,一般不适合使用静态渲染。在构建 PWA 时,服务器渲染也可以提供有趣的决策。整个页面使用 Service Workers 缓存,还是使用服务器渲染部分内容,那个更好?

# 客户端渲染

客户端渲染(CSR)是指使用 JavaScript 在浏览器中直接渲染页面。所有的逻辑、数据获取、模板化和路由都是在客户端而不是服务器上处理的。

客户端渲染可能很难在移动端做到很快。如果要做到接近纯服务器渲染的性能,需要(1)只渲染少量的内容,(2)尽量少的 JavaScript 执行,(3)尽可能少的 RTT。 使用 HTTP/2 Server Push 或 可以更快地传递关键脚本和数据,从而使解析器更快地开始工作。像 PRPL 这样的模式是值得使用的,以确保初始和后续导航的即时性。 3.png

客户端渲染的主要缺点是,随着应用程序的增长,所需 JavaScript 的数量往往会增加。随着新的 JavaScript 库、polyfills 和第三方代码的添加,这变得特别困难,这些代码会竞争 CPU 处理能力,并且必须在页面内容渲染之前进行处理。如果 CSR 依赖大型 JavaScript 包,应该考虑进行代码拆分,并确保延迟加载 JavaScript - “只在需要时提供所需的服务”。对于很少或没有交互性体验的页面,服务器渲染可以提供更灵活的解决方案。 对于构建单个页面应用程序的人员来说,大多数页面共享的用户界面的核心部分,这就可以使用 ApplicationShell 缓存技术。与 Service Workers 结合,可以极大地提高重复访问的性能体验。

# 通过 rehydration 将服务器渲染和客户端渲染结合起来

通常被称为通用渲染或简单的“SSR”,这种方法试图抹平客户端渲染和服务器渲染之间的差异。导航请求(如整页加载或重新加载)由服务器处理,服务器将应用程序渲染为 HTML,然后将用于渲染的 JavaScript 和数据嵌入到结果文档中。这实现了与服务器渲染一样快速的FCP,然后通过在客户端上使用一种称为(re)hydration 的技术再次渲染后续的内容。这是一个新的解决方案,但它可能有一些相当大的性能缺陷。 SSR + rehydration 的主要缺点是,虽然它改善了 FP,但它会对 TTI 有较大的负面影响。SSR 页面通常看起来像是加载的和交互的,但是在执行客户端 JS 和附加事件处理程序之前实际上无法响应输入。这在手机上可能需要几秒钟甚至几分钟。也许你自己也经历过这种情况 - 在一段时间后,它看起来像是一个页面已经加载完成,但点击无法响应。这很令人沮丧。“为什么没响应?为什么不能滚动?“

# Rehydration 问题:重复

Rehydration 问题往往比 JS 造成的延迟可交互更严重。为了使客户端 JavaScript 能够准确地找到服务器中断的地方,而不必重新请求服务器用于渲染 HTML 的所有数据,当前的 SSR 解决方案通常将响应从 UI 的数据依赖项序列化为脚本标记。生成的 HTML 文档包含高度的重复: 4.png

如你所见,服务器响应导航请求返回应用程序 UI 的描述,但它也返回用于组成该 UI 的源数据,以及 UI 实现的完整副本,然后在客户端上启动。只有在 bundle.js 完成加载和执行之后,这个 UI 才具有交互性。从实际网站收集到的使用 SSR + rehydration 的性能指标来看,非常不鼓励使用这种方案。原因在于用户体验:很容易让用户进入长时间无法响应的情况。 5.png

尽管如此,SSR 还是有希望的。在短期内,只有对很大程度可缓存的内容使用 SSR 才能减少 TTFB 延迟,产生类似于预渲染的结果。再逐步地、或部分地 Rehydrating 可能是使这项技术在未来更加可行的关键。

# 流式服务器渲染和渐进式 Rehydration

服务器渲染在过去几年中取得了一些进展。流式服务器渲染允许以块的形式发送 HTML,浏览器可以在接收到它时渐进渲染 HTML。随着 HTML 内容块更快地到达用户,这可以提供快速的 FP 和 FCP。在 React 中,renderToNodeStream() 中的流是异步的 - 与同步 renderToString 相比 - 意味着背压处理得很好。渐进式 rehydration 也是值得关注的,React 一直在探索。使用这种方法,服务器渲染的应用程序的各个部分会随着时间的推移“启动”,而不是当前的通用一次初始化整个应用程序的方法。这可以帮助减少页面交互所需的 JavaScript 数量,因为客户端可以推迟执行页面低优先级部分内容,以避免阻塞主线程。它还可以帮助避免最常见的 SSR Rehydration 缺陷之一,即服务器渲染的 DOM 树被破坏,然后立即重建 - 最常见的原因是初始的同步客户端渲染所需的数据还没有完全准备好,可能是 awaiting Promise。

# 部分 Rehydration

事实证明,部分 rehydration 很难实现。这种方法是渐进式 rehydration 概念的延伸,其中分析了要渐进式 rehydration 的各个部分(部件/视图/树),并确定了那些相互作用较小或没有反应性的部分。对于这些主要是静态的部分,相应的 JavaScript 代码随后被转换为惰性引用和装饰功能,将它们的客户端占用空间减少到接近零。部分 rehydration 方法有其自身的问题和妥协。它给缓存带来了一些有趣的挑战,而客户端导航意味着,我们不能假设服务器渲染的 HTML,对于页面还未加载完成的应用程序的惰性部分是可用的。

# 三态渲染

如果你选择了 Service Workers,那么你可能会对“三态”渲染感兴趣。这是一种技术,你可以在初始/非 JS 导航中使用流式服务器渲染,然后在 Service Workers 安装之后让其负责 HTML 的渲染。这可以使缓存的组件和模板保持最新,并启用 SPA 样式的导航,用于在同一会话中渲染新视图。当你可以在服务器、客户端页和 Service Workers 之间共享相同的模板代码和路由代码时,此方法最有效。 6.png

# SEO 注意事项

SEO(Search engine optimization),搜索引擎优化。

当选择在 Web 上渲染的策略时,团队通常需要考虑 SEO 的影响。服务器渲染通常会提供“完整的外观”体验,爬虫可以轻松地解释。爬虫可能理解 JavaScript,但在渲染方式上常常存在一些值得注意的限制。客户端渲染可以工作,但通常没有额外的测试。如果你的架构主要由客户端 JavaScript 驱动,动态渲染也是一个值得考虑的选项。 当有疑问时,移动友好型测试工具(Mobile Friendly Test )对于测试所选择的方法是否能实现你的期望是非常宝贵的。它展示了 Google 爬虫中任何页面的外观、找到的序列化 HTML 内容(在 JavaScript 执行后)以及渲染过程中遇到的任何错误。 7.png

# 综述

在决定渲染方法时,需要检查和理解瓶颈是什么。考虑一下静态渲染或服务器渲染是否可以满足 90% 的需求。使用最少的 JS 来获得可交互的体验是完全没有问题的。下面是一张显示服务器-客户端渲染系列的信息图表: 8.png