跳到主要内容

第 23 章:并发渲染——中断、恢复与时间切片

"并发"这个词容易误会,React 并没有同时干两件事(JS 是单线程)。它的并发是指:一次渲染可以被中途打断,之后要么接着算,要么重算。这正是第 4 章"render 可中断"的完整样子。

先记住这一句:并发渲染有两种"停"时间片用完 → 让出主线程,下帧接着算(恢复);更高优先级的 lane 来了 → 丢弃半成品,从 base 重算(重启)。恢复靠双缓冲续跑,重启靠第 13 章的 baseState。

一图流:中断之后往哪走

两种"停",源码里的分界

renderRootConcurrentReactFiberWorkLoop.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 指针的位置继续算。时间切片跑的是 workLoopConcurrent2965),第 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 章会用到。

动手实验

  1. 看续跑:渲染一棵大树(低优),在 renderRootConcurrent 的 else 分支打断点,观察两次调用走的是 continuation 而不是 fresh stack。
  2. 看重启:渲染中途用高优 lane 触发一个更新,打断点看它走进 prepareFreshStack 分支,旧 workInProgress 被丢弃。
  3. 数时间片:在 workLoopConcurrent(2965)打断点,观察一次渲染被切成多少段、每段之间主线程有没有机会跑动画。

小结

  • 并发不是"同时",是可中断:时间片停 → 恢复续跑;高优插队 → 重算。
  • 分界在 renderRootConcurrentlanes 变则 prepareFreshStack,不变则 continuation(2696)。
  • 重算不丢状态,靠第 13 章的 baseState;半成品随时可弃。
  • 时间切片让插队成为可能,expiredLanes 防饿死。
  • 下一步:既然大部分子树都不用重算,怎么把它们跳过?第 24 章 bailout。