Skip to main content

React 18 并发渲染原理

并发渲染不是「更快」,而是「可中断」。它让 React 能在长任务中让出主线程,优先响应输入。

一、渲染为什么可以被打断​

18 之前,一次更新从根节点到叶子节点是一气呵成的:中途无法暂停,如果这棵树很大,主线程会被占满几十到几百毫秒,期间点击与输入全部排队,INP 直接变差。并发特性把渲染切成了一个个可暂停的工作单元,React 在每个单元之间检查「有没有更紧急的事」,有就让位。总工作量没变,但长任务被拆开了。代价是调度本身有一点开销,以及 transition 期间要同时持有新旧两棵树的内存——绝大多数 UI 下可忽略,但超大量并发更新时要留意。它的用户可感知收益指标是 INP(交互到下一帧绘制),而不是 TTI 或 FCP:长任务被切开,INP 直接降下来。

// 没有并发:大列表过滤卡住输入,直到算完才响应下一次按键
function onChange(e) {
setKeyword(e.target.value)
setList(filter(hugeList, e.target.value)) // 同步算完,期间输入排队
}
// 有并发:把非紧急更新标记可中断,输入先响应
function onChange(e) {
setKeyword(e.target.value)
startTransition(() => setList(filter(hugeList, e.target.value)))
}

并发拆的是长任务,不是工作量——这一点先钉死,后面所有误解都源于忘了它。

同步渲染:长任务占住主线程,用户输入排在后面长渲染任务点击输入要等渲染做完才被响应并发渲染:渲染可被打断,主线程优先响应输入片段 1片段 2让出片段 3片段 4输入插入到片段之间并发渲染不是「更快」,而是「可中断」——让出主线程的时间片交给更高优先级的更新。
图:并发的价值在于可中断,不在于单次渲染速度

二、Fiber 提供了什么​

Suspense 边界先完成,客户端就能先绑这一块的事件Shell立即水合边界 A数据到了→水合边界 B仍在等后到兜底每个边界独立成棵子树,互不阻塞——这也是「边界要小」的原因:边界越大,可交互的比例越低。由此引出两条实践:边界放在「内容确实会等」的位置别把整个页面包进一个大边界把 Suspense 当作「这里可以等」的声明,而不是「懒加载开关」——两者碰巧都能用,但只有前者能表达意图。
图:流式渲染下各边界独立水合,边界大小直接决定首屏可交互比例

Fiber 有两重身份:它是数据结构(每个组件对应一个节点,用链表串成可遍历的树),也是执行单元(渲染以一个 Fiber 为最小单位推进)。同时存在两棵树——当前屏幕上的是 current,正在构建的是 workInProgress,构建完成后整体切换。正因为有了这层结构,渲染才能做到「做一半先停、下次接着做」。双缓冲的意义不只是「做一半能停」——它还意味着渲染中被打断时,已显示的 current 树始终完整,用户不会看到半成品;workInProgress 是后台草稿,提交时才一次性替换。

直觉:渲染从一个 Fiber 推进到下一个,而不是一次性递归到底
current 树(已显示) workInProgress 树(构建中)
A A'
/ \ / \
B C B' C' ← 做到 C 时被输入打断,下次从 C' 继续

三、优先级怎么排​

lane 用位标记给每次更新打优先级:输入、点击这类离散事件优先级最高,立刻执行;transition 里的更新优先级低,可以被打断重来。同一次渲染可以只处理某个优先级的子集——高优先级的更新先提交,低优先级的后补。优先级是调度依据,不是速度开关。lane 不是「优先级越高越快」,而是「高优先级可以先插队提交」;低优先级更新仍会执行,只是排在后面。所以并发不会让慢更新消失,只是不让它挡住用户输入——这点想错就会指望并发来「加速」,结果失望。

lane 直觉:
离散事件(输入/点击) → 最高优先级,立即提交
普通 setState → 中优先级
transition 内更新 → 低优先级,可被高优先级打断

四、批处理带来的行为变化​

React 18 把批处理范围从「只有事件回调」扩大到「微任务、timeout、promise 回调里也批」。一次函数里连续多次 setState,18 之前在 setTimeout 里不会合并、会触发多次渲染;18 之后统一合并成一次。需要同步读取刷新后 DOM 时用 flushSync,但要谨慎——它会跳出批处理,破坏并发收益。

// React 17:timeout 里的多次 setState 不批处理,渲染两次
setTimeout(() => { setA(1); setB(2) }, 0)
// React 18:自动合并成一次渲染(除非用 flushSync 强制同步)
setTimeout(() => { setA(1); setB(2) }, 0)

批处理范围扩大了,但不是全部——某些跨框架或手动调度的场景仍可能不批。flushSync 是逃生舱不是常态——只在确实需要「改完状态立刻读 DOM」时用,且要包最小范围,否则会把这个更新及相邻更新都踢出并发调度,得不偿失。

一个能看出差别的对照:同样的计算量,一个卡一个不卡:

// 不包 transition:每次按键都同步渲染 5000 行,输入明显卡顿
onChange={(e) => setKeyword(e.target.value)}

// 包了 transition:输入先响应,列表渲染被降级,可以打断重来
onChange={(e) => {
setInput(e.target.value) // 紧急:输入框立刻更新
startTransition(() => setKeyword(e.target.value)) // 非紧急:可打断
}}

// 注意:计算总量没变,变的只是「谁先被响应」

五、让位,而不是变快​

并发渲染并不引入多线程,JS 仍然单线程。并发指的是可中断与优先级调度,同一时刻还是只做一件事。

开了并发也不会让所有更新都变快——它优化的是响应性,整体耗时甚至可能略有增加。

startTransition 和 setTimeout 也不是一回事:一个是基于优先级的调度,一个是定时器,语义完全不同。Suspense 同理,懒加载只是它的一种用法,它本质是在声明「这里可以等」的边界。

并发渲染改的不是「算得多快」,而是「能不能随时让位给更急的事」。评估它该用 INP 这类响应性指标,而不是总渲染耗时。

并发特性的设计动机读一遍 React v18 发布公告 会清楚很多,自动批处理那节也值得对照。