React 协调算法:当 Key 相同但 Type 改变时,React 源码是选择复用 Fiber 还是销毁重建?为什么?

大家好,欢迎来到今天的“React 内部世界”深度解剖课。

我是你们的讲师,一个在 React 源码里摸爬滚打多年,头发比你的 useState 状态还少的资深专家。

今天,我们要聊一个极其重要,但经常被大家忽略的“生死攸关”的问题。这个问题关乎 React 协调算法的核心逻辑,关乎性能,关乎内存,甚至关乎你是否能在面试中把面试官聊到怀疑人生。

问题来了:当 Key 相同但 Type 改变时,React 源码是选择复用 Fiber 还是销毁重建?

先给你们一个结论,然后我再慢慢给你们扒开它的肠肚。

结论是:销毁重建。

没错,哪怕你拿着相同的身份证(Key),React 也会把那个旧节点像过期的罐头一样扔掉,然后重新捏一个新鲜的面团(Fiber)。它绝不会复用。

为什么?难道它不懂“惜物”吗?难道它不知道复用对象能省电吗?

今天,我们就来把这层窗户纸捅破,看看 React 到底在想什么。


第一部分:Fiber 是什么?为什么它这么“记仇”?

在讲具体逻辑之前,我们得先统一一下语言。很多同学对 React 的理解还停留在“虚拟 DOM”这个层面,觉得 React 像个魔法师,把 JS 对象变成了 HTML。

错!大错特错!

在 React 16 引入 Fiber 架构之后,那个东西叫 Fiber。它是 React 内部的一个核心数据结构,它不仅仅是虚拟 DOM,它是 React 的工作单元

你可以把 Fiber 节点想象成 React 的“身体零件”。每个 Fiber 节点都有自己独立的属性:

  • type: 它是什么类型的节点?是 div?是 class 组件?还是 function 组件?
  • key: 它的身份证号。
  • stateNode: 它对应的真实 DOM 节点(如果是 HostComponent)或者组件实例(如果是 ClassComponent)。
  • memoizedProps: 它当前持有的 props。
  • memoizedState: 它的内部状态(比如 useState 的值)。
  • alternate: 这是重点! 它的“上一帧”版本。

React 的协调算法,本质上就是在这两个树之间做比对。左边是“旧树”,右边是“新树”。

我们的目标是:用最少的力气,把旧树变成新树。


第二部分:Key 相同,Type 改变 —— 代码实战

为了讲清楚,咱们不看枯燥的伪代码,直接上“源码味儿”的真代码。

假设我们有一个列表渲染的场景:

旧 Fiber:

// 假设这是渲染完后的 Fiber 树结构
{
  type: 'div',       // 注意这里是 div
  key: 'item-1',
  stateNode: <div>旧内容</div>,
  memoizedProps: { id: 1 },
  // ... 其他属性
}

新 JSX:

// 假设这是你更新的代码
<div key="item-1">
  <span>新内容</span>  // 注意这里变成了 span
</div>

当 React 开始协调的时候,它会拿着这个 key="item-1" 的 Fiber 节点去新树里找。

  1. 找到匹配项: React 发现新树里也有一个 key="item-1" 的节点。
  2. 比对 Type: React 拿着这个节点的 type(也就是 'span')去对比旧 Fiber 的 type(也就是 'div')。
  3. 判定: span !== div

在源码里,这段逻辑大概长这样(简化版):

// 伪代码:ReactFiberReconciler.js 中的核心逻辑
function reconcileChildren(current, workInProgress, nextChildren) {
  // 1. 遍历 nextChildren (新节点)
  for (let i = 0; i < nextChildren.length; i++) {
    const child = nextChildren[i];

    // 2. 尝试复用:检查 key 是否匹配
    if (child.key === currentChild.key) {

      // 3. 关键点来了:检查 Type 是否匹配
      if (child.type === currentChild.type) {
        // 如果类型一样(比如 div 变成 div,或者函数组件没变)
        // 那恭喜你,进入“复用”模式,更新 props,复用 stateNode
        updateSlot(workInProgress, child, child.type);
        currentChild = currentChild.sibling; // 指针后移
      } else {
        // 4. 如果 Key 相同,但 Type 不同(div 变 span)
        // 源码逻辑:直接销毁旧节点,创建新节点
        // 这就是我们要讲的!
        deleteRemainingChildren(current, currentChild.sibling);
        return placeSingleChild(
          createFiberFromTypeAndProps(child.type, child.key, child.props)
        );
      }
    } else {
      // 如果 Key 都不匹配,那就更别提了,全销毁重建
      deleteRemainingChildren(current, currentChild);
      return createFiberFromChildren(nextChildren);
    }
  }
}

