Dive into React——Diff 算法


考点 4.1:单节点 Diff --- type + key 双重判断

第 0 段:直觉锚定

想象你是一个图书馆管理员,面前有一排旧书架(current 树的子 Fiber 链表),手里拿着一张新的书单(本次 render 返回的新子元素)。

单节点 Diff 就是:新书单上只有一本书 ,你要在旧书架上找到能不能复用它。怎么判断"复用"?两个条件:

  1. 书名一样type 相同)------内容类型得对得上
  2. 书架上贴的标签一样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:「deleteChilddeleteRemainingChildren 立即删除 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 本对旧书架第 1 本,第 2 对第 2......直到对不上或某一方用完
  2. 第二轮(如果新列表还剩):旧的全没了 → 直接新建
  3. 第三轮(如果新列表还剩、旧的也还剩):把旧的全部塞进 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。区分方法看 alternatealternate === 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.keynull。React 的 Diff 代码中,null key 的处理逻辑是:用节点在旧链表中的位置 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 的 keynull,新 Element 的 key 也是 nullnull === 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.keynull,而 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 有两种特殊的子节点类型:

  1. Fragment<></><React.Fragment>)------没有对应 DOM,只是一个"透明容器"
  2. PortalReactDOM.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 的执行过程:

  1. 第一轮哪一步 break?为什么?
  2. 进入第几轮?Map 的内容是什么?
  3. 每个 newChild 的 placeChild 结果如何?lastPlacedIndex 怎么变化?哪些节点打了 Placement
  4. 最终哪些旧 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 行为会变成什么样?从源码角度解释为什么。

相关推荐
拾年2751 小时前
别调 BERT 了:我用 Prompt 做了套 NLP 系统,20 分钟搞定
前端·人工智能
半个落月1 小时前
别再死记变量提升了——从 V8 编译过程真正理解 JS 执行机制
前端
橘子星2 小时前
别再懵圈!JS 执行机制的 “千层套路” 全揭秘
前端·javascript
GuWenyue2 小时前
LeetCode 76 最小覆盖子串|JS 滑动窗口标准解法
前端·算法·面试
YHHLAI2 小时前
前端 HTTP 请求 & LLM 接口开发
前端·网络协议·http
拾年2752 小时前
__proto__ vs prototype:90% 的人分不清的 JavaScript 核心
前端·javascript·面试
国科安芯2 小时前
国科安芯推出商业航天级抗辐照半双工 RS485 收发器 ASC485S2Y
前端·单片机·嵌入式硬件·架构·安全性测试
丑过三八线2 小时前
Umi 运行时配置 app.tsx 详解
前端
提子拌饭1332 小时前
个人月事记录表应用 - 鸿蒙PC Electron框架完整实现指南
前端·javascript·华为·electron·前端框架·开源·鸿蒙系统