跳到主要内容

4 篇博文 含有标签「源码」

查看所有标签

useEffect 三种用法,在 commit 阶段的挂载时机(源码级辨析)

· 阅读需 7 分钟

useEffect 是渲染后异步执行的副作用」——这句话对,但不完整。同样是 useEffectuseEffect(fn)useEffect(fn, [])useEffect(fn, [a])commit 阶段触发的时机和条件完全不同;再叠上 useLayoutEffect,时机又变了。

本文从 React 源码(open/react)出发,把「不同使用方式 → 不同 flag → commit 阶段不同挂载时机」这条链讲清楚。

React diff 算法源码拆解:子节点调和的四大步骤

· 阅读需 12 分钟

上一篇我们讲了 React 19 把属性级 diffprepareUpdate/updatePayload)挪进了 commit 阶段。但大家常说的"React diff 算法",其实指的是另一个完全不同的东西——子节点调和(reconciliation):比较同一层级的 children,决定哪些 fiber 复用、哪些删除、哪些新建、哪些移动。

本文从源码角度拆解这个算法,核心是 reconcileChildrenArray四大步骤(这四步直接写在 React 源码 ReactChildFiber.js 的注释里)。先概述全局,再逐步骤分论。


1. 概述:diff 到底 diff 什么

触发链

beginWork
└─ reconcileChildren(current, workInProgress, nextChildren)
├─ current === null → mountChildFibers (shouldTrackSideEffects = false)
└─ current !== null → reconcileChildFibers (shouldTrackSideEffects = true)
├─ 单个元素 → reconcileSingleElement
├─ 数组 → reconcileChildrenArray ★ 本文主角(四大步骤)
└─ 可迭代 → reconcileChildrenIteratable

关键点:diff 不在 commit 阶段,也不直接操作 DOM。它发生在渲染阶段,是纯 JS 的结构计算——产出的是新的 fiber 树 + 副作用标记(flags),真正的 DOM 增删改要等 commit 阶段才落地:

三个设计前提

React 的 diff 不是"通用 diff",它做了三个刻意的简化,才把复杂度压到 O(n):

  1. 只比较同一层级的兄弟节点——树形结构天然分层,不做跨层比较(跨层移动 = 先删后建);
  2. key 识别"同一个节点"——key 相同才认为可以复用,这是复用的唯一依据;
  3. 类型(type)不同直接销毁重建——一旦 type 变了(比如 <div><span>),就不再深入 diff,整个旧 fiber 作废。

这三个前提决定了 diff 的行为边界,也是面试里"为什么 React diff 是 O(n)"的标准答案。


2. 四大步骤总览

reconcileChildrenArray 从头到尾就是四步——它对应源码里 reconcileChildrenArray 的四个结构分支:

1. Reconcile the children in the same order with the same key → 前缀扫描复用
2. Delete the remaining old children when the new children are exhausted → 删除
3. Create new fibers for the remaining new children when the old children are exhausted → 新建
4. Reconcile the remaining children and clean up the old children → map 移动 + 清理

整体决策流程:

四个步骤互相衔接:能省则省。前两步(前缀复用、整段删除)是最常见的场景,走的都是"不建 Map"的快速路径;只有真正出现乱序/中间插入时才落到第 4 步建 Map。


3. Step 1:前缀扫描——同序同 key 复用

这是 diff 的快速路径。同时从左到右遍历旧 fiber 链表和新 children,只要 key 匹配就复用旧 fiber(updateElement 原地更新 props),key 一旦不匹配立即 break

// ReactChildFiber.js(简化示意)
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
const newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx]);
if (newFiber === null) {
break; // ★ key 不匹配,停止前缀扫描
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// ... 链接 sibling
}

updateSlot 的匹配逻辑(ReactChildFiber.js:811)——key 相等才继续,否则返回 null 表示"这一位对不上"

function updateSlot(returnFiber, oldFiber, newChild, lanes) {
const key = oldFiber !== null ? oldFiber.key : null;
// ...文本节点:key 必须为 null 才可复用
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE: {
if (newChild.key === key) { // ★ key 匹配 → 进入 updateElement
return updateElement(returnFiber, oldFiber, newChild, lanes);
} else {
return null; // ★ key 不匹配 → break
}
}
}
}
// ...
}

