简析 Web 渲染架构
今天我们来聊聊 web 渲染架构,众所周知,我们的浏览器需要从服务器获取HTML,JS,CSS文件等资源文件进行一个渲染操作,具体到浏览器和服务器之间他们怎么样进行沟通或者怎么样进行数据传输,我们以后再讲。今天的重点是浏览器如何将前端工程从代码变成我们可以看到的东西。
The future of rendering in React
New Suspense SSR Architecture in React 18 · Discussion #37 · reactwg/react-18
相关术语
在深入研究渲染模式之前,让我们来看一下将在这篇文章中使用的一些重要术语:
- 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。
一图流

前端渲染方式
CSR & SSR
在Next.js和Remix等元框架出现之前的一段时间里,客户端渲染,主要是使用create-react-app或其他类似的启动器,是构建React应用的默认方式。
使用CSR,服务器只为每个页面提供包含必要的脚本和链接标签的原始HTML。一旦相关的JavaScript被下载到浏览器上。React会渲染树,并生成所有的DOM节点。所有路由和数据获取的逻辑也由客户端的JavaScript处理。
因此,CSR应用程序有一个快速的时间来首次字节,因为它们主要依靠静态资产。然而,用户必须盯着一个空白的屏幕,直到相关的JavaScript被下载。即使在那之后,大多数现实世界的应用程序也需要从API中获取数据来向用户显示相关数据,这导致了非常缓慢的LCP。
CSR优点:
- 由于客户端的渲染架构包括静态文件,它可以非常容易地通过CDN提供服务。
- 所有的渲染都是在客户端完成的,因此CSR允许我们在不刷新整个页面的情况下进行导航,提供良好的用户体验。
- 到第一个字节的时间很快,因此浏览器可以立即开始加载字体、CSS和JavaScript。
CSR缺点:
- 由于所有的内容都是在客户端渲染的,所以性能会受到很大的影响,因为用户首先需要下载和处理才能看到页面上的内容。
- 客户端渲染应用程序通常会在组件挂载时获取他们需要的数据,这导致了糟糕的用户体验,因为在最初的页面加载时,他们会被一堆加载器迎接。另外,如果子组件有获取数据的要求,但在它们的父组件获取所有数据之前,它们不会被渲染,这可能会导致级联的加载器和糟糕的网络瀑布。
- 最后,搜索引擎优化是客户端渲染应用程序的一个问题,因为网络爬虫可以很容易地读取服务器渲染的HTML,但他们可能不会等待下载所有的JavaScript包,执行它们,并等待客户端的数据获取瀑布完成,这可能导致不当的索引。
SSR 我们都比较熟悉,它是为了解决单页应用(SPA)产生的 SEO、首屏渲染时间等问题而诞生的,在服务端直接实时同构渲染用户看到的页面(类似 PHP)。SSR是一种 “魔术”。它并不能使你的应用程序更快地完全交互。相反,它可以让你更快地显示你的应用程序的非交互式版本,这样用户就可以在等待JS加载时看一下静态内容。然而,这一招对于网络连接不畅的人来说有很大的不同,并提高了整体感知的性能。它也有助于你的搜索引擎排名,这既是由于更容易被索引,也是由于更好的速度。
注意:不要将SSR与服务器组件混淆。服务器组件是一个更具实验性的功能,目前仍在研究中,可能不会成为React 18最初版本的一部分。你可以在这里了解服务器组件。服务器组件是对SSR的补充,并将成为推荐的数据获取方法的一部分,但这篇文章不是关于它们。

