并发渲染(Concurrent Rendering)是 React 18 最重要的底层变革,它彻底改变了 React 的更新处理方式。不再是一次状态更新必须同步完成渲染,而是允许渲染过程可中断、可分片、可插队,让应用在高负载下仍能保持流畅的交互体验。
从同步到并发:一个宏观对比
在传统的同步模式(Legacy Mode)中,当状态更新发生时,React 会从根节点开始,一口气完成整棵组件树的渲染,期间无法响应用户的任何输入。如果组件树很深,这次同步渲染可能耗时几十甚至上百毫秒,导致页面掉帧、按钮点击无响应。
并发模式(Concurrent Mode,通过 createRoot 启用)则完全不同:它将一次大的渲染任务拆分成多个小的工作单元,在浏览器空闲时逐步执行。执行过程中,如果有更高优先级的更新(如用户输入)到来,React 可以暂停当前的渲染,优先处理高优先级更新,完成后再恢复之前的工作。这种机制被称为时间切片。
// React 17 的同步入口
ReactDOM.render(<App />, document.getElementById('root'));
// React 18 的并发入口
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
仅这一行变更,就打开了并发渲染的大门。
并发渲染的两大基石:Fiber 架构与 Scheduler
并发渲染并非凭空产生,它建立在两套底层系统之上:
- Fiber 架构:将虚拟 DOM 的每一个节点抽象为一个 Fiber 节点,Fiber 节点形成了可遍历的链表结构。这使得渲染过程不再是递归调用,而是一个可以停顿、可以恢复的循环(
workLoop)。每个 Fiber 节点都携带了自身的状态、待处理的更新、以及指向父/子/兄弟节点的指针,这让“中断并恢复”成为可能。
- Scheduler 调度系统:负责为不同的更新任务分配优先级,并与浏览器的空闲时间进行协调。它基于
MessageChannel实现空余时间检测,并对外暴露shouldYield方法,让渲染循环可以在每一帧的剩余时间不足时主动暂停,交还主线程控制权。
这两套系统协同工作:Fiber 提供了可中断的数据结构,而 Scheduler 决定了何时中断、何时继续。
优先级与 Lane 模型
并发渲染的核心挑战是:如何在多个同时发生的更新中,选出最重要的先执行?
React 使用 Lane 模型来表示更新优先级。每个更新都会被分配一个 Lane(赛道),Lane 以二进制位掩码的形式存在,不同的 Lane 代表不同的优先级。
常见的优先级分类(从高到低):
| 优先级 | Lane 名称 | 典型场景 |
|--------|-----------|----------|
| 同步优先级 | SyncLane | 用户输入、点击事件、离散事件 |
| 连续事件 | InputContinuousLane | 拖拽、滚动 |
| 默认优先级 | DefaultLane | 数据请求后的渲染 |
| 过渡优先级 | TransitionLane | 由 useTransition / startTransition 触发的更新 |
| 空闲优先级 | IdleLane | 离屏渲染、分析上报 |
当多个更新同时存在于组件上时,React 会选出优先级最高的 Lane 优先执行,低优先级的 Lane 会被保留,稍后再处理。这实现了“高优先级任务插队”的基础。
// 使用 useTransition 将某个状态更新标记为过渡(低优先级)
function App() {
const [isPending, startTransition] = useTransition();
const [query, setQuery] = useState('');
const [result, setResult] = useState([]);
const handleChange = (e) => {
// 高优先级更新:输入框必须立即响应
setQuery(e.target.value);
// 低优先级更新:搜索结果可以慢慢显示
startTransition(() => {
setResult(search(e.target.value));
});
};
return (
<div>
<input value={query} onChange={handleChange} />
{isPending && <Spinner />}
<ResultList data={result} />
</div>
);
}
当用户快速输入时,setResult 触发的渲染会因为优先级较低而被中断,React 优先确保输入框的流畅性。一旦用户停止输入,再继续完成搜索结果的渲染。
渲染中断与恢复的完整流程
一次并发更新的执行过程可以概括为以下步骤:
- 触发更新:状态变更后,React 创建一个 Update 对象,并标记对应的 Lane,挂载到对应 Fiber 节点的更新队列中。
- 入口调度:React 调用
scheduleUpdateOnFiber,根据更新的 Lane 决定是立即同步执行还是调度一个并发任务。对于并发任务,会由 Scheduler 全局管理。
- Render 阶段(可中断):从根节点开始,React 进入
workLoop循环,深度优先遍历 Fiber 树,为每个 Fiber 节点执行协调(beginWork)和收集副作用(completeWork)。在这个过程中,每处理完一个 Fiber 节点,都会通过 Scheduler 的shouldYield函数检查是否需要让出主线程:
- 如果当前帧剩余时间充足,继续处理下一个 Fiber。
- 如果剩余时间不足(如仅剩 1ms),则暂停遍历,保存当前的 Fiber 指针(即下一个待处理的 Fiber 节点),然后退出循环,让浏览器处理用户事件或绘制。
- 待浏览器再次空闲时,从上一个保存的 Fiber 指针处恢复遍历,继续后面的工作。
- Commit 阶段(同步不可中断):当整棵树的 Render 阶段完成,React 会进入 Commit 阶段,将计算结果一次性地应用到真实 DOM 上。这个阶段很短,而且是同步的,确保 UI 一致性。
这一流程的精妙之处在于:中断只发生在不同 Fiber 节点的处理之间,不会在一个 Fiber 的执行中间插入。每个 Fiber 的 beginWork 和 completeWork 是原子的,确保了数据结构的完整性。
低优先级更新的“丢弃”与“重做”
并发渲染还有一个关键行为:当低优先级更新正在进行时,如果一个更高优先级的更新被触发,React 不仅会中断当前工作,还会完全丢弃已经为低优先级更新生成的部分工作(即 workInProgress 树),然后重新从根节点开始处理高优先级更新。待高优先级更新完成后,再重新开始低优先级更新的渲染。
这避免了低优先级工作成果与高优先级更新的冲突,保证了状态的一致性。虽然看起来有些浪费,但时间切片的粒度足够小(通常每个切片只有几毫秒),丢弃的成本并不高。
时间切片的底层实现
Scheduler 实现时间切片的原理依赖于浏览器的帧率(通常 60fps,每帧约 16.6ms)。浏览器每一帧需要完成样式的计算、布局、绘制以及执行 JavaScript。React 的渲染属于 JavaScript 执行范畴,因此必须在一帧内留给渲染大约 5ms 的可执行时间(留给其他任务的开销),否则会造成掉帧。
Scheduler 使用 MessageChannel 来模拟宏任务,在每个任务单元之间让出主线程:
- 当需要执行并发任务时,Scheduler 会将任务放入一个队列,并通过
MessageChannel发送消息。 - 浏览器在事件循环的宏任务阶段执行该消息回调,调用 React 的
workLoop。 workLoop每执行一个时间切片(默认 5ms),就调用shouldYield判断是否需要暂停。暂停的方式是return出workLoop,让宏任务回调自然结束,主线程归还给浏览器。- 下次浏览器空闲时(由
MessageChannel再次触发),继续处理后续任务。
这种“合作式调度”确保了并发渲染不会长时间霸占主线程,用户的操作始终能得到及时响应。
并发渲染的实际感知
对于开发者而言,并发渲染主要体现在几个新的 API 和行为:
- 自动批处理:
setState在异步事件(setTimeout、Promise)中也会被自动批量处理,减少不必要的渲染次数。 - Suspense 的流式加载:组件可以“等待”数据加载完成,渲染过程不会因为单个组件的阻塞而停滞。
- useTransition / useDeferredValue:让你手动将某些更新标记为低优先级,优化用户输入体验。
理解并发渲染的底层原理,能帮助你更合理地使用这些 API,在复杂的交互场景中做出恰当的优先级划分,让应用始终维持丝滑的用户体验。