React 如何乱序流式输出 UI,却仍保持最终顺序
- 原文链接:inside-react.vercel.app/blog/how-re...
- 原文作者:Sankalpa Acharya
深入解析 React 如何借助 Suspense 边界对流与渲染 UI 进行乱序处理,同时仍保持最终呈现顺序。

引言
早在 Server Components 出现之前,React 就已经支持流式渲染。React 18 提供了 renderToPipeableStream() 与 renderToReadableStream()。而在浏览器侧,这也并不是什么新鲜事:浏览器原生支持流式 HTML,会在收到数据块时就开始渲染。
可以看一个简单的演示。

大多数流式传输会遵循一种顺序:你会依次看到 chunk(1)、chunk(2) ... chunk(N-1)、chunk(N)。
但 React Server Components 与 Suspense 的有趣之处在于:它并不遵循这种顺序。你可以按任意顺序流式输出组件,例如 component(2)、component(N) ... component(1)。本文要讨论的就是这件事。
目标读者
本文面向已经熟悉 Suspense 与 Server Components 等基础概念的 React 开发者,重点解释 React 在内部如何处理流式渲染,以及「乱序流式」与常规流式有何不同。
传统 SSR
先来看这个例子:
tsx
async function ProductPage() {
const product = await getProduct(); // 约 50ms
const recommendations = await getRecommendations(); // 约 800ms
const reviews = await getReviews(); // 约 300ms
return (
<>
<Navbar />
<ProductDetails product={product} />
<Reviews reviews={reviews} />
<Recommendations recommendations={recommendations} />
<Footer />
</>
);
}

你可能会说:Sanku,你这不就是在制造瀑布流吗。把它们并行拉取啊。
行,那我们就这样做;下面这段代码把三次 await 改成并行发起,但页面输出行为仍值得继续往下看。
tsx
async function ProductPage() {
const product = getProduct(); // 约 50ms
const recommendations = getRecommendations(); // 约 800ms
const reviews = getReviews(); // 约 300ms
return (
<>
<Navbar />
<ProductDetails product={product} />
<Reviews reviews={reviews} />
<Recommendations recommendations={recommendations} />
<Footer />
</>
);
}

现在三者会同时发起。太好了......但我们仍然有一个问题。
页面会等到三者全部结束,才会发送第一字节的 HTML。即便 Footer 与 Navbar 与数据拉取无关,它们也会被卡住。整个页面会一直等到 getRecommendations() 结束,才会开始发送任何内容。
如果我们能让用户立刻看到那些暂时不需要数据的组件,那就太好了。
流式渲染
好吧,我们可以通过引入流式渲染来解决。

但「只有流式」仍然有其局限,你发现了吗?
顺序流式
即便启用了流式渲染,用户不必为了看到 Navbar 而等待 ProductDetails,Footer 仍可能因为 Recommendations 还在加载而被阻塞。这叫做顺序流式(in-order streaming):每个组件按其在 HTML 中出现的顺序依次到来。
说明
这是为了在 Next.js 里刻意演示顺序流式而写的例子;你并不能直接「手动」做到完全相同的形态。
乱序流式
顺序流式解决了一部分问题,但并没有把问题彻底解决。
如果我们能立刻发送 Navbar 与 Footer,在慢组件将要出现的位置先放下占位符(标记),等数据就绪后再把这些占位符替换成真实内容呢?互不等待、互不阻塞、彼此独立。
这就是乱序流式(out-of-order streaming):没有固定顺序,组件会在各自的数据准备好时随时到达。
React 18 引入的 renderToPipeableStream 让这件事成为可能。React 19 则稳定了 React Server Components,使其用起来顺手得多。你只需要把慢组件包在带 fallback UI 的 <Suspense> 里,其余交给 React。
tsx
async function ProductDetails() {
await delay(50);
return <section>ProductDetails</section>;
}
async function Reviews() {
await delay(800);
return <section>Reviews</section>;
}
async function Recommendations() {
await delay(300);
return <section>Recommendations</section>;
}
export default function Page() {
return (
<main>
<Navbar />
<Suspense fallback={<div>loading...</div>}>
<ProductDetails />
</Suspense>
<Suspense fallback={<div>loading...</div>}>
<Reviews />
</Suspense>
<Suspense fallback={<div>loading...</div>}>
<Recommendations />
</Suspense>
<Footer />
</main>
);
}