更进一步?
上面的方法是可行的,但在许多方面它不是最佳的。
1. 你必须在显示任何东西之前获取所有东西
目前SSR的一个问题是,它不允许组件 “等待数据”。在当前的API中,当你渲染到HTML时,你必须已经在服务器上为你的组件准备好了所有的数据。这意味着,你必须在服务器上收集所有的数据,然后才能开始向客户端发送任何HTML。这是很低效的。
例如,假设你想渲染一个带有评论的帖子。评论对早期显示很重要,所以你想在服务器的HTML输出中包含它们。但你的数据库或API层很慢,这是你无法控制的。现在你必须做出一些艰难的选择。如果你把它们排除在服务器输出之外,用户在JS加载之前不会看到它们。但如果你把它们包括在服务器输出中,你就必须推迟发送其余的HTML(例如,导航栏、侧边栏,甚至是文章内容),直到评论加载完毕,你才能呈现完整的树形。这不是很好。 顺便提一下,一些数据获取解决方案反复尝试将树渲染成HTML,并丢弃结果,直到数据被解决,因为React没有提供一个更符合人体工程学的选项。我们想提供一个不需要如此极端妥协的解决方案。
2. 你必须在加载所有的东西之后才能进行水化处理
在你的JavaScript代码加载后,你会告诉React对HTML进行 “水化”,并使其具有交互性。React会在渲染你的组件时 “走动 “服务器生成的HTML,并将事件处理程序附加到该HTML。要做到这一点,你的组件在浏览器中生成的树必须与服务器生成的树一致。否则React就不能 “匹配它们!” 这样做的一个非常不幸的后果是,你必须在客户端加载所有组件的JavaScript,然后才能开始对其中的任何组件进行水化。
例如,假设评论小部件包含很多复杂的交互逻辑,而为它加载JavaScript需要一段时间。现在你不得不再次做出艰难的选择。把服务器上的评论渲染成HTML,以便尽早向用户展示,这是很好的做法。但是,因为今天的水化只能在一次完成,所以在加载评论小部件的代码之前,你不能开始水化导航栏、侧边栏和文章内容。当然,你可以使用代码分割并单独加载它,但你必须将评论从服务器HTML中排除。否则React将不知道如何处理这块HTML(它的代码在哪里?)并在水化过程中删除它。
3. 在与任何东西进行交互之前,你必须对所有东西进行水合。
水化本身也有一个类似的问题。今天,React一次就把树水化了。这意味着一旦它开始水化(本质上是调用你的组件函数),React将不会停止,直到它完成对整个树的水化。因此,你必须等待所有的组件都被水化,然后才能与它们进行交互。
例如,我们假设评论部件有昂贵的渲染逻辑。它在你的电脑上可能工作得很快,但在低端设备上运行所有这些逻辑并不便宜,甚至可能会锁定屏幕几秒钟。当然,在理想情况下,我们根本就不会在客户端有这样的逻辑(这一点服务器组件可以帮助我们解决)。但对于某些逻辑来说,这是不可避免的,因为它决定了所附的事件处理程序应该做什么,而且对于交互性来说是至关重要的。因此,一旦水化开始,用户就不能与导航栏、侧边栏或文章内容互动,直到整个树被水化。对于导航来说,这是特别不幸的,因为用户可能想完全离开这个页面,但由于我们忙于水化,我们把他们留在他们不再关心的当前页面。
我们如何才能解决这些问题?
这些问题之间有一个共同点。它们迫使你在早做一些事情(但却伤害了用户体验,因为它阻碍了所有其他工作),或晚做一些事情(但却伤害了用户体验,因为你浪费了时间)之间做出选择。 这是因为有一个 “瀑布”:
获取数据(服务器)→渲染成HTML(服务器)→加载代码(客户端)→水化(客户端)
在前一个阶段完成之前,这两个阶段都不能开始,因为应用程序的前一个阶段已经完成。这就是为什么它的效率很低。我们的解决方案是把工作分开,这样我们就可以为屏幕的一部分而不是整个应用程序做这些阶段。
这并不是一个新奇的想法:例如,Marko是实现这种模式的一个版本的JavaScript网络框架。挑战在于如何将这样的模式适应于React编程模型。这花了一些时间来解决。React在2018年为此目的引入了
<Suspense>组件。当我们引入它时,我们只支持它在客户端的懒惰加载代码。但我们的目标是将它与服务器渲染结合起来,解决这些问题。
让我们看看如何在React 18中使用<Suspense>来解决这些问题。
SSG & ISR & DPR
SSR 的缺点是显而易见的,重度依赖服务器端的计算能力和带宽,而且如果服务器距离用户物理距离较远,其加载时间会显著增加。
SSG 就出来了,它将网站内容分为变动不频繁/频繁两种,变动频繁的由 HTTP API 获取然后进行 CSR;变动不频繁的则生成静态页面,进行 CDN 缓存。如果静态页面发生了变化,则进行全站重新渲染并推送 CDN 更新缓存。因为 CDN 缓存和 CSR 能力的加入,页面非首屏的加载速度大幅提升。

