乐趣区

Web渲染那些事儿

作为开发者,经常需要面对影响整个应用架构的决策。而 Web 开发者的核心决策之一,就是应用逻辑与渲染工作的实现,应处于架构中的什么位置(译注:客户端 or 服务器?)。现在有很多不同构建网站的方法,因此这些决策变得愈加困难。
我们对这一领域的理解,来自于我们过去几年在 Chrome 工作中,与大型网站的交流。从广义上讲,我们鼓励开发人员考虑通过一种称为 rehydration 的方式,进行服务器渲染或静态渲染。
为了更好地理解在做出决定时所选择的架构,我们需要对每种方法有充分的理解,并且在谈到它们时使用一致的术语。
术语
渲染

SSR:服务器渲染(Server-Side Rendering)——在服务器上将客户端或通用 (universal) 应用程序渲染成 HTML。

CSR:客户端渲染(Client-Side Rendering)——在浏览器中渲染 App,通常使用 DOM。

Rehydration:在客户端上“启动”JavaScript 视图,复用服务器渲染的 HTML DOM 树和数据。(译注:利用服务器返回 HTML 中的 JS 数据,重新渲染页面的技术,详见知乎讨论,其中《三体》的部分很形象~)

预渲染(Prerendering):在构建时运行客户端应用程序,以将其初始状态捕获为静态 HTML。

性能

TTFB:首字节时间(Time to First Byte)——从点击链接 到 接收第一个字节内容 之间的时间。

FP:首次绘制(First Pain)——第一次有像素对用户可见的时间。

FCP:首次内容绘制(First Contentful Paint)——请求内容(文章正文等)变得可见的时间。

TTI:可交互时间(Time To Interactive)——页面变为可交互的时间(事件绑定等)。

服务器渲染(Server Rendering)
服务器渲染,指在服务器中生成整个页面的 HTML,以此响应请求的技术。这样做避免了在客户端上进行数据获取的额外往返 (round-trips) 和模板处理,因为这些工作在浏览器获得响应之前,已由服务器处理了。
服务器渲染通常会得到快速的首次绘制(FP)和首次内容绘制(FCP)。在服务器上运行页面逻辑和渲染,可以避免向客户端发送大量 JavaScript,有助于实现快速的可交互时间(TTI)。这之所以行得通,因为服务器渲染的本质,只是向用户浏览器发送文本和链接。这种方法适用于广泛的设备和网络,并能触发一些有趣的浏览器优化,比如流文档解析。

使用服务器渲染,用户不再需要在客户端上等待 CPU 相关的 JavaScript 处理后,然后才能访问站点。即使第三方 JS 无法避免,使用服务器渲染来减少自己的 JS 成本,也能提供更多的性能“预算”。但是,这种方法有一个主要缺点:在服务器上生成页面有一定耗时,可能会导致较慢的首字节时间(TTFB)。
服务器渲染是否满足应用程序,很大程度上取决于构建目标的体验类型。关于服务器渲染与客户端渲染的正确应用存在长期争论,但重要的是我们可以选择对某些页面使用服务器渲染,而对其余页面不使用。一些网站已成功采用混合渲染技术:Netflix 服务器渲染其相对静态的落地页面,同时为交互繁重的页面预拉取 JS,为这些重客户端页面提供更快的加载能力。
许多现代框架、库和架构,使得在客户端和服务器上渲染相同的应用程序成为可能。这些技术可用于服务器渲染,但是要注意,在服务器和客户端上进行渲染的架构,都是各框架自家的解决方案,具有不同的性能特点和权衡。React 用户可以使用 renderToString() 或在其上构建的解决方案如 Next.js,用于服务器渲染;Vue 用户可以查看 Vue 的服务器渲染指南或 Nuxt;Angular 有 Universal。大部分流行的解决方案采用某种 hydration 的形态,因此在选择工具之前要注意使用的方法。
静态渲染(Static Rendering)
静态渲染在构建时进行,并提供快速的 FP、FCP 和 TTI——假设客户端 JS 的体积得当。与服务器渲染不同,它还致力于实现始终如一的快速首字节时间(TTFB),因为页面的 HTML 不必动态生成。通常,静态渲染意味着提前为每个 URL 生成单独的 HTML 文件。通过预先生成 HTML 响应,可以将静态渲染部署到多个 CDN 以利用边缘缓存。(译注:也就是“页面静态化”)