例子[A, B, C, D][A, B, C, X]

A 匹配 → 复用 | B 匹配 → 复用 | C 匹配 → 复用 | X 与 D 的 key 不同 → break

前三项零成本复用,只有 X 进入后面的步骤。append / 前缀删除这类最常见的操作,在 Step 1 就结束了,这也是 React 刻意保留"先正向扫描"的原因(源码注释里提到:先走 forward-only 路径,只有发现需要大量前瞻时才用 Map)。


4. Step 2:删除——新 children 耗尽,剩余旧的全删

前缀扫描 break 时,如果 newIdx 已经等于新 children 长度,说明新列表走完了但旧列表还剩——剩下的旧 fiber 全部无用,一次性删除:

// ReactChildFiber.js(简化示意)
if (newIdx === newChildren.length) {
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}

deleteRemainingChildren → 逐个 deleteChild

function deleteChild(returnFiber, childToDelete) {
if (!shouldTrackSideEffects) return; // mount 阶段不追踪
const deletions = returnFiber.deletions;
if (deletions === null) {
returnFiber.deletions = [childToDelete]; // 挂在父 fiber 上
returnFiber.flags |= ChildDeletion; // ★ 打 ChildDeletion 标记
} else {
deletions.push(childToDelete);
}
}

注意删除是"记账"不是真删:被删的 fiber 收集到 returnFiber.deletions 数组 + 打 ChildDeletion flag,commit 阶段才真正卸载 DOM 并执行 unmount effect。

例子[A, B, C][A]:A 复用,B、C 在 Step 2 被标记删除。


5. Step 3:新建——旧 children 耗尽,剩余新的全建

反过来,如果 break 时 oldFiber === null(旧链表走完了但新 children 还有),说明剩下的全是新增,走快路径批量 createChild

// ReactChildFiber.js(简化示意)
if (oldFiber === null) {
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = createChild(returnFiber, newChildren[newIdx]);
if (newFiber === null) continue;
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// ... 链接 sibling
}
return resultingFirstChild;
}

createChild 按元素类型生成全新 fiber(createFiberFromElement / createFiberFromFragment / createFiberFromText),placeChild 会给它打 Placement 标记(commit 时插入 DOM)。

例子[A][A, B, C]:A 复用,B、C 在 Step 3 新建并打 Placement。


6. Step 4:map 移动 + 清理——乱序 / 中间差异的核心

走到这里说明新旧都还有剩余——要么 key 顺序乱了,要么中间插了东西。React 不能再线性对齐,于是把剩余旧 fiber 塞进一个 Map(按 key 索引),再逐个去"认领"

// ReactChildFiber.js(简化示意)—— Step 4
const existingChildren = mapRemainingChildren(oldFiber); // key → fiber(无 key 用 index)
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = updateFromMap(existingChildren, returnFiber, newIdx, newChildren[newIdx]);
if (newFiber === null) continue;
if (shouldTrackSideEffects) {
if (newFiber.alternate !== null) {
existingChildren.delete(newFiber.key == null ? newFiber.index : newFiber.key); // 认领后从 Map 删
}
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// ... 链接 sibling
}
// 遍历完,Map 里剩下的都是"新列表里已经不要了"的旧 fiber → 全部删除
existingChildren.forEach(child => deleteChild(returnFiber, child));

mapRemainingChildren 建 Map(ReactChildFiber.js:463):有 key 用 key,没 key 用 index 兜底。

updateFromMap 负责"认领"(ReactChildFiber.js:941):按 key(或 index)在 Map 里找匹配的旧 fiber → 找到且 type 匹配就 updateElement 原地复用;找不到就 createChild 新建。

例子[A, B, C][C, A, D]