说明
为了方便你跟上节奏,我把演示 GIF 里的延迟调大了(1s、2s、3s)。
挺酷的对吧?接下来我们深入看看 React 到底是怎么做到的。
内部机制
把 React 用的技巧用大白话说出来其实很简单:立刻发送已经有的内容;对还没有的内容留下带标记的占位符;等服务器把数据解析完后,再用 JavaScript 完成替换。
就是这样。下文都只是这个思路的具体实现。
如果你观察服务器实际吐出来的 HTML 流,大致会看到类似下面这样的结构:

html
<header>Navbar</header>
<!--$?-->
<template id="B:0"></template>
<div>loading..</div>
<!--/$-->
<!--$?-->
<template id="B:1"></template>
<div>loading...</div>
<!--/$-->
<!--$?-->
<template id="B:2"></template>
<div>loading...</div>
<!--/$-->
<footer>Footer</footer>
Navbar 与 Footer 已经在那儿了。慢组件各自处在 Suspense 边界里,并带有一个 fallback 的 div。
我们单独看一下 ProductDetails:
html
<!--$?-->
<template id="B:0"></template>
<div>loading..</div>
<!--/$-->
<!--$?--> 与 <!--/$--> 是 Suspense 边界的标记。<template> 标签是稍后会被替换掉的占位符。<div>loading..</div> 则是你的 fallback UI。
id="B:0" 让 React 知道当解析后的组件到达时,应该去替换哪一个占位符。
注释里的 $? 表示该 Suspense 边界仍处于 pending:fallback 正在展示,我们还没收到真实数据。

到这一步,我强烈建议你打开一个 Next.js 项目,打开 DevTools 看 Network:亲眼看到隐藏的 div 与 script 标签随着流式数据一点点进来,往往比光看文字更快「开窍」。
组件回推到客户端
当数据在服务器端解析完成后,React 会把组件继续以流的方式推回客户端。看起来像这样:
html
<div hidden id="S:0">
<section>ProductDetails</section>
</div>
注意这是一个 hidden 的 div。React 不会把它直接插到「正确的位置」,而是先把它暂存到屏幕外,并用 id="S:0" 标记。紧接着,它会再流式输出一小段 <script>:
html
<script>
$RC("B:0", "S:0");
</script>