看到了吗?代码逻辑非常干脆:Key 相同只是拿到了入场券,Type 匹配才是复用的硬指标。 Type 不匹配,门都没有,直接走人。


第三部分:为什么不能复用?—— 那些你不想面对的“坑”

这时候肯定有同学要问了:“老师,既然 Fiber 节点已经存在了,属性也更新了,你直接把 stateNode 换一下不就行了吗?为什么要销毁重建?这多浪费性能啊!”

这是一个非常好的问题。这触及到了 React 设计哲学的痛点。

1. 结构完全不同,没法“整容”

想象一下,你以前住的是“平房”(div),现在你想住“摩天大楼”(span,虽然 span 也是行内元素,但逻辑上我们假设它是完全不同的结构)。

你不能把平房里的沙发(stateNode)直接搬到摩天大楼里去,因为结构对不上。你需要先把平房拆了(销毁旧 Fiber),然后去盖新楼(创建新 Fiber)。

2. Ref 的噩梦

这是最现实的问题。假设你的 div 里面有一个 input,你给这个 input 加了一个 ref,用来获取焦点:

function MyComponent() {
  const inputRef = useRef(null);

  useEffect(() => {
    // 依赖旧的结构
    inputRef.current.focus();
  }, []);

  return (
    // 旧结构
    <div key="a">
      <input ref={inputRef} />
    </div>
  );
}

现在,你把 <div> 改成了 <span>

// 新结构
function MyComponent() {
  // ... ref 没变
  return (
    <span key="a">
      <input ref={inputRef} /> {/* input 还在,但它在 span 里了 */}
    </span>
  );
}

如果你直接复用旧 Fiber:

  1. 旧 Fiber 的 stateNode 是一个 div DOM 节点。
  2. 你强行复用它,React 会认为这个节点还是 div
  3. 你去调用 inputRef.current.focus()inputRef.current 指向的还是那个 div 节点!
  4. 结果:你试图在一个 div 上调用 focus()。浏览器会一脸懵逼,或者抛出错误。或者更糟糕的是,如果 span 里有另一个 input,焦点根本对不上。

所以,为了保证 Ref 的准确性,React 必须销毁旧的 Fiber,重建一个新的 Fiber。

3. Hooks 的状态迁移

React 的 useStateuseReducer 等状态是存在 Fiber 节点的 memoizedState 属性里的。

function Counter() {
  const [count, setCount] = useState(0);
  return <div onClick={() => setCount(c => c + 1)}>{count}</div>;
}

如果 Counter 变成了 function Counter() { return <span ... /> }

  • 复用旧 Fiber: 旧的 Fiber 的 memoizedState{ memoizedState: 0, queue: ... }。新的 Fiber 是个 span。你怎么把一个 div 的状态挂载到 span 上?这太荒谬了。
  • 销毁重建: 旧的 Fiber 调用 unmountComponentAtNode,把状态清空,把 useEffect 的清理函数跑一遍。然后创建一个新的 Fiber,初始化一个新的 memoizedState

这就像你换了工作,你不能把上家公司的工牌和工位(DOM)直接带到新公司去,你需要重新入职,重新领工牌。


第四部分:源码深挖 —— 销毁与重建的流水线

既然决定了要销毁重建,React 内部是怎么执行的呢?这可不是一句话的事,这是一场精细的手术。

阶段一:销毁旧节点

当 React 发现 Type 不匹配时,它会调用 unmountFiber

