搜索框里敲一个字,输入框要过半秒才把字显出来;点一下 Tab 切页签,整个界面像被冻住。打开 Chrome DevTools 的 Performance 面板录一段,看到的往往是一根几百毫秒的黄色长条,一个 Task 里塞满了 React 的 render 调用。这时候再去加 memo、加 useMemo 基本没用,因为问题不在渲染了几次,而在于这一次渲染太长,并且中途没有任何人能打断它。
React 18 的并发机制就是冲着这个场景来的。这篇不铺 API 清单,只讲一件事:React 是怎么把一次「一口气跑完」的渲染,改造成可以随时暂停、让出主线程、之后再接着跑的。读完你应该能自己解释清楚 Lane、时间切片、startTransition 三者是怎么串起来的。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- React 18 之前的渲染为什么「不可中断」,卡的到底是哪一步
- Fiber 架构把递归改成链表遍历,换来了什么
- Lane 模型用二进制位表示优先级的设计动机和位运算技巧
- React 为什么放弃
requestIdleCallback,改用MessageChannel自己实现调度器 - 5ms 时间片、
shouldYieldToHost与浏览器一帧的关系 startTransition和useDeferredValue在调度行为上的真实差别- 并发渲染带来的新坑,以及面试里怎么把这套机制讲明白
面向使用者的那一批新 API 和升级注意事项,比如 createRoot、自动批处理、Suspense SSR、useId、严格模式二次挂载,我拆到了另一篇里讲,见 React 18 新特性与升级指南。这篇只聚焦调度机制本身。
# 一、卡顿卡在哪一步
# 1.1 渲染是一次深度优先遍历
假设我们有这样一棵组件树:
function App() {
return (
<div>
<Header />
<Sidebar />
<Content>
<ComponentA />
<ComponentB />
</Content>
<Footer />
</div>
);
}
React 会以 DFS(深度优先搜索)的顺序走完整棵树:
App -> Header -> Sidebar -> Content -> ComponentA -> ComponentB -> Footer
每走到一个组件,React 都会为它创建或复用一个 Fiber Node,把 props、key、ref、lanes 这些渲染需要的信息挂在上面。整棵树对应一张 Fiber 链表结构,这就是从 React 16 开始的 Fiber 架构。
触发这次遍历的场景只有两类。一类是 mount,也就是首次渲染,入口是 ReactDOM.createRoot(document.querySelector('#root')).render(<App />);另一类是 update,由 useState、useReducer 这些 Hook 的 setter 触发。两类走的是同一套协调流程,区别只在于有没有可复用的老 Fiber。
# 1.2 真正的问题是这次遍历停不下来
React 18 之前,这次遍历一旦开始就必须走到底。
后果有三条。渲染开销大的组件(比如几百个列表项)会把主线程占满,用户看到的是页面完全没反应;遍历期间浏览器拿不到主线程,没法处理点击、没法重绘;哪怕这时候来了个明显更紧急的任务,比如用户点了另一个按钮,也只能排队等这一轮渲染跑完。
React 官方文档里有个很直白的例子,把这三条一次性演出来:
function App() {
const [tab, setTab] = useState('posts');
return (
<div>
<button onClick={() => setTab('posts')}>Posts</button>
<button onClick={() => setTab('about')}>About</button>
{tab === 'posts' ? <PostsTab /> : <AboutTab />}
</div>
);
}
// PostsTab 包含500个渲染开销大的组件
function PostsTab() {
return (
<div>
{Array(500).fill(0).map((_, i) => (
<SlowPost key={i} index={i} />
))}
</div>
);
}
// 每个SlowPost组件渲染需要1ms
function SlowPost({ index }) {
// 模拟渲染开销
let startTime = performance.now();
while (performance.now() - startTime < 1) {}
return <div>Post #{index}</div>;
}
500 个组件、每个 1ms,这一轮渲染就是 500ms 的同步任务。点 Posts 之后页面明显卡一下,卡住期间点 About 完全没反应,等前一次渲染结束才会一起处理。
我一开始也是这么想的:把 SlowPost 包一层 React.memo 不就好了?没用。memo 解决的是「不该渲染的组件被重渲染」,而这里的 500 个组件是第一次挂载,本来就得渲染一遍。要解决的是「这 500ms 能不能分几次跑」,这是调度问题,不是记忆化问题。
这就是并发机制要处理的核心矛盾。
# 二、Fiber 让什么变成了可中断的
很多人可能没注意到,Fiber 架构本身(React 16)就已经为可中断做好了地基,只是 React 16、17 没把这个能力对外开放。
React 15 的协调是递归的。父组件调子组件,子组件再调孙组件,整个过程压在 JS 调用栈上。调用栈这个东西的特点是,你没法在中间「存档退出」,一旦 return 回去,中间状态就丢了。所以老架构想中断也中断不了。
Fiber 做的事情是把这棵树的遍历从「递归」改写成「循环 + 链表指针」。每个 Fiber 节点上有 return(指向父节点)、child(指向第一个子节点)、sibling(指向下一个兄弟节点)三根指针,遍历过程变成了一个 while 循环,当前进度就是一个全局变量 workInProgress。
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
if (workInProgress !== null) {
// 还有工作没完成,让出主线程
return true;
}
}
看这段循环的条件就懂了。跳出 while 之后,workInProgress 还留在原地指着下一个要处理的节点,下次调度进来接着从这里跑就行。进度存在堆上的一个变量里,而不是存在调用栈里,这就是「可中断」的物理基础。
对照 React 15 的同步版本,循环条件里没有 shouldYield(),只有 workInProgress !== null,那就是一路跑到底。
顺着上面聊,还有一个配套设计叫双缓存。React 同时维护 current 树(当前屏幕上的)和 workInProgress 树(正在构建的),中途放弃 workInProgress 树不会影响屏幕,因为屏幕上挂的一直是 current。只有构建完整、进入 commit 阶段的那一刻,才会把 current 指针切过去。所以 render 阶段可以被中断甚至被完全丢弃重来,commit 阶段则必须一次性同步跑完,不然用户会看到画到一半的界面。
记住这条分界线,后面很多行为都能从这里推出来:render 可中断,commit 不可中断。
# 三、Lane 模型,优先级怎么编码
# 3.1 Lane 是什么
有了可中断的能力还不够,得有人告诉 React「什么时候该中断」「先做哪个」。这就是 Lane 模型的职责。
Lane 直译是赛道。React 给每一次更新分配一条或多条赛道,赛道的位置代表优先级高低,然后调度器按优先级决定这一轮先处理哪些更新。
# 3.2 为什么用二进制位
React 用二进制位来表示 Lane,源码里长这样(简化过):
// React 源码中的 Lane 定义(简化)
const Lane = {
NoLane: 0b0000000000000000000000000000000,
SyncLane: 0b0000000000000000000000000000001, // 最高优先级
InputContinuousLane: 0b0000000000000000000000000000100,
DefaultLane: 0b0000000000000000000000000010000,
IdleLane: 0b0000000000000000000001000000000, // 最低优先级
};
为什么非要用二进制,而不是 0 到 10 的数字?
一个原因是 JS 的位运算跑在 32 位整数上,快且没有内存分配。更关键的原因是,一个 Fiber 上可能同时挂着好几种优先级的更新,用位掩码可以把「一组优先级」压进一个数字里,合并和判断都是一步位运算:
// 合并多个 Lane
export function mergeLanes(a, b) {
return a | b; // 位运算 OR
}
// 移除某个 Lane
export function removeLanes(set, subset) {
return set & ~subset; // 位运算 AND NOT
}
如果换成数组或者对象来存这一组优先级,每次合并都要遍历、去重、分配新对象,而这套逻辑在一次渲染里会被调用成千上万次。React 还有个叫 getHighestPriorityLane 的函数,实现就是 lanes & -lanes,取出最低位的那个 1,一行搞定「找出这组里最紧急的那条赛道」。这类小技巧是 Lane 用位表示的直接收益。
# 3.3 事件类型决定初始优先级
那一次 setState 的优先级是谁定的?答案是触发它的那个事件。React 在事件系统里维护了一张映射表:
export function getEventPriority(domEventName) {
switch (domEventName) {
case 'click':
case 'input':
case 'keydown':
return DiscreteEventPriority; // 最高优先级
case 'scroll':
case 'wheel':
case 'mouseenter':
return ContinuousEventPriority; // 中等优先级
default:
return DefaultEventPriority; // 默认优先级
}
}
优先级顺序是 DiscreteEventPriority 大于 ContinuousEventPriority 大于 DefaultEventPriority。
背后的判断标准挺朴素的。点击、输入、按键这类离散事件,用户预期是「按下去立刻有反应」,慢一帧就能感知到,所以给最高优先级;滚动、滚轮、鼠标移入这类连续事件本来就会高频触发,中间丢几帧影响不大;剩下的网络回调、定时器里的更新,默认优先级就够。