前缀扫描:A 对 C,key 不匹配 → break(进 Step 4)
建 Map:{A, B, C}
遍历新列表:
C → Map 命中 → 复用 C,删 Map 里的 C,placeChild 判定移动
A → Map 命中 → 复用 A,删 Map 里的 A,placeChild 判定移动
D → Map 未命中 → createChild 新建
Map 剩余 {B} → B 已不在新列表 → deleteChild(B)

Step 4 用 O(1) 的 Map 查询替代了 O(n) 的线性查找,这是乱序场景下 diff 依然保持 O(n) 均摊的关键。


7. 移动判定:placeChildlastPlacedIndex(最精妙的一步)

四大步骤反复调用 placeChild,它决定一个复用节点是"待在原位"还是"要移动"。判据是 lastPlacedIndex——已放置的最右位置

// ReactChildFiber.js(简化示意)
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;
} else {
return oldIndex; // 顺序保持 → 不动,更新最右位置
}
} else {
newFiber.flags |= Placement; // 全新 → 插入
return lastPlacedIndex;
}
}

直觉:我们从左到右扫描新列表,lastPlacedIndex 记录"已经就位的最靠右的旧位置"。如果一个节点旧的 index 比这个还小,说明它原本在左边、现在排到了右边——相对顺序变了,必须移动

例子[a, b, c, d][d, a, b, c]

d:oldIndex=3, lastPlacedIndex=0 → 3≥0 → 不动,lastPlacedIndex=3
a:oldIndex=0, lastPlacedIndex=3 → 0<3 → 移动(打 Placement)
b:oldIndex=1, lastPlacedIndex=3 → 1<3 → 移动
c:oldIndex=2, lastPlacedIndex=3 → 2<3 → 移动

结果:d 原位,a/b/c 各移动一次。这是 "只向前移动"的贪心(源码注释明确写了这个 limitation):[d, a, b, c] 明明最优只需移 1 次(把 d 挪到末尾),React 却选择移 3 次(a/b/c 挪到 d 后面)。代价是"移动次数可能不是最优",换来的是单次遍历、无需回溯的 O(n) 复杂度。源码注释:

"we only support moving a fiber forward, not backward… So we have to move 3 times to place a, b, c instead of moving 1 time to place d."


8. 单节点情况:reconcileSingleElement

当新 children 是单个元素(不是数组)时走 reconcileSingleElementReactChildFiber.js:1634)。逻辑更直接——遍历旧链表找"对的那个":

旧 child 的 key 与 type处理
key 匹配 type 匹配useFiber 复用,删除其余 sibling
key 匹配 type 不同整个旧链表不可复用 → 全部删除 → 新建
key 不匹配删掉当前 child,继续找下一个 sibling
遍历完没找到创建全新 fiber

单节点不用建 Map,O(n) 线性扫一遍即可(旧链表通常很短)。


9. key 的意义与注意事项

Step 1 和 Step 4 都依赖 key,key 是整套算法的"身份标识"。两条铁律:

  1. key 要稳定且唯一——同一列表里不能重复,跨渲染不能变;
  2. 别用 index 当 key——一旦在中间插入/删除元素,index 会整体位移,Step 1 的"同序同 key"匹配会认错节点,导致复用错乱的 fiber(state/DOM 对不上)

反例:[A, B][X, A, B],若用 index 当 key:

Step 1:X(index0) 对 A(index0) → key 都是 0 → 复用 A 的 fiber 渲染 X!❌ 状态错乱
A(index1) 对 B(index1) → key 都是 1 → 复用 B 的 fiber 渲染 A!

而用稳定 key 时:X 找不到匹配 → 新建;A、B 的 key 匹配 → 正确复用,只有 X 一次插入。


10. 复杂度分析

场景走哪条路复杂度
前缀一致(append / 头部删除)Step 1(可能 + Step 2/3)O(n),不建 Map
乱序 / 中间插入Step 4O(n) 均摊(Map 构建 O(m) + 认领 O(k),m/k 都是剩余量)
单节点reconcileSingleElementO(n)(旧链表长度)

代价是 Step 1 的前缀扫描在"开头就乱序"的场景下白扫一部分,以及移动是贪心(非最优步数)——但换来的是绝对的单次遍历,这正是 React diff 敢声称 O(n) 的原因。