// ReactFiberWorkLoop.js
function unmountFiber(current, nearestMountedAncestor) {
  // 1. 垃圾回收:如果这是 DOM 节点,把它从真实 DOM 树里删掉
  // 这一步至关重要,否则页面上就会残留一堆无用的 div
  if (current.alternate !== null) {
    // 如果是双缓冲模式,这个逻辑更复杂,涉及到替换父节点的子节点指针
    // 这里简化理解:清理真实 DOM
    if (current.stateNode) {
      // 比如 unmountComponentAtNode 的逻辑,会调用
      // current.stateNode._reactRootContainer = null;
      // current.stateNode = null;
    }
  }

  // 2. 清理副作用
  // 这是最重要的一步!
  // React 会跑一遍当前 Fiber 节点下的所有 Effect List
  // 比如你在 useEffect 里写了 cleanup 函数,现在要执行了!
  const updateQueue = current.updateQueue;
  if (updateQueue !== null) {
    // 触发 setState 的清理
    const lastEffect = updateQueue.last;
    if (lastEffect !== null) {
      // ... 执行 cleanup 逻辑
    }
  }

  // 3. 清理 Ref
  // 如果你有 useRef,React 会把 ref.current 设置为 null
  // 防止内存泄漏
  if (current.ref !== null) {
    current.ref.current = null;
  }

  // 4. 清理 Hooks 状态
  // 把 memoizedState 置为 null
  current.memoizedState = null;
}

你看,销毁不仅仅是 removeChild 那么简单,它还要负责“善后”。如果你写了一个 useEffect(() => { return () => console.log('我要走了') }, []),当 Type 改变时,这个 console.log 会立刻执行。

这就是为什么有时候你改了组件类型,会看到控制台疯狂输出日志,吓得你以为是报错了。

阶段二:重建新节点

销毁完了,接下来就是重建。React 会调用 createFiberFromTypeAndProps

function createFiberFromTypeAndProps(type, key, pendingProps) {
  let fiber;
  let fiberTag;

  // 1. 判断类型
  if (typeof type === 'function') {
    // 函数组件
    fiberTag = FunctionComponent;
  } else if (typeof type === 'string') {
    // DOM 组件
    fiberTag = HostComponent;
  } else {
    // ... 其他类型
  }

  // 2. 创建 Fiber 实例
  fiber = createFiber(fiberTag, pendingProps, key);

  // 3. 关键:初始化 Hooks
  // 注意!这里会重置所有状态!
  if (fiberTag === FunctionComponent) {
    fiber.memoizedState = type.defaultProps || null;
    fiber.updateQueue = null;
  }

  return fiber;
}

重建的过程,就是把内存里的对象重新分配一遍。新的 Fiber 拥有新的 memoizedState(Hooks 状态),新的 stateNode(DOM 节点),新的 ref


第五部分:Key 的“误导性”

既然 Key 相同 Type 不同会销毁重建,那 Key 有什么用呢?

Key 的作用是辅助协调。它帮助 React 在新旧树之间找到对应关系。

  • 如果 Key 不同: React 会认为这是两个完全不同的东西。它会直接把旧的销毁,把新的创建。这是最暴力的方式。
  • 如果 Key 相同但 Type 不同: React 会先尝试复用(Type 匹配),发现复用不了,退而求其次,先尝试把 Key 匹配上的节点做复用尝试(虽然失败了),如果 Key 不匹配,那就全盘销毁。

举个极端的例子:

// 旧列表
<div key="1" type="div">Item 1</div>
<div key="2" type="div">Item 2</div>

// 新列表
<div key="1" type="span">Item 1</div>  // Key 1 复用,Type 变 span -> 销毁重建
<div key="2" type="div">Item 2</div>   // Key 2 复用,Type 不变 -> 复用

React 会先处理 Key=”1″ 的节点,发现 Type 不匹配,把旧的 div 销毁,创建新的 span。然后处理 Key=”2″ 的节点,发现 Type 匹配,直接复用旧 Fiber,只更新 props。

所以,Key 决定了“谁留下来”,但 Type 决定了“能不能留下来”。


第六部分:性能考量 —— React 为什么这么做?

你可能会说:“老师,我懂了,销毁重建很惨。那 React 能不能优化一下?比如用 Diff 算法先算出最小操作集合?”

这是一个非常深刻的技术问题。

React 的 Diff 算法核心原则是:只比较同层级的节点,不同类型节点直接销毁重建。

这其实是 React 的一种权衡。

为什么不能更聪明一点?

假设 React 尝试去“智能”地迁移状态。比如:

  1. div -> span
  2. 保留 divmemoizedProps(属性)。
  3. 保留 divstateNode(真实 DOM)。
  4. divstateNodetagName 改成 span

