如果说前六篇讲的是 React「怎么把更新做出来、做得多快」,那这最后一篇要回答的是:当同时来了很多更新,React 凭什么决定先做哪个、能不能做一半先干别的。这就是并发渲染(Concurrent Rendering)——React 18 之后整个架构的灵魂,也是理解 useTransition、Suspense 这些新特性的钥匙。
从 Concurrent Mode 到 React 18:一次「改名」背后的妥协
2018 年 React 团队放出「异步渲染」路线图,2019 年以 Concurrent Mode 的名字开放实验。但几年下来团队发现:把它设计成一种「模式」(整个应用要么全开、要么全关)太激进——老应用一开就崩,新用户又不知道怎么开。于是 React 18 做了个重要决定:并发能力默认开启,但只有在你主动使用并发特性时才真正生效,并把名字从 Concurrent Mode 改成了「并发特性」(Concurrent Features)。这就是为什么 startTransition、useDeferredValue、Suspense 这些 API 会被统称为「并发特性」。
换句话说:并发不是一个开关,而是一套「更新可以被标记优先级、可以被中断」的基础设施。往下看。
lane:给每一次更新贴上优先级标签
并发的地基,是第六篇调度器背后的 lane(车道)模型。在 React 18 源码中,一次 setState 产生的 update 会被打上一条 lane——一条 lane 本质上是 32 位整数里的一个 bit,数字越小优先级越高:
JAVASCRIPT// ReactFiberLane.js(示意)
export const SyncLane = 0b0000000000000000000000000000001;
export const DefaultLane = 0b0000000000000000000000000010000;
export const TransitionLane1 = 0b0000000000001000000000000000000;
不同类型的更新会走不同的 lane:
- 离散事件(click、keydown 等):同步优先级 SyncLane,尽快处理;
- 连续事件(scroll、mousemove)与普通 setState:默认优先级 DefaultLane;
- transition 更新(startTransition 里的 setState):落到一组共 16 条的 TransitionLane 上,天然低于所有紧急 lane。
这就是「优先级」的全部秘密:不是调度器临时拍脑袋,而是从源头就把更新分好了三六九等。
startTransition / useTransition:把「重活」主动降级
JSXconst [isPending, startTransition] = useTransition();
// 输入框受控更新:紧急,必须立刻回显
const onChange = (e) => setQuery(e.target.value);
// 列表过滤这种重计算:不紧急,可以慢慢来
startTransition(() => setFilteredList(heavyFilter(list, query)));
useTransition 返回的 startTransition 在内部做的第一件事,是把回调里的 setState 标记为 Transition lane 的更新(源码中 setState 走 dispatchSetState 时,会读取 ReactCurrentBatchConfig.transition 判断当前是否处于过渡上下文)。于是:
- 用户打字触发的
setQuery是紧急更新,永远插队先渲染; setFilteredList是过渡更新,渲染时可以慢慢来、可以被新一轮打字打断;- 过渡没完成期间
isPending为 true,UI 可以显示轻量提示,让用户知道「在算了」。
对照 setTimeout 方案:很多人以前把 setTimeout 当作「降级手段」,但 setTimeout 只是把活往后挪——到期后它照样以最高优先级执行,照样卡顿;更要命的是,如果定时器触发前用户又输入了新内容,旧结果依然会被渲染出来、覆盖掉新输入。useTransition 的更新则是可放弃的:渲染过程中一旦发现来了更紧急的更新,React 会直接丢弃当前半成品,先渲染紧急更新。这在源码里对应调度器用 getNextLanes 对比出更高优先级的 lane 后,将低优先级渲染标记为中断并重新调度。
抢占与恢复:渲染可以做到一半停掉吗
可以,这正是「可中断渲染」的由来。回顾第三篇:render 阶段要把整棵 Fiber 树遍历一遍、算出新状态;在并发模式下,这次遍历被切成一个个时间片(第六篇的时间切片),每个时间片结束通过 shouldYield 把主线程让给浏览器。如果此时来了紧急更新:
- 调度器用
getNextLanes对比 lane,发现紧急 lane 更优先; - 当前正在进行的低优先级渲染被打断——workInProgress 树直接作废(好在双缓存机制下,alternate 树完好无损,随时可以重来);
- 以紧急优先级从 root 重新开始 render,随后 commit 一次性同步完成。
关键点在于:render 阶段可中断,commit 阶段绝不中断。DOM 的增删改一旦做到一半被掐断,页面就处于不一致状态;所以 React 把「算」和「做」彻底分开——算可以反复重来,做必须一次到位。这套「算错就重算、重算更高优」的闭环,就是优先级抢占与渲染中断恢复的完整图景。
Suspense:渲染到一半,数据还没来怎么办
并发渲染要解决的另一件事是等待数据。Suspense 的底层原理一句话就能说清:组件渲染时可以 throw 一个 Promise:
JSXfunction User({ id }) {
const user = readCache(fetchUser(id)); // 数据未就绪时:throw 一个 promise
return <div>{user.name}</div>;
}
// React 19 起也可以直接写:const user = use(fetchUser(id));
reconciler 在 render 阶段捕获到这个被 throw 的 promise,把当前 fiber 标记为挂起(suspended),向上找到最近的 <Suspense> 边界并渲染 fallback;等 promise resolve 后,再带着数据重新调度一次渲染。结合并发特性,Suspense 还有一个反直觉的好处:界面上已经有内容时,过渡更新挂起不会回退成 fallback,而是保留旧内容直到新内容就绪——彻底告别「闪一下 loading 再白屏」的体验。
收官:七篇一条主线
到这里,React 源码探秘系列就讲完了。回头看,七篇其实是一条线:JSX 编译成 ReactElement(一),挂载成 Fiber 树(二);更新时 render 阶段逐个协调 Fiber、算出变化(三),commit 阶段把变化一次性落到 DOM(四);函数组件的状态藏在 Hooks 链表里(五);为了不占死主线程,调度器用时间切片分配每一帧的工作量(六);而并发渲染(七)在这套架构上补上最后一块拼图——给更新分优先级,让紧急的事插队、让不紧急的事慢慢算、让等待数据不再阻塞界面。
从「能渲染」到「渲染得快」,再到「渲染得聪明」,React 走了七年。理解了这七篇,再看任何 React 新特性都不会觉得是黑魔法——它们全都长在同一棵 Fiber 树上。