前言

今年 3 月底 React 18 正式发布,官方口号喊得很响:”Concurrent Rendering(并发渲染)是 React 未来十年愿景的基础”。我一开始是抱着怀疑态度去读文档的——React 16 的 Fiber 架构铺垫了四年,总不能又只是”底层重构、用户无感”吧?后来在一个大列表搜索的场景里实际用上了 useTransition,卡顿肉眼可见地消失,才真正体会到这次升级的价值。这篇文章就把我对并发渲染的理解和实战代码整理出来。

1. React 18 发布背景

要理解 React 18,得先简单回顾一下历史脉络。React 16(2017)引入 Fiber 架构,把同步不可打断的递归渲染改造成链表结构,为”可中断渲染”打下了地基,但当时所有更新仍然是同步执行的。React 17(2020)基本是个”垫脚石”版本,主要工作是事件委托移到根节点、JSX 转换升级等铺垫。

React 18(2022)才是 Fiber 架构真正的”交卷”:渲染过程可以被打断、暂停、重新开始,React 可以根据优先级决定先渲染什么、后渲染什么。官方称之为 Concurrent Rendering。配套的新特性(useTransition、useDeferredValue、Suspense 数据获取、流式 SSR)全部建立在这个能力之上。

先把一件事说清楚:并发渲染默认开启,但只有使用新 API 声明”这是可并发的更新”时才会生效。旧的代码路径行为完全不变,老项目升 createRoot 不用担心行为变化。

2. 什么是 Concurrent Rendering

2.1 可中断渲染:从”独占”到”协作”

React 17 及之前,一次状态更新触发的渲染是同步的、不可打断的:JS 引擎从 render 到 commit 一口气跑完,期间用户的输入、动画帧全部排队等待。列表大一点,输入框每敲一个字母都要卡一下,这就是经典的”输入掉帧”问题。

并发渲染改变了这个模型:React 把渲染工作切成一个个小单元(对应 Fiber 节点),每处理完一个单元就去问一句”有没有更重要的事要做?”。如果用户此刻正在打字,React 会把手头的低优先级渲染暂停,先让浏览器处理输入事件;输入处理完,再从暂停的位置恢复渲染。整个过程类似时间切片(Time Slicing)。

这里有个最容易误解的点要划重点:并发渲染并没有让单次渲染变快,渲染总量甚至可能因中断恢复而增加。它改变的是调度方式——让高优先级的交互永远插队,把耗时的低优先级渲染延后,用户感知到的”卡顿”消失了。

2.2 优先级调度:Lane 模型的通俗理解

React 18 内部用 Lane(车道)模型给更新划分优先级,可以类比高速公路车道:不同紧急程度的更新驶入不同车道,调度器每轮先处理最高优先级车道上的更新。

用文字描述一次”输入 + 大列表过滤”的调度过程:

1
2
3
4
5
6
7
8
9
10
11
用户敲入字符 "react"
└─ 触发两个更新:
├─ input.value 变化 → 高优先级(SyncLane):立即渲染,输入不能等
└─ 列表过滤 setState(list) → 低优先级(TransitionLane):标记为过渡更新

调度器时间线:
t0 渲染 input 更新 ──渲染单元1──渲染单元2──▶ 检查:用户又敲字了?
t1 是 → 暂停列表渲染,让出主线程给输入事件
t2 输入事件处理完 → 新的列表过滤请求到来,废弃旧的渲染结果
t3 从头开始渲染最新列表(旧的半成品直接丢弃,不会 commit 到屏幕)
t4 列表渲染完成 → 一次性 commit

注意 t3 这个细节:被中断的渲染如果还没 commit,React 会直接丢弃重算,而不是续着渲染一个已经过时的结果。因为这类过渡渲染不 commit 就不会影响屏幕内容,丢弃的成本是零——这就是并发渲染敢于”半途而废”的底气。

3. createRoot 与 Automatic Batching

3.1 createRoot:开启并发的开关

升级 React 18 后,业务代码要改的第一行就是入口文件:

1
2
3
4
5
6
7
8
// React 17 及之前
import ReactDOM from 'react-dom';
ReactDOM.render(<App />, document.getElementById('root'));

// React 18
import ReactDOM from 'react-dom/client';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);