11. 一句话总结

React 的 diff = 子节点调和:渲染阶段用 reconcileChildFibers 比较同层兄弟,通过前缀扫描复用 → 整段删除 → 整段新建 → Map 认领移动四大步骤,产出新的 fiber 树 + Placement/ChildDeletion 标记,commit 阶段才落地 DOM。O(n) 的秘密是"单次遍历 + key 的 O(1) 匹配 + 只向前移动的贪心",而这一切都建立在三个前提上:只比同层、key 定身份、type 不同即重建


参考

  • React 19.2.7 ReactChildFiber.jsreconcileChildrenArray(1124) / reconcileSingleElement(1634) / updateSlot(811) / placeChild(492) / mapRemainingChildren(463) / updateFromMap(941)

React 19:把属性 diff 从渲染阶段挪进 commit 阶段 —— prepareUpdate/updatePayload 的终结

· 阅读需 8 分钟

很多 React 教程(尤其是写 React 16–18 的书)里会有这样一段:"HostComponent 在 completeWork 的更新流程中,属性发生变化时,会把 diff 的结果以 ['title', 1, 'style', {'color': '#333'}] 这样的扁平数组保存在 updateQueue 里。"

这段描述是真的,但只对 React 16–18 成立。React 19 干了一件大事:把 prepareUpdate 整个删掉,属性 diff 从"渲染阶段预计算"挪到了"commit 阶段边 diff 边应用"。这个改动同时回答了一个经典困惑——updateQueue 到底是"环形链表"还是"key-value 数组"?答案是:它俩都对,因为 updateQueue 是"多态"的,而这个改动恰好把其中一个形态清掉了。