SSG 的缺点对于大型网站来说非常致命,因为每次网站更新都会进行全站渲染,网站越大渲染时间也会增加。ISG 为了解决这个问题,将 SSG 砍到只有关键的页面,如首页等,然后非关键的页面先返回一个 fallback 页面使用 CSR 渲染出来,再由服务器进行异步预渲染返回 CDN 缓存并显示,方便 SEO 与 后续用户的访问。
ISR 的缺陷在于其缓存机制导致的老旧数据第一次加载与实际数据不一致问题,因为 ISR 遵循 CDN 一致性原则,如果用户访问一个老旧的数据,那么这个数据会被直接返回,同时 CDN 去查询该数据是否过期,哦,过期了,那服务器才会生成新的数据,但这时候用户已经看上老数据了啊。还有就是 fallback 导致的用户体验问题。
DPR 也是在 ISR 的基础上缝缝补补,让 CDN 不要着急,先与服务器沟通好再返回数据,然后也不要老是返回 fallback ,用一个叫 On-demand Builder(按需构建器)的东西先返回数据,然后再提交到 CDN 缓存。但说实话还是存在缺点,比如你这个 On-demand Builder 本质是一个无防护的边缘服务器,很容易被 Dos 攻击,然后渲染时间也不确定。
后面还有一些其他的方案,但都是在 CSR 或者 SSR 基础上延伸出来。
孤岛架构
岛屿架构模式是由Etsy的前端架构师Katie Sylor-Miller在2019年的一次会议上提出的。他的总体思路是在服务器上渲染HTML页面,并在高度动态的区域周围注入占位符或插槽。这些占位符/槽包含服务器渲染的HTML输出,来自其相应的小部件。它们表示的区域可以在客户端 “水化 “为小型的独立部件,重新使用其服务器渲染的初始HTML。可以认为这就像一个静态的HTML文档,其中包含多个独立的嵌入式应用程序。

乍看之下,这似乎与 “微前端 “相似。这两种方法都有一个共同的想法,就是将应用程序分解成独立的单元,然而 “微前端 “通常并不意味着这些单元的组成是通过HTML实现的。
与岛屿架构更接近的是渐进式增强,在CSR中,script在浏览器端初始化,并将script执行,渲染DOM。而SSR的DOM在服务器上渲染,浏览器需要加载它的时候会为它发出一个专门的请求,加载该部分的DOM。
与典型的单页应用程序架构相比,岛屿架构有许多好处。
- 并行化的水化 - Progressive hydration for free 在React、Angular、Preact和Vue等框架中,页面上的各个小部件会随着时间的推移被加载和初始化。这可以通过requestIdleCallback使用简单的调度方法来完成。 与渐进式水合类似,使用岛屿架构渲染页面的结果是,页面中较重的动态部分不仅被逐步初始化,而且被单独初始化。这意味着页面的个别区域会变得互动,而不需要先加载页面上的其他东西。 与渐进式水合不同,围绕岛屿架构的方法不需要自上而下的渲染,而是并行渲染。这是一个明显的优势,因为没有外部的父组件必须在其后代之前被初始化。页面的每一部分都是一个孤立的单元,一个单元的性能问题不会影响其他单元。
- 搜索引擎优化和用户体验- SEO & UX 单页面应用程序所使用的SSR的现状是,它经常被认为是出于SEO的需要。然而,SSR实际上会对用户体验产生负面的影响—访问者在等待页面的实际功能时,只能盯着该页面的一个令人沮丧的假版本。 SSR也存在着一些性能隐患的影响。在虚拟DOM库中,很容易(也很常见)意外地构建一种情况,即第一次渲染会破坏服务器之前渲染的HTML,然后服务器只能从头开始重新创建它(通常是同步的)。 在岛屿架构中,SSR 会立即交付给浏览器页面的一个基本部分。这个HTML包含了用户请求的内容的有意义的、可立即渲染的表示。该HTML可能缺少客户端的交互性,但至少包含了最基本的,与水化无关的内容。
- 更有利于可访问性和可发现性 - web accessibility & discoverability 一个使用标准HTML链接进行导航的网站更容易被辅助技术和网络爬虫使用。无论链接或表单是否被JavaScript拦截并转到客户端逻辑,这都是真实的,因为基本假设仍然是真实的:点击链接就能导航到指定的页面。 举个例子,想想有多少次你被发送一个 “链接 “到发送者认为是他们正在浏览的页面,但却发现这个链接没有包含任何必要的信息。构建基于页面的应用程序并不能完全防止这些类型的奇怪体验,它只是使这样做的决定更加直接。它使默认的结果成为可访问的结果。
总结一下,这是一个需要我们在前期去设计哪部分应该水化,但可以用更少代码来做某事的架构。是一个非常适合小型静态网站的架构。
但是,这个思路确实没有新意,React早于16版本就提出了类似的思想,并在18版本进行了可用于生产的原生实现。
流式 SSR
对比
