第 23 章:并发渲染——中断、恢复与时间切片
"并发"这个词容易误会,React 并没有同时干两件事(JS 是单线程)。它的并发是指:一次渲染可以被中途打断,之后要么接着算,要么重算。这正是第 4 章"render 可中断"的完整样子。
先记住这一句:并发渲染有两种"停":时间片用完 → 让出主线程,下帧接着算(恢复);更高优先级的 lane 来了 → 丢弃半成品,从 base 重算(重启)。恢复靠双缓冲续跑,重启靠第 13 章的 baseState。
一图流:中断之后往哪走
两种"停",源码里的分界
renderRootConcurrent(ReactFiberWorkLoop.js:2688)开头有个关键判断(2696):
if (workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes) {
prepareFreshStack(root, lanes); // lanes 变了:扔掉旧栈,重新开始
} else {
// This is a continuation of an existing work-in-progress.
// ……继续上次的进度
}
这条 if 就是中断与恢复的分界:
-
恢复(continuation):渲染因为时间片用完而暂停时,
workInProgressRootRenderLanes没变。下一帧再次进入renderRootConcurrent,走 else 分支,接着上次workInProgress指针的位置继续算。时间切片跑的是workLoopConcurrent(2965),第 4 章那个now() < yieldAfter循环。 -
重启(fresh stack):渲染中途来了更高优先级的更新,
lanes变了,走 if 分支,prepareFreshStack从根重新建栈。之前算到一半的 workInProgress 树被整棵扔掉。
为什么要"重算"而不是"接着算"
高优任务插队时,低优渲染算到一半的结果不可信了,因为新到的更新可能影响树的任何一部分。所以 React 选择最保守的做法:从 prepareFreshStack 重建 workInProgress 树,重新开始。
那"重新开始"不会把状态也算丢吗?不会,这就是双缓冲 + baseState(第 13 章)的意义:每个 hook 的 baseState 永远记着"从哪开始算",丢弃的只是这次渲染的中间结果,不是状态本身。下次从 base 重放更新队列,结果一模一样。baseState 就是为"渲染可以被丢弃"设计的。
时间切片:让主线程喘气
低优渲染在时间片内干活,shouldYield 了就让出(第 5 章),浏览器画帧、响应用户输入。用户这时再点一下,新更新以更高 lane 进来,就是上面的"重启"分支。时间切片让"插队"成为可能:正因为渲染可以随时停,高优任务才总能挤进来。
并发的代价与配合
默认并发渲染有个副作用:如果低优更新永远被高优挤掉,可能饿死(一直不落地)。React 的兜底是 lane 过期机制(expiredLanes):任务拖到超时,就强制以同步优先级跑掉(第 5 章"欠了债就得闷头还")。同时 Suspense、useTransition 这类 API 都是为并发设计的地基,第 26、27 章会用到。
动手实验
- 看续跑:渲染一棵大树(低优),在
renderRootConcurrent的 else 分支打断点,观察两次调用走的是 continuation 而不是 fresh stack。 - 看重启:渲染中途用高优 lane 触发一个更新,打断点看它走进
prepareFreshStack分支,旧 workInProgress 被丢弃。 - 数时间片:在
workLoopConcurrent(2965)打断点,观察一次渲染被切成多少段、每段之间主线程有没有机会跑动画。
小结
- 并发不是"同时",是可中断:时间片停 → 恢复续跑;高优插队 → 重算。
- 分界在
renderRootConcurrent:lanes 变则prepareFreshStack,不变则 continuation(2696)。 - 重算不丢状态,靠第 13 章的 baseState;半成品随时可弃。
- 时间切片让插队成为可能,
expiredLanes防饿死。 - 下一步:既然大部分子树都不用重算,怎么把它们跳过?第 24 章 bailout。