createRoot 就是并发功能的总开关。使用旧的 ReactDOM.render 时 React 会打警告,并且所有新特性(包括自动批处理)都不生效。过渡期间也可以用 ReactDOM.createRoot + flushSync 组合,后者可以强制某次更新走同步路径。

3.2 Automatic Batching:批处理范围扩大

“批处理”指多次 setState 合并为一次渲染。React 17 只在事件回调(如 onClick)里批处理,在 Promise、setTimeout、原生事件里每次 setState 都会触发独立渲染。React 18 的自动批处理把范围扩大到了所有更新场景:

1
2
3
4
5
6
7
8
// React 17:setTimeout 里两次 setState 各触发一次渲染,一共渲染两次
// React 18:自动批处理,两次 setState 合并成一次渲染
function handleClick() {
setTimeout(() => {
setCount((c) => c + 1);
setFlag((f) => !f);
}, 100);
}

绝大多数场景这是免费的性能提升。极少数依赖”setState 后立即同步重渲染”的代码(比如打印中间状态)会踩到行为差异,需要用 flushSync 显式同步:

1
2
3
4
5
import { flushSync } from 'react-dom';

flushSync(() => setCount(1)); // 同步渲染完成
measureDomNode(); // 能读到最新 DOM
flushSync(() => setFlag(true));

4. useTransition 与 useDeferredValue 实战

useTransition 实现的渐进式列表更新

4.1 一个真实卡顿的场景

设想一个常见需求:搜索框 + 一万条数据的列表实时过滤。React 17 的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function SearchList() {
const [query, setQuery] = useState('');
const [list, setList] = useState(bigData);

const handleChange = (e) => {
const q = e.target.value;
setQuery(q); // ① 更新输入框
// ② 立即同步过滤一万条数据
setList(bigData.filter((item) => item.name.includes(q)));
};

return (
<>
<input value={query} onChange={handleChange} />
<List data={list} />
</>
);
}

handleChange 里两个 setState 同步批处理成一次渲染,而这次渲染里包含了对一万条数据的过滤计算和一万个列表项的 diff。结果就是:每敲一个字母,主线程被阻塞几百毫秒,输入框跟着卡。输入和过滤被绑在了同一个”同步更新”里,输入被迫陪过滤一起慢。

4.2 useTransition:把慢更新标记为”过渡”

React 18 的解法是给更新分类:输入框是”紧急更新”(UI 响应必须即时),列表过滤是”过渡更新”(晚几百毫秒无所谓)。useTransition 就是这个分类器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { useState, useTransition } from 'react';

function SearchList() {
const [query, setQuery] = useState('');
const [list, setList] = useState(bigData);
const [isPending, startTransition] = useTransition();

const handleChange = (e) => {
const q = e.target.value;
// 紧急更新:同步执行,输入框绝不卡
setQuery(q);

// 过渡更新:可中断、可被打断重算,不阻塞输入
startTransition(() => {
setList(bigData.filter((item) => item.name.includes(q)));
});
};

return (
<>
<input value={query} onChange={handleChange} />
{isPending && <span className="hint">过滤中…</span>}
<List data={list} />
</>
);
}

跑起来的实际效果:连续快速输入 “reactjs” 六个字母,输入框每帧都跟手;列表渲染被中断了五次,只有最后一次结果被 commit 到屏幕。isPending 为 true 表示还有过渡渲染在后台进行,可以用来展示轻量提示。

几个使用要点:

  1. startTransition 包住的回调里做 setState,不要把网络请求等副作用放进去(请求该发就发,把”用结果更新状态”包起来);
  2. 过渡更新期间,React 会展示旧状态的界面,所以页面不会白屏或闪烁,只是”旧列表多停留了一会儿”;
  3. 如果过渡更新自身还要展示 loading(比如切换 tab 拉数据),可以配合 useTransition 的 isPending,或者用 Suspense 的 fallback。

4.3 useDeferredValue:值的”防抖”版

如果不想改 setState 的调用位置,React 18 还提供了一个更”声明式”的 API:useDeferredValue。它返回一个滞后于真实值的副本,紧急部分用真值渲染,耗时的计算用滞后值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function SearchList() {
const [query, setQuery] = useState('');
// deferredQuery 是 query 的延迟版本
const deferredQuery = useDeferredValue(query);
const list = useMemo(
() => bigData.filter((item) => item.name.includes(deferredQuery)),
[deferredQuery]
);

return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<List data={list} />
</>
);
}

