React Render / Commit 主流程
这篇笔记不再按断点流水账组织,而是按一次更新真正经过的系统结构来学:
触发更新
-> 选择 Lane
-> update 入队
-> root 被调度
-> render 阶段构建 workInProgress 树
-> beginWork 向下计算
-> reconcileChildren 做 diff 并生成 child Fiber
-> completeWork 向上收尾并收集 flags
-> commit 阶段提交 DOM 和 effects
-> root.current = finishedWork 完成双缓冲切换面试时最重要的不是背函数名,而是能回答三件事:
setState后 React 为什么不是立刻改 DOM?render 阶段到底算出了什么?
commit 阶段到底提交了什么?
一、总体认知:一次更新的三段式
React 一次更新可以先粗略分成三段:
Update 阶段:把“我要更新”包装成 update,并把 root 标记为有任务
Render 阶段:在内存里计算新的 Fiber 树,也就是 workInProgress 树
Commit 阶段:把 render 算出的副作用真正提交到宿主环境,例如 DOM更完整的主链路是:
setState / dispatch
-> dispatchSetState
-> requestUpdateLane
-> 创建 update
-> enqueueConcurrentHookUpdate
-> scheduleUpdateOnFiber
-> markRootUpdated
-> ensureRootIsScheduled
-> getNextLanes
-> performWorkOnRoot
-> renderRootSync / renderRootConcurrent
-> createWorkInProgress
-> workLoop
-> performUnitOfWork
-> beginWork
-> reconcileChildren
-> completeWork
-> finishedWork
-> commitRoot
-> before mutation
-> mutation
-> root.current = finishedWork
-> layout
-> passive整体流程图:
flowchart TD
A["用户事件 / setState"] --> B["创建 update"]
B --> C["选择 lane"]
C --> D["update 入队"]
D --> E["标记 FiberRoot.pendingLanes"]
E --> F["Root Scheduler 安排任务"]
F --> G["Render 阶段"]
G --> H["createWorkInProgress"]
H --> I["beginWork 向下计算"]
I --> J["reconcileChildren 生成/复用 child Fiber"]
J --> K["completeWork 向上收尾"]
K --> L["finishedWork"]
L --> M["Commit 阶段"]
M --> N["before mutation"]
N --> O["mutation 修改 DOM"]
O --> P["root.current = finishedWork"]
P --> Q["layout effects"]
Q --> R["passive effects"]几个核心对象先放在同一张表里:
名称 | 本质 | 主要阶段 |
|---|---|---|
| JSX 运行时产物,描述 UI | render 前 / render 中 |
| React 内部工作单元 | render / commit |
| 当前页面已提交的 Fiber 树 | commit 后 |
| 本次更新正在计算的新 Fiber 树 | render |
| current 和 WIP 互相指向的桥 | render / commit |
| 一次状态更新意图 | dispatch / render |
| 更新优先级和批次标识 | dispatch / scheduler / render |
| render 完成后的 WIP 根 Fiber | commit 前 |
| 当前 Fiber 自己的副作用 | render 产物,commit 消费 |
| 子树副作用汇总 | completeWork 冒泡,commit 跳过无效子树 |
一句话总览:
render 阶段负责“算”:算新 state、新 children、新 Fiber、flags。
commit 阶段负责“交”:把 flags 对应的 DOM 操作和 effects 真正执行。二、触发更新:setState 如何变成 root 上的待处理任务
以函数组件的 useState 为例:
function Counter() {
const [count, setCount] = useState(0);
return <button => setCount(1)}>{count}</button>;
}点击后执行的是 setCount(1)。但 React 不会立刻把页面里的 0 改成 1,而是先创建一个 update:
update = {
lane,
action,
hasEagerState,
eagerState,
next
}这些字段的含义:
字段 | 含义 |
|---|---|
| 本次更新的优先级和批次 |
| 你传给 setState 的值或函数,例如 |
| 是否提前算出了新 state |
| eager 优化提前算出来的新 state |
| 指向下一个 update,形成环状链表 |
为什么需要 updateQueue?
因为一次 render 前可能发生多次 setState。
React 需要先把这些更新收集起来,再在 render 阶段按 lane 和顺序统一计算新 state。多个 update 的环状链表示意:
flowchart LR
Q["queue.pending 指向最后一个 update"] --> U3["update3"]
U1["update1"] --> U2["update2"]
U2 --> U3
U3 --> U1关键简化源码:
function dispatchSetState(fiber, queue, action) {
const lane = requestUpdateLane(fiber);
const didScheduleUpdate = dispatchSetStateInternal(
fiber,
queue,
action,
lane
);
if (didScheduleUpdate) {
startUpdateTimerByLane(lane);
}
}function dispatchSetStateInternal(fiber, queue, action, lane) {
const update = {
lane,
action,
hasEagerState: false,
eagerState: null,
next: null,
};
const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
if (root !== null) {
scheduleUpdateOnFiber(root, fiber, lane);
return true;
}
return false;
}function scheduleUpdateOnFiber(root, fiber, lane) {
markRootUpdated(root, lane);
ensureRootIsScheduled(root);
}这里要抓住两个变化:
queue.pending 挂上 update
root.pendingLanes 标记这棵 root 有待处理 lane所以 setState 的本质不是“改状态”,而是:
创建 update -> update 入队 -> root 被标记和调度面试表达:
setState 表示一次更新意图。React 会把它包装成 update,放入对应 Hook 或 class 的 updateQueue。
随后 React 根据触发更新的 Fiber 向上找到 FiberRoot,把对应 lane 标记到 root.pendingLanes,
再通过 Root Scheduler 安排后续 render。真正的新 state 会在 render 阶段消费 updateQueue 时计算。三、Render 阶段入口:从 current 到 workInProgress
render 阶段的输入和输出:
输入:
root.current 当前已提交 Fiber 树
lanes 本次要处理的优先级集合
updateQueue 待处理更新
输出:
finishedWork,也就是计算完成的 workInProgress 树render 阶段不会直接改 DOM。它主要在内存里创建或复用 Fiber,计算新状态,打上 flags。
所以进入 render 后,React 第一件事不是执行组件,而是先准备一棵“可以被本轮更新改写的树”。这就是 createWorkInProgress 的位置:它把已经提交的 current 树,转换成本轮 render 可操作的 WIP 树。
3.1 createWorkInProgress:双缓冲入口
React 不会直接修改 current 树,而是基于 current 创建 workInProgress 树:
current 树 = 当前页面已经提交的 Fiber 树
workInProgress 树 = 本次更新正在构建的新 Fiber 树两棵树通过 alternate 互相连接:
current.alternate === workInProgress
workInProgress.alternate === current简化源码:
function createWorkInProgress(current, pendingProps) {
let workInProgress = current.alternate;
if (workInProgress === null) {
workInProgress = createFiber(
current.tag,
pendingProps,
current.key,
current.mode
);
workInProgress.elementType = current.elementType;
workInProgress.type = current.type;
workInProgress.stateNode = current.stateNode;
workInProgress.alternate = current;
current.alternate = workInProgress;
} else {
workInProgress.pendingProps = pendingProps;
workInProgress.flags = NoFlags;
workInProgress.subtreeFlags = NoFlags;
workInProgress.deletions = null;
}
workInProgress.child = current.child;
workInProgress.memoizedProps = current.memoizedProps;
workInProgress.memoizedState = current.memoizedState;
workInProgress.updateQueue = current.updateQueue;
workInProgress.lanes = current.lanes;
workInProgress.childLanes = current.childLanes;
return workInProgress;
}这段源码对应的核心思想:
如果 alternate 不存在,就创建第二棵树。
如果 alternate 已经存在,就复用旧的 WIP,重置 flags 等本轮 render 相关字段。有了根部的 WIP Fiber 后,React 才开始真正“走树”。走树不是递归调用组件那么简单,而是把每个 Fiber 当成一个工作单元,逐个执行,这就是 workLoop。
3.2 workLoop:深度优先遍历 Fiber 树
render 阶段的主循环是深度优先:
先 beginWork 向下
没有 child 后 completeWork 向上简化源码:
function renderRootSync(root, lanes) {
prepareFreshStack(root, lanes);
workLoopSync();
return workInProgressRootExitStatus;
}function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}function performUnitOfWork(unitOfWork) {
const current = unitOfWork.alternate;
const next = beginWork(current, unitOfWork, renderLanes);
unitOfWork.memoizedProps = unitOfWork.pendingProps;
if (next === null) {
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
}这段就是 render 阶段最核心的骨架:
beginWork 返回 child,说明继续向下。
beginWork 返回 null,说明当前节点没有更深的 child,要开始 completeWork 回溯。遍历示意:
四、beginWork:向下计算与组件执行
beginWork 是 render 阶段的“向下阶段”。它处理当前 Fiber,并决定下一步要不要进入子节点。
它主要做四件事:
判断当前 Fiber 是否需要更新。
根据
fiber.tag分发到不同类型的处理逻辑。对函数组件执行组件函数并处理 Hooks。
调用
reconcileChildren生成或复用 child Fiber。
4.1 beginWork 的核心分发
简化源码:
function beginWork(current, workInProgress, renderLanes) {
if (current !== null) {
const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;
if (oldProps !== newProps || hasContextChanged()) {
didReceiveUpdate = true;
} else {
const hasScheduledUpdate = checkScheduledUpdateOrContext(
current,
renderLanes
);
if (!hasScheduledUpdate) {
didReceiveUpdate = false;
return attemptEarlyBailoutIfNoScheduledUpdate(
current,
workInProgress,
renderLanes
);
}
}
}
switch (workInProgress.tag) {
case FunctionComponent:
return updateFunctionComponent(
current,
workInProgress,
workInProgress.type,
workInProgress.pendingProps,
renderLanes
);
case HostRoot:
return updateHostRoot(current, workInProgress, renderLanes);
case HostComponent:
return updateHostComponent(current, workInProgress, renderLanes);
case SuspenseComponent:
return updateSuspenseComponent(current, workInProgress, renderLanes);
}
}tag 决定当前 Fiber 走哪种逻辑:
tag | 表示什么 | beginWork 关注点 |
|---|---|---|
| 根节点 | 处理 root updateQueue |
| 函数组件 | 执行函数组件、处理 Hooks |
| DOM 节点,如 | 处理 children |
| 文本节点 | 通常没有子节点 |
| Suspense | 判断显示 fallback 还是 primary |
| memo 组件 | 比较 props,可能 bailout |
上面的分发表里,面试和日常业务最常遇到的是 FunctionComponent。因为我们写的组件大多是函数组件,而 Hooks、组件重新执行、children 生成,都从这个分支进入。
4.2 函数组件在哪里执行
函数组件不是浏览器调用的,而是在 render 阶段由 React 调用:
beginWork
-> updateFunctionComponent
-> renderWithHooks
-> Component(props)简化源码:
function updateFunctionComponent(
current,
workInProgress,
Component,
nextProps,
renderLanes
) {
const nextChildren = renderWithHooks(
current,
workInProgress,
Component,
nextProps,
null,
renderLanes
);
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
return workInProgress.child;
}renderWithHooks 做两件关键事:
执行 Component(props),拿到新的 ReactElement children。
维护 Hook 链表,并消费 Hook updateQueue 计算新 state。注意这个关系:
函数组件返回的是 ReactElement。
reconcileChildren 再把 ReactElement 转成 child Fiber。不过,React 并不是每次走到一个 Fiber 都一定重新执行完整逻辑。进入真正更新前,它会先判断这棵子树有没有必要算。如果没有必要,就会进入 bailout。
4.3 bailout 是什么
bailout 是 render 阶段的跳过优化。
当 React 判断当前 Fiber 没有必要重新计算时,可以跳过当前节点或整个子树:
props 没变
context 没变
当前 Fiber 没有命中本轮 renderLanes 的 update
子树也没有本轮需要处理的工作简化理解:
不是每次 root 更新,整棵树都要重新执行组件函数。
React 会尽量复用旧 Fiber 的结果。面试表达:
beginWork 是 render 阶段的向下计算阶段。它会根据 Fiber tag 分发处理逻辑。
函数组件会在 beginWork 中进入 updateFunctionComponent,再通过 renderWithHooks 执行组件函数。
如果当前 Fiber 和子树没有本轮 lane 对应的工作,React 会尝试 bailout,跳过不必要计算。五、reconcileChildren:React Diff 如何决定复用、插入、删除和移动
reconcileChildren 是 render 阶段里非常核心的一步。它不是直接操作 DOM,而是在 beginWork 里把组件本次 render 返回的 ReactElement children,和上一次已经提交的 current.child Fiber 做对比,生成新的 workInProgress.child Fiber 链表。
先把一句话钉住:
React Diff 比较的是:旧 Fiber 和新 ReactElement。
它的结果是:新的 child Fiber 链表,以及 Placement / Update / ChildDeletion 等 flags。
真正 DOM 插入、删除、移动发生在 commit 的 mutation 阶段。5.1 Diff 的输入、输出和核心问题
在函数组件里,renderWithHooks 执行组件函数后,会得到 nextChildren:
function List({items}) {
return items.map(item => <li key={item.id}>{item.name}</li>);
}这个返回值本质上是新的 ReactElement 描述。React 接下来要回答四个问题:
1. 哪些旧 Fiber 可以复用?
2. 哪些新 ReactElement 需要创建新 Fiber?
3. 哪些旧 Fiber 在新 children 中不存在,需要删除?
4. 哪些复用的 Fiber 位置变了,需要在 commit 阶段移动 DOM?输入和输出可以这样看:
输入:
current.child 旧 child Fiber 链表
workInProgress 当前正在计算的父 Fiber
nextChildren 新 ReactElement children
renderLanes 本轮 render 的 lane
输出:
workInProgress.child 新 child Fiber 链表
flags/subtreeFlags 给 commit 阶段消费的副作用标记简化源码:
export function reconcileChildren(
current,
workInProgress,
nextChildren,
renderLanes
) {
if (current === null) {
workInProgress.child = mountChildFibers(
workInProgress,
null,
nextChildren,
renderLanes
);
} else {
workInProgress.child = reconcileChildFibers(
workInProgress,
current.child,
nextChildren,
renderLanes
);
}
}这里有一个关键区别:
mountChildFibers:初次挂载,没有旧 Fiber 可对比,主要是创建 Fiber。
reconcileChildFibers:更新阶段,需要和旧 Fiber 对比,并记录副作用 flags。也就是说,Diff 主要讨论的是更新阶段的 reconcileChildFibers。
5.2 复用规则:为什么是 key 和 type
React 判断“旧 Fiber 和新 ReactElement 是不是同一个节点”,核心看两个字段:
key 相同
type 相同
=> 认为是同一个节点,可以复用旧 Fiber这里的 type 指的是元素类型:
<div /> type 是 "div"
<Counter /> type 是 Counter 函数本身
<React.Fragment /> type 是 Fragment 符号为什么不是只看 type?因为同级列表里可能有很多相同类型:
[
<li>A</li>,
<li>B</li>,
<li>C</li>
]它们的 type 都是 "li",React 还需要 key 来判断“这个 li 对应的是哪一条数据”。
示例:
// key/type 都相同,可以复用旧 Fiber
<li key="a">old</li>
<li key="a">new</li>
// key 相同但 type 不同,不能复用
<li key="a">old</li>
<div key="a">new</div>
// type 相同但 key 不同,通常也不能复用
<li key="a">A</li>
<li key="b">B</li>复用不是“什么都不做”。复用的意思是:React 会基于旧 Fiber 创建或复用对应的 workInProgress Fiber,保留 state、ref、DOM 等身份相关信息,同时把新的 props 放到 pendingProps 上。
旧 Fiber 保存身份和历史信息
新 ReactElement 提供本次 render 的最新描述
复用后得到新的 workInProgress Fiber5.3 单节点 Diff:先理解最小模型
如果新的 children 是单个 ReactElement,Diff 的问题比较简单:从旧 child 链表里找到 key 相同的节点,再比较 type。
流程:
1. 遍历旧 child Fiber。
2. 如果 key 不同,说明不是目标节点,可以删除这个旧 Fiber。
3. 如果 key 相同,再比较 type。
4. type 相同:复用旧 Fiber,删除剩余旧兄弟节点。
5. type 不同:删除旧节点,创建新 Fiber。
6. 如果旧链表里没找到可复用节点,直接创建新 Fiber。简化源码:
function reconcileSingleElement(returnFiber, currentFirstChild, element, lanes) {
let child = currentFirstChild;
while (child !== null) {
if (child.key === element.key) {
if (child.elementType === element.type) {
deleteRemainingChildren(returnFiber, child.sibling);
return useFiber(child, element.props);
}
deleteRemainingChildren(returnFiber, child);
break;
}
deleteChild(returnFiber, child);
child = child.sibling;
}
return createFiberFromElement(element, returnFiber.mode, lanes);
}单节点 Diff 的价值是让你先建立“身份判断”的规则:key 定位同级身份,type 决定能不能延续这个身份。真实业务里的列表会走数组 Diff,但数组 Diff 也是围绕这个规则展开。
5.4 数组 Diff:两轮策略
数组 Diff 是面试高频点,因为列表更新会涉及复用、插入、删除和移动。
React 没有一开始就把所有旧节点建成 Map,而是分两轮做:
第一轮:从左到右按顺序比较,能复用就继续。
第二轮:一旦顺序比较失败,把剩余旧 Fiber 放进 Map,再用 key 或 index 查找可复用节点。为什么这么设计?
大多数列表更新是尾部追加、局部更新,顺序不变。
顺序比较成本最低,可以快速处理常见场景。
只有遇到插入、删除、移动导致顺序错位时,才进入 Map 兜底。简化源码:
function reconcileChildrenArray(
returnFiber,
currentFirstChild,
newChildren,
lanes
) {
let oldFiber = currentFirstChild;
let lastPlacedIndex = 0;
let newIdx = 0;
let previousNewFiber = null;
// 第一轮:顺序对比
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
const newFiber = updateSlot(
returnFiber,
oldFiber,
newChildren[newIdx],
lanes
);
if (newFiber === null) {
break;
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
previousNewFiber = link(previousNewFiber, newFiber);
oldFiber = oldFiber.sibling;
}
// 新数组结束,删除剩余旧 Fiber
if (newIdx === newChildren.length) {
deleteRemainingChildren(returnFiber, oldFiber);
return firstNewFiber;
}
// 旧 Fiber 结束,剩余新节点都是插入
if (oldFiber === null) {
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
previousNewFiber = link(previousNewFiber, newFiber);
}
return firstNewFiber;
}
// 第二轮:剩余旧 Fiber 建 Map
const existingChildren = mapRemainingChildren(oldFiber);
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = updateFromMap(
existingChildren,
returnFiber,
newIdx,
newChildren[newIdx],
lanes
);
if (newFiber !== null) {
existingChildren.delete(keyOf(newFiber));
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
previousNewFiber = link(previousNewFiber, newFiber);
}
}
deleteRemainingChildren(returnFiber, existingChildren);
return firstNewFiber;
}流程图:
flowchart TD
A["开始数组 Diff"] --> B["第一轮:oldFiber 与 newChildren[newIdx] 顺序比较"]
B --> C{"key/type 是否可复用"}
C -->|是| D["复用 Fiber,执行 placeChild"]
D --> B
C -->|否| E["停止第一轮"]
E --> F{"newChildren 是否遍历完"}
F -->|是| G["删除剩余旧 Fiber"]
F -->|否| H{"oldFiber 是否遍历完"}
H -->|是| I["创建剩余新 Fiber,标记 Placement"]
H -->|否| J["剩余旧 Fiber 建 Map"]
J --> K["第二轮:按 key 或 index 查找可复用旧 Fiber"]
K --> L["复用、新建、删除,并通过 placeChild 判断移动"]一个简单例子:
旧列表:A B C D
新列表:A B C D E前四个节点第一轮顺序比较就能复用,旧 Fiber 先结束,E 被创建并标记 Placement。这是尾部追加,成本很低。
再看一个插入场景:
旧列表:A B C
新列表:X A B C第一轮比较 A 和 X 失败,React 把旧的 A B C 建成 Map。然后新列表从 X 开始查:
X:Map 里没有,创建新 Fiber,标记 Placement
A:Map 里找到,复用
B:Map 里找到,复用
C:Map 里找到,复用5.5 移动判断:lastPlacedIndex 到底是什么
数组 Diff 里最容易绕的是“移动”。React 判断移动的核心函数是 placeChild,核心变量是 lastPlacedIndex。
先给定义:
lastPlacedIndex 表示:到目前为止,新列表已经处理过的节点中,能够保持相对顺序的最大旧 index。更口语一点:
React 从左到右扫描新列表。
如果一个节点在旧列表里的位置,比前面已经确认不动的节点还靠前,
说明它必须移动到后面去。简化源码:
function placeChild(newFiber, lastPlacedIndex, newIndex) {
newFiber.index = newIndex;
const current = newFiber.alternate;
if (current !== null) {
const oldIndex = current.index;
if (oldIndex < lastPlacedIndex) {
newFiber.flags |= Placement;
return lastPlacedIndex;
}
return oldIndex;
}
newFiber.flags |= Placement;
return lastPlacedIndex;
}这段逻辑分两类:
current === null:
这个新 Fiber 没有对应旧 Fiber,是新插入,标记 Placement。
current !== null:
这个 Fiber 可以复用。
如果 oldIndex < lastPlacedIndex,说明它相对顺序倒退了,标记 Placement,表示需要移动。
如果 oldIndex >= lastPlacedIndex,说明它仍然能保持相对顺序,不需要移动,并更新 lastPlacedIndex。例子一:A B C D 变成 B A C D
旧列表:A(0) B(1) C(2) D(3)
新列表:B A C D处理过程:
初始 lastPlacedIndex = 0
B:oldIndex = 1,1 >= 0,不移动,lastPlacedIndex = 1
A:oldIndex = 0,0 < 1,需要移动,标记 Placement,lastPlacedIndex 仍是 1
C:oldIndex = 2,2 >= 1,不移动,lastPlacedIndex = 2
D:oldIndex = 3,3 >= 2,不移动,lastPlacedIndex = 3为什么不是移动 B,而是移动 A?
React 是从新列表左到右扫描。
B 在新列表里先出现,而且它在旧列表中的 oldIndex = 1。
后面遇到 A 时,A 的 oldIndex = 0,比 B 的 oldIndex 更小。
这说明 A 原本在 B 前面,现在要出现在 B 后面,所以 A 需要移动。例子二:A B C D 变成 D A B C
初始 lastPlacedIndex = 0
D:oldIndex = 3,3 >= 0,不移动,lastPlacedIndex = 3
A:oldIndex = 0,0 < 3,移动
B:oldIndex = 1,1 < 3,移动
C:oldIndex = 2,2 < 3,移动这说明 React 的移动策略是一种启发式策略。它不是计算“全局最少移动次数”,而是尽量保留旧 index 单调递增的那部分节点,其他节点标记 Placement。
流程图:
flowchart TD
A["处理一个 newFiber"] --> B{"是否有 alternate"}
B -->|没有| C["新插入:标记 Placement"]
B -->|有| D["读取 oldIndex = alternate.index"]
D --> E{"oldIndex < lastPlacedIndex"}
E -->|是| F["相对顺序倒退:标记 Placement,表示移动"]
E -->|否| G["相对顺序保持:不移动,更新 lastPlacedIndex = oldIndex"]commit 阶段看到 Placement 后,会找到宿主父节点和插入位置,最终用类似下面的 DOM API 完成插入或移动:
parent.insertBefore(dom, beforeNode);
// 或
parent.appendChild(dom);同一个 DOM 节点如果已经在页面上,insertBefore / appendChild 会把它移动到新位置,而不是复制一份。
5.6 为什么不推荐用 index 做 key
key 的作用是描述节点身份。index 描述的是当前位置,不是这条数据是谁。
如果列表只静态展示、不会插入、删除、排序,用 index 通常问题不大。但只要列表顺序会变化,index key 就容易导致 Fiber 错误复用。
例子:
// 旧列表
[
<Item key={0} name="A" />,
<Item key={1} name="B" />,
<Item key={2} name="C" />,
]
// 头部插入 X 后的新列表
[
<Item key={0} name="X" />,
<Item key={1} name="A" />,
<Item key={2} name="B" />,
<Item key={3} name="C" />,
]React 会看到:
old key 0: A
new key 0: X
old key 1: B
new key 1: A
old key 2: C
new key 2: B因为 key 一样,React 会误以为这些节点是同一个组件,只是 props 变了。问题在于组件状态会跟着“位置”走,而不是跟着“数据”走。
例如列表项里有输入框:
items.map((item, index) => (
<TodoItem key={index} todo={item} />
));用户在 B 这一项里输入内容,然后头部插入 X。原来的 B 位置变了,但第二个位置的 Fiber 可能被复用给 A,于是输入框状态可能跑到错误的数据项上。
正确写法应该使用稳定业务 id:
items.map(item => (
<TodoItem key={item.id} todo={item} />
));这样即使 B 从第二位移动到第三位,React 依然能通过 item.id 识别它是同一个组件,只是位置变了。
面试表达:
key 用来标识同级节点身份,不应该表达当前位置。
index key 在插入、删除、排序时会变化,导致 React 把旧 Fiber 错误复用给另一条数据,
进而出现状态错位、输入框内容串行、动画异常等问题。
动态列表应该优先使用稳定且唯一的业务 id。5.7 flags 和 commit 的关系
Diff 的结果不会立刻修改 DOM,而是记录到 Fiber flags 上:
flags | 含义 | commit 阶段动作 |
|---|---|---|
| 新插入或移动 |
|
| 属性、文本、ref 等需要更新 |
|
| 旧子节点需要删除 |
|
所以完整链路是:
render 阶段:
旧 Fiber vs 新 ReactElement
-> 生成新的 child Fiber 链表
-> 给需要变化的 Fiber 打 flags
commit 阶段:
扫描 flags
-> mutation 阶段执行 DOM 插入、删除、移动、更新最终面试表达:
React Diff 发生在 render 阶段的 reconcileChildren / ReactChildFiber 中。
它比较的是旧 Fiber 和新 ReactElement,而不是直接比较 DOM。
同级 children 中,React 先按顺序比较 key/type,能复用就继续;
一旦顺序比较失败,就把剩余旧 Fiber 建成 Map,再根据新节点的 key 或 index 查找可复用 Fiber。
复用的前提是 key 和 type 都匹配。
移动通过 placeChild 和 lastPlacedIndex 判断:如果复用节点的 oldIndex 小于 lastPlacedIndex,
说明它相对顺序倒退,需要标记 Placement。
Diff 只负责生成新 Fiber 和 flags,真正 DOM 操作发生在 commit 的 mutation 阶段。六、completeWork:向上收尾并收集副作用
completeWork 是 render 阶段的“向上阶段”。当一个 Fiber 没有 child 继续向下时,React 开始回溯并执行 completeWork。
它主要做三件事:
为宿主节点创建或准备 DOM 实例。
标记当前 Fiber 自己的副作用 flags。
通过
bubbleProperties把子树 flags 冒泡到父 Fiber。
简化源码:
function completeWork(current, workInProgress, renderLanes) {
const newProps = workInProgress.pendingProps;
switch (workInProgress.tag) {
case FunctionComponent:
bubbleProperties(workInProgress);
return null;
case HostComponent:
if (current !== null && workInProgress.stateNode !== null) {
updateHostComponent(current, workInProgress, newProps);
} else {
const instance = createInstance(
workInProgress.type,
newProps,
workInProgress
);
appendAllChildren(instance, workInProgress);
workInProgress.stateNode = instance;
}
bubbleProperties(workInProgress);
return null;
case HostText:
if (current !== null) {
updateHostText(current, workInProgress);
} else {
workInProgress.stateNode = createTextInstance(newProps);
}
bubbleProperties(workInProgress);
return null;
}
}FunctionComponent 为什么没有创建 DOM?
函数组件本身不是 DOM 节点。
它只是执行后返回 ReactElement,真正的 DOM Fiber 是 HostComponent / HostText。HostComponent 例如 div、button,才会对应真实 DOM 节点。
但是 completeWork 只处理当前 Fiber 还不够。commit 阶段要高效找到整棵树里哪里有副作用,所以回溯时还要把子节点的副作用汇总到父节点上,这就是 flags 和 subtreeFlags 的关系。
6.1 flags 与 subtreeFlags
flags = 当前 Fiber 自己的副作用
subtreeFlags = 子树中所有副作用的汇总简化源码:
function bubbleProperties(completedWork) {
let subtreeFlags = NoFlags;
let child = completedWork.child;
while (child !== null) {
subtreeFlags |= child.subtreeFlags;
subtreeFlags |= child.flags;
child.return = completedWork;
child = child.sibling;
}
completedWork.subtreeFlags |= subtreeFlags;
}为什么需要 subtreeFlags?
commit 阶段不应该盲目遍历整棵树。
如果某个子树没有任何副作用,React 就可以快速跳过。6.2 finishedWork 是什么
render 阶段完成后,React 得到:
finishedWork = 已经计算完成的 workInProgress 树它包含:
本次更新后的 Fiber 状态。
新的 child Fiber 结构。
DOM 更新需要的 flags。
effects 相关标记。
但它还没有成为正式 current。只有 commit 阶段执行:
root.current = finishedWork;之后,finishedWork 才正式成为当前树。
面试表达:
completeWork 仍然属于 render 阶段,不是 commit。
它会在 Fiber 回溯时为 HostComponent 创建或准备 DOM 实例,标记 Update 等 flags,
并把子树副作用通过 subtreeFlags 冒泡到父节点。render 完成后得到 finishedWork,
它是等待 commit 的新 Fiber 树。七、Commit 阶段:把副作用真正提交到宿主环境
commit 阶段入口:
commitRoot(root, finishedWork, lanes, ...)commit 大体分为:
before mutation
-> mutation
-> root.current = finishedWork
-> layout
-> passive为什么要拆阶段?
因为不同副作用需要在不同 DOM 时机执行。
有的要在 DOM 改变前读取快照。
有的要真正修改 DOM。
有的要在 DOM 更新后同步读取布局。
有的可以延后执行,不阻塞绘制。简化源码:
function commitRoot(root, finishedWork, lanes) {
root.finishedWork = null;
root.finishedLanes = NoLanes;
if (subtreeHasBeforeMutationEffects || rootHasBeforeMutationEffect) {
commitBeforeMutationEffects(root, finishedWork, lanes);
}
if (subtreeHasMutationEffects || rootHasMutationEffect) {
commitMutationEffects(root, finishedWork, lanes);
root.current = finishedWork;
} else {
root.current = finishedWork;
}
if (subtreeHasLayoutEffects || rootHasLayoutEffect) {
commitLayoutEffects(finishedWork, root, lanes);
}
schedulePassiveEffectsIfNeeded(root);
}下面几个阶段不要孤立背。它们的顺序其实按“DOM 被修改前、正在修改、修改后同步读布局、最后异步副作用”来排,这样就能解释为什么 root.current 要夹在 mutation 和 layout 中间。
7.1 before mutation
入口:
commitBeforeMutationEffects发生时机:
DOM 还没有被 mutation 阶段修改。典型用途:
class 组件 getSnapshotBeforeUpdate
读取更新前的 DOM 快照,例如滚动位置、光标位置7.2 mutation
入口:
commitMutationEffects这是 commit 阶段真正修改 DOM 的地方:
插入 DOM 节点。
删除 DOM 节点。
更新 DOM 属性。
更新文本内容。
处理部分 ref 解绑。
执行卸载相关逻辑。
render 阶段只是打 flags:
Placement
Update
ChildDeletionmutation 阶段才真正执行:
appendChild
insertBefore
removeChild
commitUpdate
commitTextUpdate7.3 root.current = finishedWork
mutation 阶段之后、layout 阶段之前,React 会执行:
root.current = finishedWork;这是双缓冲切换的最终点。
执行前:
FiberRoot.current -> 旧 current 树
旧 current.alternate -> finishedWork执行后:
FiberRoot.current -> finishedWork
finishedWork.alternate -> 旧 current 树为什么不在 mutation 前切换?
mutation 阶段有卸载、删除、ref 等逻辑需要旧 current 树仍然可用。为什么要在 layout 前切换?
layout 阶段会执行 useLayoutEffect、componentDidMount、componentDidUpdate。
这些逻辑应该看到最新 DOM 和最新 Fiber 树。7.4 layout 与 passive
layout 阶段:
入口:commitLayoutEffects
时机:DOM 已更新,root.current 已切换,浏览器绘制通常还没发生
执行:useLayoutEffect、componentDidMount、componentDidUpdate、ref 绑定后的同步逻辑passive 阶段:
入口:flushPassiveEffects
执行:useEffect
特点:通常不阻塞浏览器绘制对比:
Hook | 执行阶段 | DOM 是否更新 | 是否阻塞绘制 |
|---|---|---|---|
| layout | 已更新 | 通常会阻塞 |
| passive | 已更新 | 通常不阻塞 |
7.5 render 可中断,commit 不可中断
render 阶段:
主要在内存中计算 Fiber 树。
尚未真正修改 DOM。所以它可以暂停、恢复、被更高优先级打断,甚至丢弃 WIP 重新计算。
commit 阶段:
会真实修改 DOM,并执行生命周期和 effects。如果 commit 被中断,可能出现:
DOM 改了一半
Fiber current 没切
effects 执行了一部分
页面和 React 内部状态不一致面试表达:
React 的可中断主要发生在 render 阶段,因为 render 只是计算 Fiber 和 flags。
commit 阶段会修改真实宿主环境并执行副作用,因此必须同步完成。八、用 0 -> 1 更新串起主流程
假设组件:
function Counter() {
const [count, setCount] = useState(0);
return <button => setCount(1)}>{count}</button>;
}第一次页面显示:
0点击按钮后:
setCount(1)
-> dispatchSetState
-> requestUpdateLane
-> 创建 update(action = 1)
-> update 入队
-> scheduleUpdateOnFiber
-> markRootUpdated(root, lane)
-> ensureRootIsScheduled(root)此时页面还没变。
这一步只是把更新挂到队列里,并把 root 标记为“有待处理任务”。真正的新 state 还没计算,DOM 也还没提交。接下来 React 会根据 lane 选择本轮要处理的工作,然后创建 WIP 树进入 render 阶段。
随后进入 render:
root.current
-> createWorkInProgress
-> workLoop
-> performUnitOfWork
-> beginWork(Counter)
-> updateFunctionComponent
-> renderWithHooksrenderWithHooks 消费 updateQueue:
旧 state = 0
update.action = 1
新 state = 1state 算出来以后,组件函数会用这个新 state 重新执行。所以组件返回的 ReactElement,也会从旧的 <button>0</button> 变成新的 <button>1</button>。
函数组件重新执行:
Counter()
-> 返回 <button>1</button> 的 ReactElement拿到新的 ReactElement 后,React 还不能直接更新 DOM。它要先把新 ReactElement 和旧 child Fiber 对齐,决定哪些 Fiber 复用,哪些需要创建、删除或更新。
然后 diff:
reconcileChildren
-> 对比旧 child Fiber 和新 ReactElement
-> 复用 button Fiber
-> 文本 child 标记 Updatediff 只是把变化记录到 Fiber 上。接下来 completeWork 会在回溯时收尾,并把子树里的 flags 冒泡到父节点,方便 commit 阶段快速找到需要提交的部分。
向上收尾:
completeWork(button / text)
-> 准备 DOM 更新
-> bubbleProperties 收集 flagsrender 完成:
finishedWork = 新 WIP 树commit 提交:
commitRoot(finishedWork)
-> before mutation
-> mutation 更新 DOM 文本 0 -> 1
-> root.current = finishedWork
-> layout effects
-> passive effects这时页面显示:
1九、面试问答、源码入口与复习清单
Q1:React 一次更新的完整流程是什么?
以函数组件 useState 为例,setState 会进入 dispatchSetState。
React 会为这次更新分配 lane,创建 Hook update,并把 update 放入 updateQueue。
随后 React 会从触发更新的 Fiber 向上找到 FiberRoot,
调用 scheduleUpdateOnFiber 标记 root 上有待处理更新,并安排 render。
render 阶段从 root.current 创建 workInProgress 树。
workLoop 会不断执行 performUnitOfWork。
每个 Fiber 先进入 beginWork,函数组件会走 updateFunctionComponent,
再通过 renderWithHooks 执行组件函数并消费 Hook updateQueue。
组件返回新的 ReactElement 后,React 通过 reconcileChildren 生成或复用 child Fiber。
当没有 child 可以继续向下时,React 进入 completeWork,向上准备宿主实例并收集 flags。
render 完成后得到 finishedWork。
commit 阶段会拿 finishedWork 上的 flags 提交副作用。
before mutation 阶段读取 DOM 更新前快照。
mutation 阶段真正更新 DOM。
mutation 后执行 root.current = finishedWork,新树成为 current。
layout 阶段执行 useLayoutEffect 等同步副作用。
passive 阶段执行 useEffect。
Q2:render 和 commit 的本质区别是什么?
render 阶段负责计算。它在内存中构建 workInProgress Fiber 树,
计算新 state、新 children 和副作用 flags,通常不直接操作 DOM。
因此 render 阶段可以被中断和恢复。
commit 阶段负责提交。它会根据 finishedWork 上的 flags 修改真实 DOM,
并执行 layout / passive effects。由于会影响真实页面和副作用执行,
commit 阶段不能随意中断。
Q3:beginWork 和 completeWork 怎么区分?
beginWork 是向下阶段,处理当前 Fiber,判断是否需要更新,并根据 tag 分发到不同逻辑。
函数组件会在这里进入 renderWithHooks,HostRoot 会处理 updateQueue,
HostComponent 会处理 children。beginWork 最终会 reconcile children,并返回下一个 child Fiber。
completeWork 是向上阶段,在当前 Fiber 的子节点都处理完后执行。
它会为 DOM Fiber 创建或更新宿主实例,标记 Update 等 flags,
并通过 bubbleProperties 收集子树副作用。
函数组件本身没有 DOM 实例,所以它的 completeWork 主要是收集子树副作用。
Q4:为什么 setState 后 state 不是立刻变?
setState 表达的是一次更新意图。
React 会把这个意图包装成 update,并放入 updateQueue。
真正的新 state 是在 render 阶段处理 Hook updateQueue 时计算出来的。
这样 React 才能合并多个更新、按优先级处理更新,并支持并发渲染。
Q6:diff 发生在哪里?
diff 发生在 render 阶段,核心在 reconcileChildren / ReactChildFiber。
React 会把新的 ReactElement children 和旧的 current.child 进行比较,
决定哪些 Fiber 可以复用,哪些需要创建、删除或移动,并打上 flags。
真正 DOM 操作不会在 diff 时发生,而是在 commit 的 mutation 阶段发生。
Q7:数组 diff 的核心策略是什么?
React 数组 diff 分两轮。
第一轮从左到右顺序比较,能复用就继续。
一旦顺序比较失败,就把剩余旧 Fiber 放入 Map,
再用新 children 的 key 或 index 去 Map 中查找可复用 Fiber。
复用的前提是 key 和 type 都匹配。
移动通过 lastPlacedIndex 判断,旧 index 小于 lastPlacedIndex 的节点需要移动。
不推荐用 index 做 key,因为 index 表示位置而不是数据身份。
插入、删除、排序时,旧 Fiber 和 Hook state 可能被复用到错误的数据上。


评论
还没有评论
来发表第一条评论吧!