← 全部文章

简析 Web 渲染架构

这篇是 2022 年整理 Web 渲染方式时的笔记,主要围绕 React 18 的 Suspense SSR 讨论。下面的指标、术语和框架状态都放在这个时间背景里看。

我关注的是:HTML 在哪里生成,数据什么时候获取,浏览器又在什么时候接上交互?

The future of rendering in React

New Suspense SSR Architecture in React 18 · Discussion #37 · reactwg/react-18

Rendering on the Web

Remix and “The Edge”

相关术语

先记下阅读资料时用到的术语:

  • Time To First Byte (TTFB) :发出页面请求到接收到应答数据第一个字节所花费的时间;
  • First Paint (FP) :第一个像素对用户可见的时间。———>页面第一次被渲染出来
  • First Contentful Paint (FCP):第一条内容可见所需的时间。———>第一条有意义的内容被渲染出来
  • Largest Contentful Paint (LCP) :页面最大内容元素呈现的时间,不等同于所有内容都已渲染
  • Time To Interactive (TTI):页面变为交互并可靠响应用户事件的时间。
  • ~~First Input Delay (FID):进行交互到浏览器响应的时间。~~
  • ~~Cumulative Layout Shift (CLS): 累积布局偏移 (CLS) 是测量视觉稳定性的一个以用户为中心的重要指标,因为该项指标有助于量化用户经历意外布局偏移的频率,较低的 CLS 有助于确保一个页面是令人愉悦的。~~
  • CSR:客户端渲染 - 在浏览器中渲染应用程序,一般使用DOM。
  • SSR:服务器端渲染 - 在服务器上将客户端或通用应用程序渲染成HTML。
  • 瀑布模型:由 props(水管)和 state(水源)把组件组织起来,组件间数据流向类似于瀑布。数据流向总是从祖先到子孙(从根到叶子),不会逆流
  • hydrate 水合:在客户端启动 JavaScript视图,使其重新使用服务器渲染的HTML的DOM树和数据。
  • dehydrate 脱水:原笔记未展开。
  • 预渲染:在构建时运行客户端应用程序,将其初始状态捕捉为静态HTML。

一图流

Untitled

前端渲染方式

CSR & SSR

当时阅读的资料先从 create-react-app 一类启动器常见的客户端渲染方式讲起,再比较 Next.js、Remix 等框架的服务端方案。

在这个 CSR 模型里,服务器主要返回带脚本和资源链接的 HTML。浏览器加载 JavaScript 后,由 React 生成界面,并在客户端处理路由与数据请求。

CSR 的初始响应可以主要由静态资源组成,但内容需要等待 JavaScript 执行。若组件挂载后才开始请求数据,还会多一段等待,影响内容出现的时间。

CSR优点:

  • 初始资源是静态文件时,可以通过 CDN 分发。
  • 客户端路由可以在不刷新整页的情况下切换界面。
  • 静态响应有机会较早开始传输,浏览器再加载字体、CSS 和 JavaScript;TTFB 仍受网络和服务配置影响。

CSR缺点:

  • 内容依赖客户端代码生成时,首次显示需要等待代码下载与执行。
  • 组件挂载后才发请求,会增加等待。子组件还要等父组件的数据才能出现时,请求也可能被串行化,形成网络瀑布。
  • 爬虫若不执行完整的客户端代码与请求,可能拿不到页面内容,索引结果需要另外检查。

SSR 在服务端生成页面 HTML,让用户先看到内容。交互仍可能需要等待客户端代码加载和水合,所以“先看到”与“能操作”是两件事。服务器输出的 HTML 也更方便直接索引;具体首屏与交互表现还要看数据和代码的加载过程。

当时阅读的资料还提醒不要混淆 SSR 与服务器组件。资料中的服务器组件仍处于实验阶段;这篇没有进一步讨论它,也不把当时的状态当成今天的结论。

ssr.png

React 18 讨论里的三个等待

下面根据前面链接的 React 18 工作组讨论,整理传统整树 SSR 的限制。引用中的“目前”“今天”指讨论时的机制。

1. 你必须在显示任何东西之前获取所有东西

讨论指出,在当时的整树 SSR 模型里,组件数据要先准备好,才能生成完整 HTML。一个慢请求可能拖住页面其他部分:

例如,假设你想渲染一个带有评论的帖子。评论对早期显示很重要,所以你想在服务器的HTML输出中包含它们。但你的数据库或API层很慢,这是你无法控制的。现在你必须做出一些艰难的选择。如果你把它们排除在服务器输出之外,用户在JS加载之前不会看到它们。但如果你把它们包括在服务器输出中,你就必须推迟发送其余的HTML(例如,导航栏、侧边栏,甚至是文章内容),直到评论加载完毕,你才能呈现完整的树形。这不是很好。 顺便提一下,一些数据获取解决方案反复尝试将树渲染成HTML,并丢弃结果,直到数据被解决,因为React没有提供一个更符合人体工程学的选项。我们想提供一个不需要如此极端妥协的解决方案。

2. 你必须在加载所有的东西之后才能进行水化处理

水合需要客户端生成的树与服务端 HTML 对应。在讨论中的旧模型里,整树水合需要先加载所有组件代码;某个部件的代码很大,也会拖住其他区域:

例如,假设评论小部件包含很多复杂的交互逻辑,而为它加载JavaScript需要一段时间。现在你不得不再次做出艰难的选择。把服务器上的评论渲染成HTML,以便尽早向用户展示,这是很好的做法。但是,因为今天的水化只能在一次完成,所以在加载评论小部件的代码之前,你不能开始水化导航栏、侧边栏和文章内容。当然,你可以使用代码分割并单独加载它,但你必须将评论从服务器HTML中排除。否则React将不知道如何处理这块HTML(它的代码在哪里?)并在水化过程中删除它。

3. 在与任何东西进行交互之前,你必须对所有东西进行水合。

第三个等待发生在水合执行期间:如果整棵树需要一次处理完,某个昂贵的部件会推迟其他区域可交互的时间。下面引用中的“今天”仍指原讨论的时间:

例如,我们假设评论部件有昂贵的渲染逻辑。它在你的电脑上可能工作得很快,但在低端设备上运行所有这些逻辑并不便宜,甚至可能会锁定屏幕几秒钟。当然,在理想情况下,我们根本就不会在客户端有这样的逻辑(这一点服务器组件可以帮助我们解决)。但对于某些逻辑来说,这是不可避免的,因为它决定了所附的事件处理程序应该做什么,而且对于交互性来说是至关重要的。因此,一旦水化开始,用户就不能与导航栏、侧边栏或文章内容互动,直到整个树被水化。对于导航来说,这是特别不幸的,因为用户可能想完全离开这个页面,但由于我们忙于水化,我们把他们留在他们不再关心的当前页面。

我们如何才能解决这些问题?

共同的问题是每个阶段都等待前一阶段全部结束,形成了这样的瀑布:

获取数据(服务器)→渲染成HTML(服务器)→加载代码(客户端)→水化(客户端)

如果每个阶段都等待前一阶段全部完成,某个慢组件就会拖住整棵树。讨论提出的方向是把工作拆开,让屏幕的一部分先进入下一阶段,而不必等待整个应用。

这并不是一个新奇的想法:例如,Marko是实现这种模式的一个版本的JavaScript网络框架。挑战在于如何将这样的模式适应于React编程模型。这花了一些时间来解决。React在2018年为此目的引入了<Suspense>组件。当我们引入它时,我们只支持它在客户端的懒惰加载代码。但我们的目标是将它与服务器渲染结合起来,解决这些问题。

React 18 的讨论将这个方向与 <Suspense> 联系起来。这篇主要记录上述问题,暂未补上完整实现示例。

SSG & ISR & DPR

SSR 把渲染工作放到服务器,需要考虑服务端计算、带宽和用户到服务器的网络距离。

SSG 在构建时生成静态页面,可以通过 CDN 分发。变化频繁的部分仍可通过 HTTP API 在客户端获取。更新静态内容需要重新生成相应页面,具体重建范围取决于工具。

csr-2.png

需要生成的页面越多,构建时间就越值得关注。当时笔记还用了 ISG 这个分类:先生成关键页面,非关键页面访问时再处理,可能返回 fallback,再异步生成并缓存页面。术语与行为要按框架配置区分。

ISR 这部分,我当时关注的是返回旧缓存、后台再生成带来的数据新鲜度问题:用户可能先看到旧页面,下一次访问才拿到新版本。fallback 的展示也会影响体验。这不是所有 CDN 的统一原则,而是需要看具体缓存和再生成策略。

DPR 这部分记录的是 On-demand Builder(按需构建器)的思路:首次访问时生成,再提交到缓存。首次生成需要多久、请求集中到来时如何处理,是我当时关心的问题。这篇没有部署或压测结果。

接下来再看按页面区域划分交互的方式。

孤岛架构

笔记记录岛屿架构由 Etsy 前端架构师 Katie Sylor-Miller 在 2019 年的一次会议上提出。思路是先由服务端输出 HTML,再让需要交互的区域分别加载客户端代码和水合。这些区域像嵌在静态页面里的小应用,不必把整页当成同一个交互单元。

csr-1.png

它与微前端都涉及拆分单元,但拆分的目标和组装方式不同,不能因为都有独立部件就把两者等同。

这里也可以结合渐进式增强理解:基本内容先以 HTML 提供,再为需要的区域加载交互逻辑。客户端接上的主要是这些区域的代码与行为,不应把水合理解为向服务器重新请求一块 DOM。

当时阅读的资料主要讨论了三个方面:

  • 分别初始化交互区域:React、Angular、Preact、Vue 等框架中的小部件可以按区域加载和初始化。笔记提到 requestIdleCallback 作为调度思路。各区域不必先等待整页的代码,但具体调度和性能隔离仍要看实现,不能保证一个部件完全不影响其他部件。
  • 内容显示与交互接入:服务端 HTML 可以先提供主要内容,交互随后接上。需要检查水合时是否匹配已有 HTML,以及用户等待交互的时间,不能仅凭 SSR 就判断体验好坏。
  • 可访问性与可发现性:标准 HTML 链接让基本导航不依赖客户端代码。链接仍需包含目标页面必要的信息;按页面组织并不会自动保证分享链接完整。

岛屿架构需要先确定哪些区域需要客户端交互。对于内容为主、只有部分区域动态的小型网站,这是我当时比较感兴趣的方向。它与 React 的渐进式水合有相近目标,但不能直接当成同一个架构实现。

流式 SSR

从 Islands Architecture 看前端有多卷

对比

Untitled