这听起来很美好,对吧?但是,DOM 节点的结构差异太大了。div 是块级元素,span 是行内元素。它们的 styleclassName 甚至 children 的处理方式都完全不同。

如果你强行修改 DOM 节点的类型,浏览器会报错,或者行为变得极其诡异。这比直接销毁重建要危险得多。

而且,React 的目标不是“最小化 DOM 操作”,而是“可预测的渲染结果”和“可维护的代码逻辑”。

如果 React 在这里玩“偷梁换柱”,那么开发者就会非常困惑:
“我明明改了组件类型,为什么我的 Ref 还在?为什么我的状态还在?”
这种隐藏的 Bug 是极其难调试的。

所以,销毁重建虽然看起来暴力,但它保证了逻辑的清晰和绝对的安全。


第七部分:React 18 的并发模式有变化吗?

很多同学问:“老师,现在 React 18 都并发模式了,有了自动批处理,有了 Suspense,这个规则变了吗?”

答案是:没有变。

Fiber 架构的核心就是这套逻辑。并发模式只是改变了“调度的节奏”(什么时候开始渲染,什么时候中断渲染),但并没有改变“协调的策略”(怎么比对,怎么更新)。

即使在并发模式下,当 Type 改变时,React 依然会:

  1. 挂起当前渲染(如果可能)。
  2. 执行旧 Fiber 的 unmount 清理。
  3. 创建新 Fiber。
  4. 继续渲染。

唯一的区别是,在并发模式下,React 可能会尝试“部分更新”,或者如果用户在渲染过程中输入了数据,React 会重新计算。但无论怎么算,只要 Type 变了,销毁重建的铁律就在。


第八部分:实战中的“坑”与“技巧”

既然知道了这个原理,我们在写代码时就要注意了。

坑 1:不要在列表渲染中乱用 Key

如果你为了省事,总是用 index 作为 Key:

{list.map((item, index) => (
  <div key={index}>{item.name}</div>
))}

当你改了列表顺序,或者删了中间的元素,React 会发现 key=index 匹配上了,但 Type 没变。它虽然会复用 Fiber,但是 memoizedProps(比如 item 对象本身)会跟着变。

这会导致组件内部的状态(比如输入框里的文字)错乱。

技巧: 最好用唯一的 ID 作为 Key。这样即使你把 <div> 改成 <span>,React 也能正确识别出这是同一个“项目”,虽然它必须销毁重建,但至少不会搞混其他项目。

坑 2:避免频繁的 Type 切换

如果你在 useEffect 或者某个事件处理函数里,把一个组件的渲染逻辑从 class 改成了 functional,或者从 div 改成了 section,你会触发大量的销毁重建。

这会导致:

  1. useEffect 的 cleanup 被频繁调用。
  2. 网络请求被取消再重发。
  3. 页面闪烁(因为 DOM 节点被移除又插入)。

技巧: 如果你的组件结构非常稳定,尽量保持 Type 不变。如果必须变,尽量在顶层做条件渲染,而不是在循环里做。


第九部分:总结 —— React 的“硬核”哲学

好了,同学们,今天的解剖课就接近尾声了。

我们回顾一下今天学到的核心知识点:

  1. Key 相同 Type 改变 = 销毁重建。
  2. 原因: 结构差异(DOM 节点类型不同),Ref 不安全,Hooks 状态无法迁移。
  3. 机制: React 会调用 unmountFiber 执行清理,然后调用 createFiber 创建新节点。
  4. 哲学: React 宁愿牺牲一部分性能(频繁销毁重建),也要保证逻辑的清晰、状态的安全和 Ref 的准确。它不玩“偷梁换柱”的把戏。

所以,下次当你看到 React 因为一个 <div> 变成了 <span> 而把你的组件“杀掉”并重新生出来时,不要生气。那是 React 在保护你的程序,它在说:“嘿,这玩意儿变了,咱们得从头开始,别搞混了!”

这就是 React 协调算法的硬核逻辑。希望这篇文章能让你对 React 的内部世界有一个更深刻的理解。

下课!

(注:本文源码解析基于 React 17/18 的 Fiber 架构,部分细节可能随版本微调,但核心逻辑未变。)

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注