替换就发生在这里。$RC 是 React 更早就在流里下发过的函数,因此客户端已经准备好了。我们再来看 React 为实现这件事会用到的三个函数。
html
<script>
$RB = [];
$RV = function (a) {
$RT = performance.now();
for (var b = 0; b < a.length; b += 2) {
var c = a[b],
e = a[b + 1];
null !== e.parentNode && e.parentNode.removeChild(e);
var f = c.parentNode;
if (f) {// 出于可读性,此处折叠了 51 行

你需要重点关注三件事:$RB 队列、$RC 函数、以及 $RV 函数。
$RC
javascript
$RC = function(a, b) {
if (b = document.getElementById(b))
(a = document.getElementById(a))
? (/* 替换逻辑 */)
: b.parentNode.removeChild(b)
}
$RC 接收两个参数。a 是类似 B:0 的 template id,b 则是类似 S:0 的已解析组件 id。
它首先尝试用 document.getElementById(b) 找到已解析组件对应的 div。如果找不到,就移除组件并不做任何事。如果找到了,再继续用 document.getElementById(a) 去找 template 元素。
如果找到了 template,它会把前一个兄弟注释节点上的边界标记从 $? 改成 $~,表示该 Suspense 边界已进入排队状态,然后把两个元素一起推进 $RB 队列:
javascript
a.previousSibling.data = "$~";
$RB.push(a, b);
一旦 $RC 凑齐了「template + 已解析内容」这一对,就会用 requestAnimationFrame 调用 $RV 去做真正的 DOM 交换。
$RB
$RB 只是一个充当队列的数组。React 会把 [template, resolved] 这样的成对元素推进去。真正的交换并不会在每一次 $RC 调用时立刻发生:它会等到至少有一对元素,并把 $RV 安排到下一帧执行。
$RV
这里才会发生真正的交换。
javascript
$RV = function(a) {
for (var b = 0; b < a.length; b += 2) {
var c = a[b], // template 元素(B:0)
e = a[b+1]; // 已解析组件(S:0)
...
}
}
它会每次从 $RB 里取两个元素,因为我们总是成对 push。
首先把已解析组件从隐藏的 div 上拆下来,这样它就不再处于 hidden 状态。
然后它会遍历 Suspense 边界内的所有兄弟节点,并逐个移除它们。这就是如何清掉 fallback UI:你写的 loading 转圈?没了。
javascript
do {
d = c.nextSibling;
f.removeChild(c);
c = d;
} while (c);
接着,它会把已解析组件的所有子节点,逐个插入到 Suspense 边界闭合注释之前。
javascript
for (; e.firstChild; ) f.insertBefore(e.firstChild, c);
最后,它会把边界注释从 $~ 更新为 $,表示 Suspense 已结束。如果边界节点上挂了 _reactRetry,它也会触发------这就是 React 处理并发模式重试的方式。
$? → $~ → $ 这一串状态迁移,就是 Suspense 边界的完整生命周期:
ruby
$? = pending (fallback 正在展示)
$~ = queued (已解析内容就绪,等待 RAF)
$ = complete(真实内容已进入 DOM)
打破 Suspense
既然 React 只是在 DOM 里寻找 <template id="B:0">,那如果你手动塞一个进去会发生什么?
tsx
<main>
--
<div>
hello
<template id="B:0">hello testing</template>
</div>
--
<Navbar />
<Suspense fallback={<div>loading..</div>}>
<ProductDetails />
</Suspense>
<Suspense fallback={<div>loading...</div>}>
<Reviews />
</Suspense>
<Suspense fallback={<div>loading...</div>}>
<Recommendations />
</Suspense>
<Footer />
</main>
我故意在一个随意的 div 里加了一个 <template id="B:0">。React 并不知道那是假的。当 $RC("B:0", "S:0") 运行时,它只会执行 document.getElementById("B:0"),于是先命中的是你那个。结果就是:它不会去替换真正的 ProductDetails 占位符,而是把你的随机 div 给换了。

小结
这正是 React 的流式渲染与「单纯把 HTML 分块」不同的地方:普通 HTML 流被迫按顺序解析,因为 HTML 解析本身就是顺序的。React 则把 DOM 当作暂存区:用隐藏 div 把组件先送过来,再用 JavaScript 在正确的时机把它们摆到正确的位置。
希望你喜欢这篇文章的阅读体验,也欢迎在社交平台上把本文转给同样需要搞懂流式细节的同学 ❤️
特别感谢 @render,帮我指出了几处我遗漏的问题。
术语表(本篇命中)
| 术语 | 英文 | 释义 |
|---|---|---|
| 乱序流式 | out-of-order streaming | 不依赖 DOM 出现顺序,先发送可渲染部分并以占位符延迟补齐 |
| 顺序流式 | in-order streaming | 流式片段大致按文档顺序依次到达,后续内容可能被前置的未完成异步阻塞 |
| 服务器组件 | Server Components | 在服务器上渲染/序列化的 React 组件形态,常与流式配合 |
| 流式渲染 | streaming (SSR) | 边生成边发送 HTML(或数据块),客户端可渐进展示 |
| 占位符 / 标记 | placeholder / marker | 流中预留位置,后续由脚本替换为真实 UI |