本文扒开这个改动的全部细节:前后机制、diffProperties 怎么生成数组、为什么要删(PR #26583)。


1. React 16–18:渲染时算好 diff,commit 照单执行

React 18(packages/react-reconciler/src/ReactFiberCompleteWork.new.js)里,completeWork 处理已有 HostComponent 的更新分支长这样:

const updatePayload = prepareUpdate(
instance,
type,
oldProps,
newProps,
rootContainerInstance,
currentHostContext,
);
// 把算好的 diff 结果存进 updateQueue
workInProgress.updateQueue = (updatePayload: any);
// 有变化才打 Update 标记
if (updatePayload) {
markUpdate(workInProgress);
}

prepareUpdate(DOM 实现里叫 diffProperties)做的事情是:逐属性比较 oldPropsnewProps,把"要变更的属性"压进一个扁平数组

['title', 1, 'style', {'color': '#333'}]
^^^^^^ ^ ^^^^^ ^^^^^^^^^^^^^^
key val key val

规则包括:

  • 新增/修改的属性push(propKey, propValue)
  • 被删除的属性push(propKey, '')(空字符串,应用时清掉)
  • style 特殊处理 → 先把旧 style 里的属性收集成 styleUpdates = {color: ''}(清掉消失的样式),再把新 style 差异合并进去
  • children / dangerouslySetInnerHTML / 合成事件等 → 直接跳过,不走这个数组

然后 commit 的 mutation 阶段:

commitUpdate(instance, updatePayload, type, oldProps, newProps)
updateProperties(domElement, updatePayload, type, oldProps, newProps)
两两一组 (i, i+1) 应用到 DOM

好处:diff 只在渲染阶段算一次,commit 阶段只做"照单执行"的机械活;updatePayload === null 时连 Update 标记都不打——这是一个"深度 bailout":只要属性没变,commit 阶段整条链都跳过这个节点。


2. React 19:删掉 prepareUpdate,commit 现场边 diff 边应用

React 19(v19.2.7)里,同一个更新分支变成了:

// ReactFiberCompleteWork.js
function updateHostComponent(current, workInProgress, type, newProps, renderLanes) {
if (supportsMutation) {
const oldProps = current.memoizedProps;
if (oldProps === newProps) {
return; // 引用相等 → 直接 bailout
}
markUpdate(workInProgress); // 只打一个 Update 标记,不算 diff、不存数组
}
}

commit 阶段:

// ReactFiberConfigDOM.js
commitUpdate(domElement, type, oldProps, newProps)
updateProperties(domElement, type, oldProps, newProps) // 边 diff 边应用

updateProperties 把 v18 的 diffProperties(生成数组)和"应用数组"合并成了单次遍历:对每个属性直接比较 lastProps vs nextProps,变了就立刻 setProp。没有中间数组,updateQueue 也不再承担 HostComponent 的属性 diff 载体。

对比:

React 16–18React 19
diff 发生在哪渲染阶段(completeWork)commit 阶段(mutation)
产物扁平数组 [k,v,k,v,…]无(边 diff 边改 DOM)
存哪workInProgress.updateQueue不存
提交阶段照单执行现场计算 + 应用
bailoutpayload===null 深度跳过只能靠 oldProps===newProps 引用相等

3. 为什么要删?(PR #26583,作者 Sebastian Markbåge)

这个改动的提交信息非常坦诚,直接摆出了代价与收益

Diff properties in the commit phase instead of generating an update payload (#26583) "This removes the concept of prepareUpdate(), behind a flag."

收益(为什么这么做)

  1. 省内存分配:不再每次渲染为每个有变化的元素分配一个数组;diffProperties 里数组是 [] 起步、可能扩容,元素多时可能产生多个临时对象。commit 阶段全部省掉。
  2. 总工作量更少:diff 只在一处做一遍,不再"渲染算一遍 + commit 应用一遍"。
  3. 为未来优化铺路:既然在 commit 阶段做单次循环,就可以"在一个循环里把需要的所有属性读出来",避免对 props 的多态(polymorphic)读取——这是后续重构的基础。
  4. 统一 host config:React Native(生成 payload 再应用)和 React Fabric(渲染阶段做)各有一套,这个改动把它们统一成"单一宿主配置",更一致。

代价(作者列出的 downsides)

  1. children-only 也会触发 commit 更新:如果只有 children 变了,v18 里 diffProperties 会跳过 children、返回 null → 不 markUpdate;v19 只能靠引用比较,children 属性对象变了就会排一个 commit 更新(虽然遍历本来就会经过它,额外开销不大)。
  2. commit 阶段更重:对一棵"大部分没变"的大树,commit 停留时间变长。
  3. 失去深度 bailout:v18 的 payload === null 是一种"精确知道啥也没变"的信号;v19 没了这层,特殊场景要做的活变多。
  4. 代码重复:每个特殊 case(input/select/textarea…)都要自己复制一遍"清理旧属性"的循环。

落地过程:先是 flag,后 Ship

  1. 2023-04 PR #26583ca41adb8c1 把这个行为放到 diffInCommitPhase 开关后面;
  2. 随后 PR #274097f6201889e "Ship diffInCommitPhase"——Meta 内部性能测试结果中性,于是默认开启;
  3. React 19 正式版:开关被彻底删除,成为唯一路径。

"Meta 测试中性"这个结论很重要——它不是一次"大幅性能优化",而是一次架构净收益:性能不变,但代码更少、分配更少、宿主配置更统一。

更深一层的动机:渲染阶段应当是"纯的"

React 19 的核心方向是默认并发(concurrent by default)。并发渲染意味着渲染随时可能被 shouldYield 打断、整棵 workInProgress 树被丢弃重来。在渲染阶段预计算 updatePayload 属于"宿主相关的副作用",如果这次渲染被放弃,那数组就是白算的。挪到 commit 阶段后:

  • diff 只会在最终提交的那棵树上计算一次;
  • 渲染阶段保持纯净(只做 JS 层面的树构建),宿主细节全部收敛到 commit。

4. React 19 里 HostComponent 的 updateQueue:现在是 null

既然 React 19 删掉了数组,那 HostComponent 的 updateQueue 现在是什么形态、干什么用?

答案是:null,彻底闲置。 Fiber 构造函数里 updateQueue 初始就是 nullReactFiber.js:161),而 v19 里没有任何代码会去给 HostComponent 设置或读取它——completeWork 的 HostComponent 分支(ReactFiberCompleteWork.js:1337)只做一件事:updateHostComponentmarkUpdate(workInProgress) 打一个 Update flag。属性 diff 要的 old/new props 直接来自 current.memoizedPropsworkInProgress.memoizedProps,完全不走 updateQueue

所以"19 里 HostComponent 的 updateQueue 干什么用"的答案是:什么都不干。它保留在 Fiber 结构里,纯粹是因为 Fiber 是通用数据结构——updateQueue 字段对别的 fiber 类型还有用:

fiber 类型updateQueue 的形态与用途(React 19)
HostRoot状态更新链表(render() 的元素入队)
类组件状态更新链表(base + pending)
hook(useState/useReducer)hook 自己的 queue(不是 fiber.updateQueue)
Suspense / Offscreenretry 队列(offscreenQueue.retryQueueReactFiberCompleteWork.js:1964
HostComponentnull,无用途

换句话说,React 19 把 HostComponent 的 updateQueue 从"重载字段"降级成了"空字段"——属性 diff 的载体被彻底移除,"需要更新"的信号由 Update flag 单独承担,old/new props 在 commit 时现取。


5. 那"updateQueue 是环形链表还是 key-value 数组"到底谁对?

两种说法都对,因为 updateQueue多态的——同一个字段在不同 fiber 类型上装的东西完全不同:

fiber 类型updateQueue 装什么结构
HostRoot / hook / 类组件状态更新(Update 节点)链表pending 环形链 / base+pending 单向链)
HostComponent(16–18)属性 diff 结果扁平 key-value 数组 ['title',1,'style',{…}]
HostComponent(19+)空(见上一节)

React 19 这次改动,顺带把 HostComponent 这个"数组形态"清掉了updateQueue 的多态程度下降,基于 19 的教材不会再出现"updateQueue 是 key-value 数组"的说法——而"环形链表"的形象反而更纯粹,因为它只属于状态/副作用队列。


6. 简化后的实现示意

把 v19 的机制抽象成伪代码,就是三步:渲染阶段只打标记,commit 阶段现场 diff

// 渲染阶段 completeWork(更新分支)
function updateHostComponent(current, workInProgress, type, newProps, renderLanes) {
const oldProps = current.memoizedProps;
if (oldProps === newProps) {
return; // 引用相等 → bailout
}
markUpdate(workInProgress); // 只打 Update flag,不算 diff、不存数组
}

// commit 阶段 mutation
commitUpdate(domElement, type, oldProps, newProps)
updateProperties(domElement, type, oldProps, newProps) // 边 diff 边应用

reconciler 与宿主彻底解耦:reconciler 只负责"这个节点需要更新"(打 flag),"怎么更新"完全交给宿主在 commit 时决定。整个 reconcile 层再也看不到 prepareUpdate / updatePayload——它们已经属于历史。


7. 一句话总结

React 19 把 HostComponent 的属性 diff 从**渲染阶段的预计算(扁平数组存进 updateQueue)**挪到了 commit 阶段的现场 diff + 应用,并删掉了 prepareUpdate。收益是省掉临时分配、渲染阶段保持纯净、宿主逻辑统一;代价是失去深度 bailout、commit 变重。updateQueue 在 HostComponent 上从此是 null,环形链表的形象也更纯粹。


参考

  • React PR #26583 ca41adb8c1 — Diff properties in the commit phase instead of generating an update payload
  • React PR #27409 7f6201889e — Ship diffInCommitPhase
  • React 18.2.0 ReactFiberCompleteWork.new.js / ReactDOMComponent.js diffProperties
  • React 19.2.7 ReactFiberCompleteWork.js / ReactFiberConfigDOM.js updateProperties

Zepto源码学习-核心篇

· 阅读需 8 分钟

0.前言

Zepto源码1.2.0未压缩带注释约有1835行,之前是当做设计模式来阅读,并没有深入。且在当前前端环境下,JQuery的重要性大大降低了,从事开发工作大多用的是Angular、Vue等,并没有将jQuery用到精通。以训练为目的,尝试将Zepto源码讲的清楚一点