考点 4.1:单节点 Diff --- type + key 双重判断
第 0 段:直觉锚定
想象你是一个图书馆管理员,面前有一排旧书架(current 树的子 Fiber 链表),手里拿着一张新的书单(本次 render 返回的新子元素)。
单节点 Diff 就是:新书单上只有一本书 ,你要在旧书架上找到能不能复用它。怎么判断"复用"?两个条件:
- 书名一样 (
type相同)------内容类型得对得上 - 书架上贴的标签一样 (
key相同)------位置标识得对得上
两个都满足 → 复用旧书(useFiber 复制节点),换上新封面(pendingProps);任一不满足 → 旧书扔掉(标记 ChildDeletion),按新书单造一本新的(createFiberFromElement)。
第 1 段:问题背景
React 的 render 阶段在做 beginWork 时,需要为每个 Fiber 节点生成新的子 Fiber 。这个过程叫 reconciliation(协调) 。
关键问题:当旧的 current 树上已经有子 Fiber,而本次 render 返回了新的子元素时,React 是全部销毁重建 ,还是尽量复用旧节点?
答案是尽量复用,因为:
- 创建新 Fiber 有内存开销
- 复用可以保留 DOM 节点,避免不必要的 DOM 操作
- 复用能保持内部状态(Hook 链表、DOM 节点等)
⚠️ 常见先入为主的误解: 很多人以为 Diff 是拿"新旧两棵树"做比较。实际上 Diff 的视角是单个父节点 :给定一个父 Fiber 的
currentFirstChild(旧子链表头)和newChild(新子元素),判断如何复用。React 不是自顶向下整棵树对比,而是每个 Fiber 节点各负责自己的子节点协调。
第 2 段:核心数据结构
单节点 Diff 涉及的数据流:
php
输入:
returnFiber --- 父 Fiber(正在 beginWork 的节点)
currentFirstChild --- current 树上该父节点的第一个子 Fiber(旧子链表头)
element --- 本次 render 返回的 ReactElement(新子元素)
lanes --- 当前渲染优先级
输出:
一个新的 wip Fiber --- 复用或新建的子 Fiber,已设置 return 指针
旧子链表结构(单节点 Diff 中被遍历):
currentFirstChild → child → child.sibling → child.sibling → null
↓
遍历每个旧子节点,用 key + type 判断能否匹配
关键判断字段:
php
ReactElement(新) Fiber(旧)
┌─────────────┐ ┌─────────────────┐
│ key │ ══比较══ │ key │
│ type │ ══比较══ │ elementType │
│ props │ →复用时传│ pendingProps │
│ $$typeof │ │ tag │
└─────────────┘ │ alternate │ ← null=新建, 非null=复用
│ return │
│ flags │ ← Placement / ChildDeletion
└─────────────────┘
第 3 段:运行流程
定位: react@18.3.1 · packages/react-reconciler/src/ReactChildFiber.js · reconcileSingleElement(第 1698 行)
整体调用链:
scss
beginWork
└→ reconcileChildren(current, workInProgress, nextChildren, renderLanes)
└→ reconcileChildFibers(returnFiber, currentFirstChild, newChild, lanes)
└→ reconcileChildFibersImpl(...)
└→ reconcileSingleElement(returnFiber, currentFirstChild, element, lanes) ← 单节点入口
└→ placeSingleChild(result) ← 标记 Placement
reconcileSingleElement 的完整流程伪代码(按源码第 1698-1801 行裁剪):
ini
function reconcileSingleElement(returnFiber, currentFirstChild, element, lanes) {
const key = element.key;
let child = currentFirstChild;
// ---- 第一阶段:遍历旧子链表,找能复用的节点 ----
while (child !== null) {
// 判断1: key 是否匹配
if (child.key === key) {
// 判断2: type 是否匹配(分 Fragment 和普通元素两条路径)
if (element.type === REACT_FRAGMENT_TYPE) {
if (child.tag === Fragment) {
// ✅ key 匹配 + type 匹配(Fragment) → 复用
deleteRemainingChildren(returnFiber, child.sibling); // 删除后续兄弟
const existing = useFiber(child, element.props.children); // 复制 Fiber
existing.return = returnFiber;
return existing;
}
// ❌ key 匹配但 type 不匹配 → 删除所有旧子节点,跳出循环
deleteRemainingChildren(returnFiber, child);
break;
} else {
// 普通元素:比较 elementType
if (child.elementType === elementType
|| (Lazy 类型 && resolveLazy(elementType) === child.type)) {
// ✅ key 匹配 + type 匹配 → 复用
deleteRemainingChildren(returnFiber, child.sibling);
const existing = useFiber(child, element.props);
existing.return = returnFiber;
return existing;
}
// ❌ key 匹配但 type 不匹配 → 删除所有旧子节点,跳出
deleteRemainingChildren(returnFiber, child);
break;
}
} else {
// ❌ key 不匹配 → 删除当前这一个旧子节点,继续看下一个兄弟
deleteChild(returnFiber, child);
}
child = child.sibling; // 移动到下一个旧子节点
}
// ---- 第二阶段:没找到可复用的节点,创建新 Fiber ----
if (element.type === REACT_FRAGMENT_TYPE) {
const created = createFiberFromFragment(element.props.children, ...);
created.return = returnFiber;
return created;
} else {
const created = createFiberFromElement(element, ...);
created.return = returnFiber;
return created;
}
}
阅读入口: 打开 ReactChildFiber.js 第 1698-1801 行,核心关注 while 循环中的两个 if 判断(第 1709 行 child.key === key 和第 1735 行 child.elementType === elementType)。
第 4 段:设计动机与权衡
为什么用 key + type 双重判断,而不是只用 type?
考虑这个场景:
css
// 旧渲染
<div>
<span key="a">Hello</span>
<span key="b">World</span>
</div>
// 新渲染(只保留 key="b" 的 span)
<div>
<span key="b">React</span>
</div>
如果只看 type(都是 span),React 会复用第一个 <span key="a"> 来渲染 "React"。但语义上,我们想要的是保留 key="b" 的节点,删除 key="a" 的节点。
key 的作用就是给同一层级的同 type 元素加一个身份标识,让 React 能区分"同一类型但不同逻辑身份"的节点。
为什么 key 不匹配时只删除当前节点继续找,而 type 不匹配时直接删除全部?
因为旧子链表是按 sibling 连接的有序序列。key 是身份标识------如果 key 对不上,说明"这个位置不是它",继续往后找。但如果 key 对上了而 type 对不上,说明"就是这个位置,但类型变了",那后面的兄弟也不可能匹配(单节点 Diff 只需要找到一个),所以直接全部删除。
这个设计的代价:
单节点 Diff 的 while 循环最坏情况要遍历整个旧子链表(所有 key 都不匹配),时间复杂度 O(n)。但对于单子节点的场景,通常旧链表也很短,实际开销很小。
第 5 段:次级误解和边界
误解 1:「key 匹配就一定会复用」
错。key 只是第一道门槛,通过后还要检查 type。<div key="a"> 变成 <span key="a">,key 匹配但 type 不匹配,结果仍然是:删除旧的 div Fiber,新建 span Fiber。
误解 2:「单节点 Diff 时旧子链表只有一个节点」
不一定。旧渲染可能是多子节点(数组),新渲染变成单子节点。比如 condition ? <Single /> : [<A />, <B />],从多切到单时,currentFirstChild 指向的旧链表可能有多个节点。这时 while 循环会逐个遍历,找到能复用的就用,剩余的全部标记删除。
误解 3:「deleteChild 和 deleteRemainingChildren 立即删除 DOM」
错。这两个函数只是在父 Fiber 的 deletions 数组中记录待删除的子 Fiber,并给父 Fiber 打上 ChildDeletion flag。真正的 DOM 删除发生在 commit 阶段的 Mutation 子阶段。
认知交接:
现在我们知道了单节点 Diff 的核心逻辑:遍历旧子链表,用 key + type 双重判断找到可复用节点,找到就 useFiber 复用,找不到就新建。但这是"只有一个新子元素"的情况。当新子元素是一个数组(多个子节点)时,Diff 的策略完全不同------React 需要在两组节点之间做最优匹配。这就是 4.2「多节点 Diff:三轮遍历」要处理的事情。
考点 4.2:多节点 Diff --- 三轮遍历
第 0 段:直觉锚定
回到图书馆的例子。这次你不是拿着一本书来找,而是拿着一张新书单(新子元素数组),要和旧书架上的一排书(旧子 Fiber 链表)做匹配。
React 的策略不是什么高级的最优匹配算法,而是三轮简单的遍历:
- 第一轮:从头一对一比对------新列表第 1 本对旧书架第 1 本,第 2 对第 2......直到对不上或某一方用完
- 第二轮(如果新列表还剩):旧的全没了 → 直接新建
- 第三轮(如果新列表还剩、旧的也还剩):把旧的全部塞进 Map,新列表逐个去 Map 里找
这就像你整理书架:先按顺序从左到右对一遍(第一轮),新书单上还剩的书直接买新的(第二轮),如果还有剩的但旧书架也还有没配上的,就把旧书全摆桌上按标签找(第三轮)。
第 1 段:问题背景
单节点 Diff 只需要处理"一个新子元素 vs 旧子链表"的情况。但实际开发中,列表渲染才是高频场景:
xml
<ul>
{items.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
当 items 发生增删、重排时,React 需要在旧 Fiber 链表 和新 Element 数组之间找到最优复用方案。
核心约束: Fiber 链表是单向的(只有 sibling 指针,没有 prev 指针),所以无法像经典的双端 Diff 那样从两头同时比较。React 选择了只从左到右的遍历策略。
⚠️ 常见先入为主的误解: 很多人以为 React 的多节点 Diff 是 O(n³) 的经典树 Diff 算法。实际上 React 的 Diff 是 O(n) 的------它做了一系列限制(同层级比较、key 标识身份、只从左到右)来把复杂度压到线性。
第 2 段:核心数据结构
php
输入:
returnFiber --- 父 Fiber
currentFirstChild --- 旧子 Fiber 链表头
newChildren --- 新 Element 数组(已知的,可直接索引)
旧链表(单向,只有 sibling):
oldFiber → oldFiber.sibling → oldFiber.sibling → null
新数组(可索引):
newChildren[0], newChildren[1], newChildren[2], ...
关键状态变量:
oldFiber --- 当前遍历到的旧 Fiber(第一轮用)
newIdx --- 当前遍历到的新数组下标
lastPlacedIndex --- 最后一个复用节点在旧链表中的原始位置(用于判断是否需要移动)
resultingFirstChild --- 产出的 wip 子链表头
previousNewFiber --- 产出的 wip 子链表尾(用于接 sibling)
lastPlacedIndex 是理解"移动"的关键------后面第 3 段会详细讲。
第 3 段:运行流程
定位: react@18.3.1 · packages/react-reconciler/src/ReactChildFiber.js · reconcileChildrenArray(第 1172 行)
scss
reconcileChildrenArray 的调用链:
beginWork
└→ reconcileChildren
└→ reconcileChildFibers
└→ reconcileChildFibersImpl (检测到 newChild 是数组)
└→ reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes)
第一轮遍历:一对一从左到右(第 1206-1259 行)
ini
let oldFiber = currentFirstChild;
let newIdx = 0;
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
// 按 index 对齐:如果旧节点的 index > 当前新下标,说明旧节点已经"超前"了
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber; // 暂存,后面还要回来处理
oldFiber = null; // 设为 null 表示"跳过对齐"
} else {
nextOldFiber = oldFiber.sibling;
}
// 核心:updateSlot 按 key 匹配
const newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx], lanes);
if (newFiber === null) {
// key 不匹配 → 第一轮提前终止
if (oldFiber === null) oldFiber = nextOldFiber;
break;
}
// key 匹配但没复用旧 Fiber → 需要删旧
if (shouldTrackSideEffects && oldFiber && newFiber.alternate === null) {
deleteChild(returnFiber, oldFiber);
}
// 判断是否需要移动(placeChild 内部比较 lastPlacedIndex)
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// 链接 sibling
if (previousNewFiber === null) {
resultingFirstChild = newFiber; // 第一个子节点
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
oldFiber = nextOldFiber;
}
updateSlot 做了什么(第 839 行): 取旧 Fiber 的 key,和新 Element 的 key 比较。匹配 → 调 updateElement 检查 type,type 也匹配则 useFiber 复用;key 不匹配 → 返回 null,第一轮终止。
第一轮结束后的三个分支:
ini
第一轮结束后
│
├─ 情况A: newIdx === newChildren.length
│ (新数组遍历完了)
│ → 旧链表剩余全部删除
│ → 返回 resultingFirstChild
│
├─ 情况B: oldFiber === null
│ (旧链表用完了,新数组还有剩)
│ → 第二轮:纯新建
│ → for 循环创建剩余新 Fiber
│
└─ 情况C: 两者都有剩
(新旧都还没用完,说明中间发生了 key 不匹配)
→ 第三轮:Map 匹配
第二轮遍历:旧链表耗尽,纯新建(第 1271-1301 行)
ini
if (oldFiber === null) {
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
// ... 链接 sibling、placeChild
}
return resultingFirstChild;
}
逻辑简单:旧的全用完了,剩下的新元素全部 createChild 新建。
第三轮遍历:Map 匹配(第 1303-1357 行)
这是最复杂的一轮:
scss
// 1. 把旧链表剩余节点全部放入 Map
const existingChildren = mapRemainingChildren(oldFiber);
// Map 结构:key(有key用key,无key用index) → Fiber
// 2. 遍历剩余新元素,从 Map 中查找
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = updateFromMap(existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes);
if (newFiber !== null) {
// 如果复用了旧 Fiber,从 Map 中删除(避免重复匹配)
if (shouldTrackSideEffects && newFiber.alternate !== null) {
existingChildren.delete(currentFiber.key === null ? newIdx : currentFiber.key);
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// ... 链接 sibling
}
}
// 3. Map 中剩余的旧节点 → 全部标记删除
existingChildren.forEach(child => deleteChild(returnFiber, child));
updateFromMap(第 981 行): 从 Map 中用 key(或 index)查找旧 Fiber。找到后调 updateElement 做 type 比较,决定复用还是新建。
placeChild 与"移动"判断
kotlin
function placeChild(newFiber, lastPlacedIndex, newIndex) {
newFiber.index = newIndex;
if (!shouldTrackSideEffects) {
return lastPlacedIndex;
}
const current = newFiber.alternate;
if (current !== null) {
const oldIndex = current.index; // 这个节点在旧链表中的位置
if (oldIndex < lastPlacedIndex) {
// 旧位置 < 最后放置位置 → 需要移动
newFiber.flags |= Placement;
return lastPlacedIndex;
} else {
// 旧位置 >= 最后放置位置 → 不需要移动
return oldIndex; // 更新 lastPlacedIndex
}
} else {
// 新建的节点 → 需要插入
newFiber.flags |= Placement;
return lastPlacedIndex;
}
}
核心逻辑: lastPlacedIndex 记录"最后一个不需要移动的节点在旧链表中的位置"。如果当前复用节点的旧位置 < lastPlacedIndex,说明它在旧列表中出现在更前面,但在新列表中出现在更后面------需要移动。
第 4 段:设计动机与权衡
为什么不用双端 Diff?
Vue 的 Diff 用双端(头头、尾尾、头尾、尾头),可以更好地处理首尾移动。但 Fiber 链表是单向的 (只有 sibling,没有 prev),无法从尾部往前遍历。源码注释也明确说了:
"This algorithm can't optimize by searching from both ends since we don't have backpointers on fibers."
为什么不直接全用 Map?
第一轮一对一比对是快路径------大多数实际场景中,列表的头部大部分是按顺序对齐的,只有尾部有增删。第一轮就能处理大部分匹配,避免建 Map 的开销。
为什么第三轮才建 Map?
建 Map 有 O(n) 的空间和时间开销。只有在第一轮匹配断裂(key 不匹配导致提前终止)且双方都还有剩余时,才需要 Map 来做随机查找。
这个设计的代价:
对于纯倒序 这种场景(如 [A, B, C] → [C, B, A]),React 的从左到右策略会判定每个节点都需要移动(lastPlacedIndex 机制),而双端 Diff 只需要交换首尾。但 React 团队认为这种场景在实际业务中很少见,不值得为此增加复杂度。
第 5 段:次级误解和边界
误解 1:「三轮遍历就是三遍循环」
不完全是。第一轮是条件循环 (可能提前 break),第二轮和第三轮是互斥的------只会走其中一个。所以最坏情况是"第一轮 + 第三轮"= 两遍完整遍历。
误解 2:「key 不匹配时旧节点立即被删除」
错。第一轮中如果 updateSlot 返回 null(key 不匹配),循环直接 break ,旧节点并没有被删除。旧节点的删除发生在第三轮结束时------Map 中剩余的节点才被批量标记 ChildDeletion。
误解 3:「Placement 标记表示新建」
不全是。Placement 表示"这个节点需要被插入/移动到 DOM 中"。新建的节点会打 Placement,复用但位置变了 的旧节点也会打 Placement。区分方法看 alternate:alternate === null → 新建;alternate !== null → 复用但移动。
认知交接:
现在我们知道了多节点 Diff 的三轮策略:第一轮按顺序一对一匹配,第二轮处理旧节点耗尽的纯新建场景,第三轮用 Map 做随机查找。其中"是否需要移动 DOM"的判断依赖于 lastPlacedIndex。但你可能一直在想一个问题------key 到底是怎么影响这些判断的?为什么有人说"用 index 做 key 等于没有 key"?这就是 4.3「key 的作用与为何不能用 index」要回答的问题。
考点 4.3:key 的作用与为何不能用 index
第 0 段:直觉锚定
想象一个公司员工表,每行有姓名和工号。你要对比新旧两张表看谁走了、谁来了、谁换了位置:
- 有工号(key): 新表里工号 105 对应的人,一定是旧表里工号 105 那个人------不管他在第几行
- 没有工号(用行号当 key): 新表第 3 行 = 旧表第 3 行。但可能新表第 3 行换人了,你却以为还是原来那个
key 就是"工号" ------跨渲染周期唯一标识"这个子元素是谁"的身份 ID。行号(index)不是身份,只是位置。
第 1 段:问题背景
前面 4.1 和 4.2 讲了 Diff 的核心机制:通过 key + type 判断是否复用。但有一个关键问题没展开:当用户没有显式提供 key 时,React 怎么办?
答案是 React 会把 key 设为 null,然后在 Diff 中退化为按位置(index)匹配。这个退化机制在三个地方都有体现:
⚠️ 常见先入为主的误解: 很多人以为"不写 key 等于 key=undefined"。实际上不写 key 时
element.key是null。React 的 Diff 代码中,nullkey 的处理逻辑是:用节点在旧链表中的位置 index 作为匹配依据。
第 2 段:源码中 key=null 的三条退化路径
退化 1:第一轮 updateSlot(第 839-876 行)
javascript
function updateSlot(returnFiber, oldFiber, newChild, lanes) {
const key = oldFiber !== null ? oldFiber.key : null;
// ...
case REACT_ELEMENT_TYPE:
if (newChild.key === key) { // null === null → true!
return updateElement(returnFiber, oldFiber, newChild, lanes);
} else {
return null;
}
}
没有 key 时: 旧 Fiber 的 key 是 null,新 Element 的 key 也是 null。null === null 为 true → 永远匹配成功。
结果:第一轮的一对一匹配永远不断裂,旧 Fiber 和新 Element 按位置顺序一一配对。
退化 2:第三轮 mapRemainingChildren(第 467-496 行)
ini
function mapRemainingChildren(currentFirstChild) {
const existingChildren = new Map();
let existingChild = currentFirstChild;
while (existingChild !== null) {
if (existingChild.key === null) {
// ⚠️ key 为 null 时,用 index 作为 Map 的键!
existingChildren.set(existingChild.index, existingChild);
} else {
existingChildren.set(existingChild.key, existingChild);
}
existingChild = existingChild.sibling;
}
return existingChildren;
}
没有 key 时: Map 的键不是唯一身份标识,而是位置 index。
退化 3:第三轮 updateFromMap(第 1008-1015 行)
vbnet
case REACT_ELEMENT_TYPE: {
const matchedFiber =
existingChildren.get(
newChild.key === null ? newIdx : newChild.key, // ⚠️ null key 用 newIdx 查找
);
}
没有 key 时: 用新数组的下标 newIdx 去 Map 里找------而 Map 里存的键恰好也是旧节点的 index。所以本质上是"新数组第 i 个匹配旧链表第 i 个"。
第 3 段:运行流程 --- index 做 key 如何出错
用一个经典场景演示:
less
// 旧渲染
<ul>
<li>Apple</li> {/* 无 key,index=0,对应 Fiber A */}
<li>Banana</li> {/* 无 key,index=1,对应 Fiber B */}
</ul>
// 在头部插入一个元素后
<ul>
<li>Peach</li> {/* 无 key,index=0 */}
<li>Apple</li> {/* 无 key,index=1 */}
<li>Banana</li> {/* 无 key,index=2 */}
</ul>
如果用 index 做 key(或不写 key),Diff 过程:
第一轮同步一对一:
| newIdx | 旧 Fiber | 新 Element | key 匹配? | type 匹配? | 结果 |
|---|---|---|---|---|---|
| 0 | A(Li, "Apple") | Peach | null===null ✅ | Li===Li ✅ | 复用 A,props 变为 "Peach" |
| 1 | B(Li, "Banana") | Apple | null===null ✅ | Li===Li ✅ | 复用 B,props 变为 "Apple" |
第一轮结束后:newIdx=2,旧 Fiber=null → 第二轮纯新建
| newIdx | 新 Element | 结果 |
|---|---|---|
| 2 | Banana | 新建 Fiber C |
实际 DOM 操作:
- 更新第 1 个 li 的文本:"Apple" → "Peach"
- 更新第 2 个 li 的文本:"Banana" → "Apple"
- 新建第 3 个 li:"Banana"
- 3 次 DOM 操作
对比:如果用了唯一 key(如 id)
| newIdx | 旧 Fiber | 新 Element | key 匹配 | 结果 |
|---|---|---|---|---|
| 0 | A(key="apple") | Peach(key="peach") | ❌ | break |
第三轮 Map 匹配:
| newIdx | 新 Element | Map 查找 | 结果 |
|---|---|---|---|
| 0 | Peach(key="peach") | 未找到 | 新建 |
| 1 | Apple(key="apple") | 找到 A | 复用,不动 |
| 2 | Banana(key="banana") | 找到 B | 复用,不动 |
实际 DOM 操作:
- 在头部插入 1 个新 li:"Peach"
- 1 次 DOM 操作
关键区别: 用 key 时 React 知道 "Apple 还是 Apple、Banana 还是 Banana",只插入新元素。用 index 时 React 以为"第 0 个位置还是第 0 个位置",把每个位置的文本都改了一遍。
更严重的后果:状态错乱
javascript
function Item({ children }) {
const [count, setCount] = useState(0);
return <li onClick={() => setCount(c => c + 1)}>{children} count={count}</li>;
}
// 旧:[<Item>Apple</Item>, <Item>Banana</Item>]
// 用户点击了 Apple 的 li,Apple 的 count = 1
// 头部插入 Peach 后(无 key):
// React 复用旧 index=0 的 Fiber 给 Peach → Peach 继承了 count=1!
// React 复用旧 index=1 的 Fiber 给 Apple → Apple 的 count 重置为 0!
这就是"状态跟着 Fiber 走,Fiber 跟着 key/index 走"的后果。 用 index 做 key,Fiber 的复用逻辑就和"位置"绑定而不是和"语义身份"绑定,导致内部状态(Hooks 链表、DOM 局部状态如 input value)跟着位置迁移,而非跟着逻辑实体迁移。
第 4 段:设计动机与权衡
为什么 React 不直接报错要求必须写 key?
向后兼容。React 早期版本没有 key 的概念,强制要求会 breaking change。所以 React 选择退化运行------没有 key 就按 index 匹配,同时在开发模式下对缺少 key 的列表渲染输出警告。
key 为什么设计在 Element 上而不是 Fiber 上?
因为 key 是开发者声明的身份标识("我认为这个元素是谁"),而不是运行时自动生成的。Element 是开发者通过 JSX 描述的,key 写在 JSX 上;Fiber 是 React 内部创建的,不应该自己发明身份。
key 不需要全局唯一,只需兄弟间唯一。 Diff 的比较范围是同一个父节点下的子节点,key 只在这个范围内有意义。不同父节点下的子节点可以有相同 key,互不影响。
第 5 段:次级误解和边界
误解 1:「key 变了就会销毁重建,所有状态丢失」
对,但这不一定是坏事。当你故意 想重置某个组件的状态时,改变它的 key 是标准做法。比如 <Component key={userId} />,userId 变化时 React 会销毁旧 Fiber、创建新的,所有内部状态(表单输入、Hook 值)都会重置。
误解 2:「key={index} 和不写 key 效果一样」
在大多数情况下是的,但有一个细微差异:不写 key 时 element.key 是 null,而 key={index} 时 element.key 是数字。在 updateSlot 中两者的匹配逻辑一样(都是位置匹配),但 React DevTools 中可以看到 key 值不同,便于调试。另外,如果列表中有混合使用(部分写了 key 部分没写),可能导致 null key 和 string key 错位匹配。
误解 3:「只要列表不变就不用写 key」
即使列表顺序不变,用唯一 key 也更安全。因为"不变"是一个业务假设,一旦将来加了排序、过滤、插入等功能,没有 key 就会出 bug。最佳实践是:动态列表始终用唯一 key。
认知交接:
现在我们知道了 key 的本质是跨渲染的身份标识,没有 key 时 Diff 退化为按位置匹配,导致错误的 Fiber 复用和状态错乱。但有一个问题:前面讲的 Diff 都是针对普通元素和组件的,如果子元素是 Fragment(<>...</>)或 Portal,Diff 还能正常工作吗?这就是 4.4 要回答的问题。
考点 4.4:能否对 Fragment / Portal 做 Diff
第 0 段:直觉锚定
把 React 的子节点 Diff 想象成快递分拣:
- 普通包裹(Element)→ 扫描条码(key)+ 检查品类(type),直接判断复用
- 透明袋(Fragment)→ 袋子本身不重要,里面装的才重要。分拣时不看袋子,直接拆开处理里面的东西
- 跨仓库转运箱(Portal)→ 需要核对目的仓库地址(containerInfo),地址对不上就不能复用
三种"货物"进入分拣系统时,走的是同一个入口,但内部判断条件各不相同。
第 1 段:问题背景
前三个考点讲的 Diff 都以普通 ReactElement 为主。但 React 有两种特殊的子节点类型:
- Fragment (
<></>或<React.Fragment>)------没有对应 DOM,只是一个"透明容器" - Portal (
ReactDOM.createPortal)------渲染到 DOM 树的其他位置
核心问题:这两个家伙进入 Diff 时,React 怎么判断复用?它们和普通 Element 的 Diff 有什么区别?
⚠️ 常见先入为主的误解: 很多人以为 Fragment 不产生 Fiber 节点,所以不参与 Diff。实际上无 key 的顶层 Fragment 确实会被"拆开"跳过,但有 key 的 Fragment 或嵌套 Fragment 会产生
tag=Fragment的 Fiber 节点,完整参与 Diff。
第 2 段:三种类型的 Diff 判断条件对比
ini
普通 Element 的复用条件(updateElement):
key 匹配 + elementType 匹配 → useFiber 复用
Fragment 的复用条件(updateFragment):
key 匹配 + current.tag === Fragment → useFiber 复用
(不需要比较 type,因为 Fragment 没有 type 变化的问题)
Portal 的复用条件(updatePortal):
key 匹配
+ current.tag === HostPortal
+ containerInfo 相同
+ implementation 相同
→ useFiber 复用
(比普通 Element 多了两项检查:目标容器和实现方式)
源码对比:
kotlin
// updateElement --- 普通元素(第 578 行)
if (current !== null) {
if (current.elementType === elementType || ...) {
return useFiber(current, element.props); // type 匹配就复用
}
}
// updateFragment --- Fragment(第 670 行)
if (current === null || current.tag !== Fragment) {
return createFiberFromFragment(fragment, ...); // 新建
} else {
return useFiber(current, fragment); // tag 是 Fragment 就复用
}
// updatePortal --- Portal(第 636 行)
if (current === null ||
current.tag !== HostPortal ||
current.stateNode.containerInfo !== portal.containerInfo ||
current.stateNode.implementation !== portal.implementation) {
return createFiberFromPortal(portal, ...); // 新建
} else {
return useFiber(current, portal.children); // 全部匹配才复用
}
第 3 段:运行流程
Fragment 和 Portal 的 Diff 走和普通 Element 相同的入口 (updateSlot / updateFromMap),只是内部 switch 到不同分支:
第一轮中(updateSlot,第 839 行起)
ini
updateSlot(returnFiber, oldFiber, newChild, lanes)
│
├─ newChild 是 Element → updateElement(4.1/4.2 讲过的标准路径)
│
├─ newChild 是 Portal → updatePortal
│ └─ key 匹配 + tag=HostPortal + containerInfo 匹配 → 复用
│
├─ newChild 是 Fragment (REACT_ELEMENT_TYPE + type=FRAGMENT)
│ └─ updateElement → updateFragment
│ └─ key 匹配 + tag=Fragment → 复用
│
└─ newChild 是数组(嵌套数组)
└─ key 必须为 null,否则返回 null(第一轮断裂)
→ updateFragment(oldFiber, newArray, ...)
第三轮中(updateFromMap,第 981 行起)
逻辑完全相同,只是从 Map 中查找旧 Fiber 时用的是 newChild.key === null ? newIdx : newChild.key,找到后再根据类型分发到 updateElement / updatePortal / updateFragment。
顶层无 key Fragment 的特殊路径
reconcileChildFibersImpl(第 1867-1877 行)在最顶部有一个拦截:
ini
const isUnkeyedUnrefedTopLevelFragment =
newChild.type === REACT_FRAGMENT_TYPE &&
newChild.key === null &&
newChild.props.ref === undefined;
if (isUnkeyedUnrefedTopLevelFragment) {
newChild = newChild.props.children; // 直接拆开!跳过 Fragment 本身
}
效果: 当父组件 return <><A /><B /></> 这种无 key 顶层 Fragment 时,React 不会创建 Fragment Fiber ,而是直接把 [A, B] 当作父组件的子元素数组去走多节点 Diff。这也就是为什么 JSX 中 <></> 不会产生额外的 DOM 节点------连 Fiber 节点都没有。
第 4 段:设计动机与权衡
为什么无 key 顶层 Fragment 要被"拆开"?
性能优化。大部分 Fragment 只是语法糖(<>...</> 等价于直接返回多个子元素),如果每次都创建一个 Fragment Fiber 再遍历其子节点,就多了一层无意义的间接层。直接拆开后,子元素直接成为父 Fiber 的子节点,Diff 路径更短。
为什么有 key 的 Fragment 不拆?
因为有 key 意味着开发者明确需要"这个 Fragment 作为一个整体有身份"。比如:
javascript
{items.map(item => (
<React.Fragment key={item.id}>
<dt>{item.term}</dt>
<dd>{item.description}</dd>
</React.Fragment>
))}
这里的 Fragment 需要 key 来参与 Diff 的身份匹配。如果拆开了,<dt> 和 <dd> 就直接作为父节点的子节点,失去了"属于同一个 item"的分组关系。
Portal 为什么多检查 containerInfo?
Portal 的核心语义是"渲染到另一个 DOM 容器"。如果 containerInfo 变了(比如从 document.body 换成了 #modal-root),那整个 Portal 的 DOM 挂载点都变了,必须销毁重建。这不是简单的 props 变化,而是"物理位置"的迁移。
第 5 段:次级误解和边界
误解 1:「Fragment 不产生任何 Fiber」
只有在"无 key + 无 ref + 顶层"这三个条件同时满足 时才不产生 Fiber。有 key 的 Fragment、嵌套在数组里的 Fragment、或者使用了 enableFragmentRefs 的有 ref 的 Fragment,都会产生 tag=Fragment 的 Fiber 节点。
误解 2:「Portal 的 Diff 只看 key」
错。Portal 的复用条件比普通 Element 更严格:key + tag + containerInfo + implementation 四重判断。containerInfo 改变是最常见的 Portal 重建原因。
误解 3:「Fragment 里的子元素走独立的 Diff」
Fragment Fiber 的子元素(fragment 数组)在 beginWork 时会被 reconcileChildren 正常处理------就是走标准的多节点 Diff(reconcileChildrenArray)。Fragment 只是一个"透明壳",Diff 逻辑和普通父组件完全一样。
认知交接:
主题块 4「Diff 算法」的全部 4 个考点已讲解完毕。我们覆盖了:
- 4.1 单节点 Diff 的 key + type 双重判断
- 4.2 多节点 Diff 的三轮遍历与 lastPlacedIndex 移动判断
- 4.3 key 的身份标识作用与 index 做 key 的危害
- 4.4 Fragment / Portal 在 Diff 中的特殊处理
接下来尝试回答下列问题:
题目考核
题目 1:
给定以下场景:
vbnet
旧 Fiber 子链表:Li(key="a") → Li(key="b") → Li(key="c")
新 Element 数组:[Li(key="c"), Li(key="a"), Li(key="d")]
请逐步追踪 reconcileChildrenArray 的执行过程:
- 第一轮哪一步 break?为什么?
- 进入第几轮?Map 的内容是什么?
- 每个 newChild 的 placeChild 结果如何?
lastPlacedIndex怎么变化?哪些节点打了Placement? - 最终哪些旧 Fiber 被标记删除?
题目 2:
如果 React 的 Fiber 链表改成双向链表 (增加 prev 指针),多节点 Diff 的第一轮遍历逻辑可以怎样优化?结合源码中 reconcileChildrenArray 的注释,说明 React 为什么没有选择这个方案。
题目 3:
javascript
function List({ items }) {
return (
<ul>
{items.map(item => (
<React.Fragment key={item.id}>
<li>{item.name}</li>
<li>{item.description}</li>
</React.Fragment>
))}
</ul>
);
}
如果去掉 Fragment 上的 key(变成 <></>),这个列表的 Diff 行为会变成什么样?从源码角度解释为什么。