静态渲染的解决方案选择很多,像 Gatsby 这样的工具旨在让开发人员感觉他们的应用程序是动态渲染的,而不是构建过程生成的。Jekyl 和 Metalsmith 提供更多模板驱动的方法,更加符合它们的静态特质。
静态渲染的一个缺点是必须为每个可能的 URL 生成单独的 HTML 文件。如果无法提前预测这些 URL 的内容,或者对于具有大量不同页面的网站,这可能具有挑战性甚至是不可行的。
React 用户可能熟悉 Gatsby、Next.js 静态导出或 Navi ——它们都可以方便使用组件。但是,了解静态渲染和预渲染之间的区别非常重要:静态渲染页面是无需执行太多客户端 JS 就可交互的,预渲染则改进了单页面应用的 FP 或 FCP,由于是单页面应用,所以必须等待客户端启动过程,以使页面真正具有交互性。(译注:简单的说静态渲染不依赖客户端 JS,适用于静态页面,而预渲染则依赖 JS,更多是为了富应用的初始界面加速)
如果不确定选择静态渲染还是预渲染方案,请尝试此测试:禁用 JavaScript 并加载创建的网页。对于静态渲染的页面,大多数功能在未启用 JavaScript 下仍然正常运作。而对于预渲染页面,一些基本功能(如链接)能正常展现,但页面其余部分无法正常展现。
另一个有效的测试是使用 Chrome DevTools 减慢网络速度,并观察在页面变为可交互之前已下载了多少 JavaScript。预渲染通常需要更多的 JavaScript 来实现交互,并且这些 JS 往往比静态渲染使用的渐进增强方法更复杂。
服务器渲染 vs 静态渲染
服务器渲染并不是银弹——它的动态特性带来显著的计算成本。许多服务器渲染解决方案会有耗时,导致延迟的 TTFB 或成倍的数据传输(例如,客户端 JS 所需的内联状态)。在 React 中,renderToString() 可能很慢,因为它是同步和单线程的。服务器渲染“正确”的姿势,可能涉及查找或构建组件缓存方案、内存消耗管理、应用记忆化技术以及许多其他方面。同一个应用程序通常需要多次处理 / 重建——一次在客户端中,一次在服务器中。因此服务器渲染可以使某些东西更快地显示出来,但并不意味着可以减少工作量。
服务器渲染为每个 URL 按需生成 HTML,但速度可能比仅提供静态渲染内容要慢。如果加以进行额外的工作,服务器渲染 + HTML 缓存,可以大大减少服务器渲染时间。服务器渲染的优势在于,能够提取更多“实时”数据,并响应比静态渲染更完整的请求集。个性化页面就是一个不适用于静态渲染的页面类型代表。
在构建 PWA 时,服务器渲染也抛出一个有趣的问题。整个页面使用 Service Worker 缓存,与服务器渲染部分内容片段,哪个方案更好?
客户端渲染(Client-Side Rendering,CSR)
客户端渲染(CSR)意味着使用 JavaScript 直接在浏览器中渲染页面。所有逻辑、数据获取、模板和路由都在客户端处理,而不是服务器上。
客户端渲染很难在移动端做到很快。如果做好压缩工作,严格控制 JavaScript 预算,并在尽可能少的 RTT 中提供内容,它可以接近纯服务器渲染的性能。使用 HTTP/2 Server Push 或 <link rel = preload> 可以更快地提供关键脚本和数据,这将使解析器更快地完成工作。像 PRPL 这样的模式值得评估,以确保初始和后续导航的即时感。

客户端渲染的主要缺点是,随着应用程序的发展,所需的 JavaScript 数量会增加。随着添加新的 JavaScript 库、polyfill 和第三方代码,更是一发不可收拾。这些代码会竞争处理能力,并且通常必须在渲染页面内容之前完成处理。构建依赖大型 JavaScript 的 CSR 应用时,应该考虑积极的代码分割,并确保延迟加载 JavaScript——“只在需要时提供所需内容”。对于很少或没有交互性的页面,服务器渲染可以作为更具扩展性的解决方案。
对于构建单页应用程序的人来说,识别大多数页面共享的 UI 核心部分,意味着可以应用 Application Shell 缓存技术。与 Service Worker 相结合,可以显著提高重复访问的感知性能。
通过 Rehydration 将服务器渲染和 CSR 相结合
这种方法通常被称为通用渲染或简称为“SSR”,它试图通过两者兼顾来平滑客户端渲染和服务器渲染之间的权衡。页面请求交由服务器处理,将应用程序渲染为 HTML,然后把用于渲染的 JavaScript 和数据,嵌入到生成的文档中。只要处理得当,这就像服务器渲染一样实现了快速的 FCP,然后通过称为 (re)hydration 的技术,在客户端上再次“拾取”来渲染。这是一种新颖的解决方案,但也具有一些明显性能缺陷。译注:如果这里不好理解,请先理解上面术语部分中 Rehydration 的知乎链接内容。
rehydration 后的 SSR 主要缺点,是它会对可交互时间 (TTI) 产生显著的负面影响,即使它改善了首次绘制(FP)。SSR 页面通常看起来具有欺骗性的加载完成和可交互性,但在执行客户端 JS 并绑定事件处理之前,页面实际上无法响应输入。这在移动设备上可能持续几秒甚至几分钟。
也许你自己也经历过这种情况——在页面看起来已经加载后的一段时间内,点击或触摸什么都没反应。这很快变得令人沮丧 ……“为什么没有反应?为什么我不能滚动?“
一个 Rehydration 问题:应用的双重成本
由于 JS 特性,Rehydration 问题往往比延迟交互更糟糕。为了使客户端 JavaScript 能够不用重新请求服务器,就能准确地获取服务器返回的用于呈现其 HTML 的所有数据,当前的 SSR 解决方案通常将 UI 的数据响应序列化,以 Script 标签形式存放在 HTML 中。结果是生成的 HTML 文档包含大量重复片段:

正如你所看到的,服务器除了返回应用程序 UI 以响应页面请求,还返回了用于组成该 UI 的源数据,以及生成相同 UI 的实现代码,即刻在客户端上运行。只有在 bundle.js 完成加载和执行后,页面才会变为可交互。
从使用 Rehydration SSR 站点收集的性能数据显示,这种用法应极力避免。归根结底,原因归结为用户体验:很容易让用户处于“不明所以”的状态。

Rehydration SSR 也不是没有希望。在短期内,仅将 SSR 用于高度可缓存的内容,可以减少 TTFB 延迟,从而达到与预渲染类似的结果。
流式服务器渲染和渐进式 Rehydration
服务器渲染在过去几年中发展迅猛。
流式服务器渲染能以 chunk 形式发送 HTML,浏览器可以在接收时逐块渲染。这促成了快速的 First Paint 和 First Contentful Paint,因为 HTML 标签更快地到达用户侧。在 React 中,流在 renderToNodeStream() 中异步处理,相比于同步的 renderToString,服务器的压力也会更小。
渐进式 Rehydration 也值得关注,React 一直在探索。使用这种方法,服务器渲染后的页面各部分,随着时间推移被“启动”,而不是通常一次初始化整个应用程序的做法。这可以减少页面可交互所需的 JavaScript 量,因为可以延迟页面低优先级部分,以防止阻塞主线程。它还可以帮助避免最常见的 SSR Rehydration 陷阱:服务器渲染的 DOM 树被破坏后立即重建——通常是因为客户端初始同步渲染所需的数据还没准备好,比如还在等待 Promise 的解析。
部分 Rehydration
部分 Rehydration 已被证明难以实现。该方法是渐进式 Rehydration 概念的扩展,通过分析渐进式 Rehydration 的各个部分(组件 / 视图 / 树),识别出那些不具交互性的部分。对于每个基本静态的部分,相应的 JavaScript 代码会被转换为惰性引用和装饰功能,将其客户端占用空间减少到接近于零。部分 Rehydration 方案伴随着自身的问题和妥协。它为缓存带来了一些有趣的挑战,我们无法假设服务器渲染的惰性部分 HTML,在页面完整加载前是可用的。
三方同构渲染(Trisomorphic Rendering)
如果可以使用 service worker,“trisomorphic”渲染也很有意思。该技术是指,利用流式服务器渲染初始页面,等 Service Worker 加载后,接管 HTML 的渲染工作。这可以使缓存的组件和模板保持最新,并启用 SPA 式的导航以在同一会话中渲染新视图。当可以在服务器、客户端页面和 Service Worker 之间共享相同模板和路由代码时,此方法最有效。
SEO 考虑
在选择渲染策略时,团队通常会考虑 SEO 的影响。为了让爬虫能够轻松获得“完整页面”,服务器渲染是不二的选择。虽然爬虫可能会理解 JavaScript,但是在渲染方式上的局限性需要注意。如果你的应用非常重 JavaScript,最近的动态渲染方案也是个值得考虑的选择。
如果有疑问,Mobile-Friendly Test 工具对于测试你选择的方法是否符合预期,非常有用。它展示了 Google 爬虫渲染页面的预览、序列化的 HTML 内容(执行 JavaScript 后),以及渲染过程中发生的错误。
总结
在决定渲染方式时,需要测量和理解真正的瓶颈在哪里。静态渲染或服务器渲染在多数情况都比较适用,尤其是可交互性对 JS 依赖较低的场景。下面是一张便捷的信息图,显示了服务器到客户端的技术频谱:

退出移动版