【翻译】React 如何乱序流式输出 UI,却仍保持最终顺序

React 如何乱序流式输出 UI,却仍保持最终顺序

深入解析 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。即便 FooterNavbar 与数据拉取无关,它们也会被卡住。整个页面会一直等到 getRecommendations() 结束,才会开始发送任何内容。

如果我们能让用户立刻看到那些暂时不需要数据的组件,那就太好了。

流式渲染

好吧,我们可以通过引入流式渲染来解决。

但「只有流式」仍然有其局限,你发现了吗?

顺序流式

即便启用了流式渲染,用户不必为了看到 Navbar 而等待 ProductDetailsFooter 仍可能因为 Recommendations 还在加载而被阻塞。这叫做顺序流式(in-order streaming):每个组件按其在 HTML 中出现的顺序依次到来。

说明

这是为了在 Next.js 里刻意演示顺序流式而写的例子;你并不能直接「手动」做到完全相同的形态。

乱序流式

顺序流式解决了一部分问题,但并没有把问题彻底解决。

如果我们能立刻发送 NavbarFooter,在慢组件将要出现的位置先放下占位符(标记),等数据就绪后再把这些占位符替换成真实内容呢?互不等待、互不阻塞、彼此独立。

这就是乱序流式(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>

NavbarFooter 已经在那儿了。慢组件各自处在 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:亲眼看到隐藏的 divscript 标签随着流式数据一点点进来,往往比光看文字更快「开窍」。

组件回推到客户端

当数据在服务器端解析完成后,React 会把组件继续以流的方式推回客户端。看起来像这样:

html 复制代码
<div hidden id="S:0">
  <section>ProductDetails</section>
</div>

注意这是一个 hiddendiv。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
相关推荐
江南十四行1 小时前
AI Agent应用类型及Function Calling开发实战(三)
服务器·前端·javascript
GISer_Jing2 小时前
AI原生全栈架构理论体系:从分布式范式演进到全链路工程化理论基石
前端·人工智能·学习·ai编程
GISer_Jing2 小时前
从“切图仔”到“增长架构师”:AI时代营销前端的范式革命
前端·人工智能·ai编程
广州华水科技2 小时前
单北斗GNSS在水库变形监测中的应用与系统安装解析
前端
xingpanvip2 小时前
星盘接口开发文档:组合三限盘接口指南
android·开发语言·前端·python·php·lua
阿拉丁的梦2 小时前
blender最好的多通道吸色工具(拾取纹理颜色排除灯光)
前端·html
吴声子夜歌2 小时前
Vue3——脚手架Vite
前端·javascript·vue.js·vite
摘星编程2 小时前
当AI开始学会“使用工具“——从ReAct到MCP,大模型如何获得真正的行动力
前端·人工智能·react.js
light blue bird2 小时前
设备数据变化上传图表数据汇总组件
大数据·前端·信息可视化