起因
看 React18 版本更新官方文章提到在 18 以前异步函数的回调中多次调用 setState 并不会进行批处理。
探究
原来在 18 以前,异步函数如 setTimeout,Promise.then 或 async/await 的回调中多次调用 setState 不会进行批处理,而是每次调用都会触发一次独立的、同步的重新渲染,而不是合并成一次。
为什么 18 以前的代码无法办到?因为 React 的批处理机制依赖于内部一个同步"上下文"标志(shouldBatchUpdates)
js
// React 17 及更早版本 (简化代码)
function batchedUpdates<A, R>(fn: A => R, a: A): R {
const prev = shouldBatchUpdates; // 保存之前的标志状态
shouldBatchUpdates = true; // 开启批处理标志
try {
return fn(a); // 同步执行传入的函数
} finally {
shouldBatchUpdates = prev; // 执行完毕后,恢复标志
}
}
当代码执行到异步回调时,同步代码早已执行完毕,标志也被恢复,导致每次 setState 都会触发独立更新。
而在 React18 的并发模式下,每次 setState 都会创建一个带有优先级 lane 的更新对象 Update 挂载到对应的 Fiber 节点上,这些更新会进入调度器的队列,调度器在合适的时机(如一个宏任务结束后的空窗期),从队列中取出所有优先级相同的更新,合成一次批处理流程进行渲染。
拓展思考
Q:官方文档"并发模式的一个关键特性是渲染可中断。当首次升级到 React18,在加入任何并发功能之前,更新内容渲染的方式和 React 之前的版本一样------通过一个单一的且不可中断的同步事务进行处理。同步渲染意味着,一旦开始渲染就无法中断,直到用户可以在屏幕上看到渲染结果。"疑问:18 版本之前的 Fiber 架构没有实现"可中断"吗?
A:18 之前的 Fiber 架构为"可中断"提供了技术基础,但 18 才真正实现了"可中断渲染"。
15 以前使用的是基于递归的 Stack Reconciler,更新一旦开始就无法中断,会一直阻塞主线程导致卡顿。16 引入 Fiber 架构,将更新工作拆分成一个个独立的小任务单元(Fiber 节点),并且这些任务可以通过【时间切片】技术,在执行过程中根据需要让给主线程,从而避免长时间阻塞。但此技术只能按照时间片暂停/恢复,且只是减少卡顿,中间状态可能闪烁。
故 18 在 Fiber 架构的基础上引入并发模式(Concurrent Code)和配套的优先级调度机制(Lane 模型),根据任务的紧急程度进行调度。高优先级的任务如用户输入可以中断低优先级的任务如复杂列表渲染,且当一个渲染任务被中断,后续可以从上次中断的地方继续。
Q:VDOM 结构和 Fiber 结构对比
A:VDOM 是树形结构,每个节点是一个普通的 JS 对象描述 UI 信息,父节点可以指向子节点,子节点没有指向父节点的指针(缺点);Fiber 结构是链表树结构,除了描述 UI,更记录了并发渲染所需的信息和节点之间的关系。
js
// VDOM 节点的大致结构(简化)
{
type: 'div', // 标签或组件
props: {
className: 'box',
children: [
{ type: 'span', props: { children: 'Hello' } }
]
},
key: '1' // 用于 diff 的标识
}
js
// Fiber 节点的核心结构(简化)
{
// 类型信息(源自 VDOM)
type: 'div',
key: '1',
props: { className: 'box' },
// 链表结构(关系指针)
return: parentFiber, // 指向父 Fiber
child: childFiber, // 指向第一个子 Fiber
sibling: siblingFiber, // 指向下一个兄弟 Fiber
// 工作单元(状态与副作用)
memoizedState: null, // 上次渲染的 state
memoizedProps: null, // 上次渲染的 props
pendingProps: null, // 本次更新的 props
updateQueue: null, // 待处理的更新队列
// 副作用链表(Effect List)
firstEffect: null,
nextEffect: null,
// 模式标记
mode: 'concurrent', // 是否支持并发
lanes: 0 // 优先级(Lane 模型)
}
Q:那 Fiber 和 VDOM 是如何协同工作来实现并发模式的?
A:调度(Schedule)→渲染(render)→提交(commit)三个阶段
js
// 触发更新 -> 调度 -> 渲染 -> 提交
setState(newState)
↓
scheduleUpdateOnFiber(fiber) // 调度更新
↓
ensureRootIsScheduled(root) // 确保 root 被调度
↓
performConcurrentWorkOnRoot(root) // 并发工作循环(可中断)
↓
renderRootSync(root) // 同步渲染(不可中断)
↓
commitRoot(root) // 提交变更到 DOM
触发更新预备进入调度
js
// 源码简化
function setState(updater, state, props) {
// 1. 创建更新对象
const update = createUpdate(eventTime, lane); // 带优先级,后文细讲
update.payload = { state };
// 2. 将更新挂载到 Fiber 的 updateQueue 上
enqueueUpdate(fiber, update, lane);
// 3. 从当前 Fiber 开始向上调度更新
const root = scheduleUpdateOnFiber(fiber, lane, eventTime);
}
// 核心调度函数:scheduleUpdateOnFiber
function scheduleUpdateOnFiber(fiber, lane, eventTime) {
// 1. 标记根节点上有待处理的更新
const root = markUpdateLaneFromFiberToRoot(fiber, lane);
// 2. 确保根节点被调度
ensureRootIsScheduled(root, eventTime); // 记录更新事件的发生时间,以此计算任务"等待了多久",防止根据任务优先级调度时低优先级的任务被无限期推迟
// 3. 如果是同步更新(如未开启并发),立即执行
if (lane === SyncLane) {
// 同步更新:直接执行 render 和 commit
performSyncWorkOnRoot(root);
} else {
// 并发更新:交给调度器按优先级排队
scheduleCallback(
priorityLevel,
performConcurrentWorkOnRoot.bind(null, root)
);
}
}
// performConcurrentWorkOnRoot 渲染阶段,可中断
function performConcurrentWorkOnRoot(root) {
// 创建 workInProgress 树(从 current 树克隆)
prepareFreshStack(root);
// 进入工作循环
do {
try {
workLoopConcurrent(); // 可中断地处理 Fiber
break;
} catch (thrownValue) {
// 如果中断,记录位置,下次恢复
handleError(root, thrownValue);
}
} while (true);
// 渲染阶段完成,准备提交
root.finishedWork = root.current.alternate;
commitRoot(root);
}
function workLoopConcurrent() {
// 循环执行工作单元,直到:
// 1. 所有工作完成
// 2. 时间片用完 (shouldYield 返回 true)
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
// 执行单个工作单元,双缓冲机制:current 和 workInProgress
function performUnitOfWork(unitOfWork) {
// React 维护两棵 Fiber 树
// root.current // 当前屏幕上显示的内容
// root.current.alternate // workInProgress 正在构建的树
// 1.workInProgress 树基于 current 树克隆创建
const current = unitOfWork.alternate;
// 2.更新时优先复用 current 的 Fiber 节点
if (current !== null) {
// 复用 current 的节点,只更新变化的 props/state
unitOfWork.memoizedProps = current.memoizedProps;
unitOfWork.memoizedState = current.memoizedState;
}
// 3. 处理当前 Fiber 节点,生成子 Fiber
const next = beginWork(unitOfWork);
// 4. 如果当前节点没有子节点,则完成当前节点
if (next === null) {
completeUnitOfWork(unitOfWork);
} else {
// 5. 继续处理子节点
workInProgress = next;
}
}
// 完成当前节点(向上回溯)
function completeUnitOfWork(unitOfWork) {
let completedWork = unitOfWork;
do {
// 1. 完成当前节点(收集副作用)
completeWork(completedWork);
// 2. 如果有兄弟节点,则继续处理兄弟节点
const siblingFiber = completedWork.sibling;
if (siblingFiber !== null) {
workInProgress = siblingFiber;
return;
}
// 3. 否则向上回溯到父节点
completedWork = completedWork.return;
} while (completedWork !== null);
}
function commitRoot(root) {
// 1. 获取副作用列表(包含所有需要变更的 Fiber)
const finishedWork = root.finishedWork;
const firstEffect = finishedWork.firstEffect;
// 2. 第一阶段:DOM 变更前(获取快照,用于getSnapshotBeforeUpdate)
commitBeforeMutationEffects(firstEffect);
// 3. 第二阶段:DOM 变更(插入、更新、删除)
commitMutationEffects(firstEffect);
// 4. 第三阶段:DOM 变更后(执行 useEffect 的清理和创建)
commitLayoutEffects(firstEffect);
// 5. 切换 Fiber 树:current 指向新的 Fiber 树
root.current = finishedWork;
}
Q:什么是 Lane 模型
A:Lane 模型使用二进制数来表示优先级,通过位运算判断一个 task 是否包含在某个更新批次中(batch)
任务入队:当一个更新发生时,React 为其计算出 Lane 并创建一个任务,根据优先级放入不同的队列。
计算过期时间:每个任务都有一个过期时间,由任务优先级决定,防止低优先级任务被无限期延迟。
任务循环:调度器会循环执行任务队列中最高级的任务,执行前会检查时间切片当前帧是否还有剩余时间,如果时间用完则中断当前任务,让出主线程;调度前会检查过期时间,若任务已过期则会立即同步执行(饥饿问题)。