工作方式类似 debounce,但有两个本质区别:一是它没有固定延迟时间,后一次渲染紧跟在前一次之后开始,机器越快延迟越小;二是输入框的渲染不会被延迟,慢的是吃计算的大列表。两种 API 的选择标准:能控制 setState 调用处就用 useTransition,值来自外部(props、自定义 Hook)就用 useDeferredValue。

另外提醒一点:不要把 useDeferredValue 当成万能的防抖工具。它优化的只是”渲染”这一段,如果你的卡顿来自阻塞主线程的非渲染计算(比如在 onChange 里同步跑了几百毫秒的字符串处理),这个 Hook 帮不了你,得先把慢计算挪进 Web Worker,或者用真正的 debounce 控制请求频率,再配合这两个 API 使用。

5. Suspense:数据获取与代码分割

5.1 代码分割里的 Suspense(现在就能用)

Suspense 在 React 18 里最成熟的应用是配合 React.lazy 做组件级代码分割,路由页面按需加载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { lazy, Suspense } from 'react';

const Chart = lazy(() => import('./Chart'));
const Report = lazy(() => import('./Report'));

function Dashboard() {
return (
<Suspense fallback={<PageSkeleton />}>
<Report />
<Suspense fallback={<ChartSkeleton />}>
<Chart />
</Suspense>
</Suspense>
);
}

注意嵌套 Suspense 的用法:外层 Report 加载完成即可渲染,内层 Chart 继续用自己的骨架屏兜底,两个模块的加载互不阻塞——如果是一个 Suspense 包到底,一个慢模块会拖住整页。

5.2 与数据获取结合( Relay / 新一代框架)

并发渲染真正的杀手锏是 Suspense 数据获取:组件在”读取数据未就绪”时抛出 Promise,React 捕获后展示 fallback,同时不阻塞页面其他部分的渲染,数据到达后从中断处继续。官方目前推荐通过框架接入(如 Relay):

1
2
3
4
5
6
7
8
9
10
11
12
13
// Relay 风格的伪代码,展示 Suspense 数据获取的模型
function Profile({ userId }) {
const data = usePreloadedQuery(profileQuery, preloadedData); // 未就绪时 throw Promise
return <h1>{data.user.name}</h1>;
}

function App() {
return (
<Suspense fallback={<ProfileSkeleton />}>
<Profile userId="1" />
</Suspense>
);
}

配合 React 18 的流式 SSR(renderToPipeableStream + selective hydration),服务端可以按 Suspense 边界分块吐 HTML,浏览器先看到的区块先交互、先水合,整体 TTI 大幅提前。这块目前需要框架支持,暂时还不建议裸用。

6. 升级注意事项

最后把升级清单列一下,我们项目升级的过程基本就是照着这个排查的:

  1. 依赖对齐:react、react-dom 升到 18,类型包升到 @types/react@18,React Testing Library 升到 13+(内部已改用 createRoot,业务测试代码基本不用动);
  2. 入口改造:ReactDOM.render 换 createRoot,SSR 的 hydrate 换 hydrateRoot;
  3. 类型变化:children 从 FC 的隐式 props 中移除,函数组件需要显式声明 children?: React.ReactNode;
  4. 批处理行为审查:排查 Promise/setTimeout 里依赖 setState 后立即重渲染的逻辑,必要时 flushSync;
  5. 第三方库兼容:个别库依赖旧渲染调度(如部分动画库、react-beautiful-dnd 老版本),升级前先确认版本兼容列表;
  6. 渐进采用:不急着把所有状态更新包进 transition,先在列表过滤、tab 切换这类”大计算 + 可容忍延迟”的场景小范围验证。

写在最后

并发渲染的本质,是把”用户交互优先”从口号变成了调度器里真实的优先级队列。React 没有让渲染变快,而是让”慢”变得不再可感。

从 useTransition 开始,前端开发者能以”声明优先级”的方式参与渲染调度,这在我看来是 Hooks 之后 React 给出的第二个范式级 API。Suspense 数据获取的生态眼下还在起步,server components 也仍是实验状态,我的打算是生产环境里先用 useTransition 和 useDeferredValue,等框架层的数据获取方案稳定了再上 Suspense。倒是 createRoot 和自动批处理这两块零成本的部分,这周就可以升。