skip to content
月与羽

React 原理

/ 10 min read

useState 后的更新流程?

用户点击 -> setState ↓ Scheduler: 排队,定优先级 ↓ Render 阶段:

  1. 复制/复用 Current 树 -> WIP 树

  2. 执行组件函数,拿新 JSX

  3. Diff: 对比找不同

  4. Flags: 给 WIP 节点打标签 (增/删/改) ↓ Commit 阶段:

  5. 根据 Flags 修改真实 DOM

  6. Swap: current 指针指向 WIP

  7. 执行 useLayoutEffect ↓ 浏览器 Paint (绘制)Passive: 执行 useEffect

Diff 之前的 Fiber 树遍历

  1. 向上标记 (Mark Update): 触发更新(如 setState)时,React 从当前 Fiber 节点向上遍历至根节点,将沿途所有父节点的 childLanes 标记为 dirty,形成一条通往根的“更新链路”。

  2. 向下调和 (Top-Down Reconciliation): WorkLoop 总是从根节点启动 beginWork。在向下遍历时,React 依据 childLanes 进行判断:

    • Bailout(剪枝):若当前节点的 childLanes 为空,说明其子树无更新,直接复用旧 Fiber 节点(clone),跳过整个子树的遍历。
    • Traversal(寻址):若 childLanes 命中,则沿链路向下查找,直至找到 lanes 匹配的目标组件。
  3. 目标处理与级联

    • 目标节点:执行组件函数(Render),生成新 Fiber 并进行 Diff。
    • 子树行为:默认情况下,目标节点的子组件会随之更新(即便 props 未变),除非使用 ==React.memo== 等手段阻断 Diff。

Key 与 Diff 算法


1. Key 与 Diff 的直接关系:复用的依据

在 React 的多节点 Diff(列表对比)中,算法的第一步就是建立映射表

没有 Key 时(默认行为)

React 默认使用 索引 (Index) 作为潜在的 Key。 Diff 算法会简单粗暴地按顺序对比:

  • 旧列表第 1 个 vs 新列表第 1 个
  • 旧列表第 2 个 vs 新列表第 2 个

后果:如果你的列表只是在头部插入了一个新元素:

  • 旧:[B, C]
  • 新:[A, B, C]

React 会这样认为:

  1. 旧的 B 变成了 A -> 更新(修改 DOM 内容)。
  2. 旧的 C 变成了 B -> 更新(修改 DOM 内容)。
  3. 最后新增一个 C -> 插入明明只是 B 和 C 往后挪了一位,React 却把它们都修改了一遍。这是巨大的性能浪费。

有了 Key 之后

React 会先遍历旧列表,生成一个 Map 对象{ 'key_b': FiberB, 'key_c': FiberC }

当遍历新列表 [A(key_a), B(key_b), C(key_c)] 时:

  1. A:查 Map,没找到 key_a -> 创建新节点
  2. B:查 Map,找到了 key_b -> 直接复用 FiberB(只做移动操作)。
  3. C:查 Map,找到了 key_c -> 直接复用 FiberC(只做移动操作)。

结论:Key 让 Diff 算法从“顺序比对”变成了“哈希查找”,极大地减少了 DOM 的销毁和重建。


2. 比性能更严重的问题:状态(State)错乱

这是面试中常考的“坑”。如果你用 Index 作为 Key,可能会导致组件状态张冠李戴。

假设你有一个列表,每一项都有一个 <input> 输入框。

  1. 渲染[{id: 1, text: '老王'}, {id: 2, text: '老李'}]。Key 用 Index (0, 1)。
  2. 操作:你在第一个输入框(老王)里打字,输入了 “Hello”。
    • 此时,Dom 节点(Index 0)保存了内部状态 “Hello”。
  3. 更新:你在列表头部插入了 id: 3 的“小张”。
    • 新数据:[小张, 老王, 老李]
  4. Diff 过程(用 Index 做 Key)
    • React 对比 Index 0:旧的是“老王”,新的是“小张”。React 认为这是同一个组件,只是 props 变了。于是把 props 改成了“小张”。
    • 但是! DOM 节点是被复用的,组件实例也是复用的。
  5. 结果
    • 第一个输入框显示的文字变成了“小张”,但是输入框里保留的内容依然是 “Hello”!
    • 因为 React 认为“这是第 0 个组件,它还在,只是换了名字”,所以它保留了第 0 个组件的所有内部状态(State/DOM Value)。

结论Key 必须是唯一且稳定的(如数据库 ID)。 用 Index 做 Key 只能保证让 React 不报错,但在列表顺序发生变化(插入、删除、排序)时,会导致状态绑定错误。


Diff 算法大致流程

React 的 Diff 算法主要发生在 Render 阶段的 reconcileChildFibers 函数中。

它的核心任务是:根据 新的 JSX (ReactElement)旧的 Fiber (current),生成 新的 Fiber (workInProgress)

React 将 Diff 流程主要分为 三种情况 来处理:


第一种情况:单节点 Diff (Single Node)

场景:新的 JSX 只有一个节点(例如 newChild 是一个对象,而不是数组)。 例如:由 <div>Old</div> 变成了 <p>New</p>

流程逻辑

  1. 遍历旧节点:虽然新的是 1 个,但旧的可能是一排兄弟节点,React 需要去尝试匹配。
  2. 第一步:比对 Key
    • 如果 key 不同:说明不是同一个组件。标记该旧节点删除,继续尝试匹配旧列表里的下一个兄弟。
    • 如果 key 相同:进入第二步。
  3. 第二步:比对 Type
    • 如果 type 也相同:复用成功!
      • 复用旧 Fiber 对象。
      • 将剩下的其他旧兄弟节点全部标记删除(因为新内容只有一个,匹配到了一个,剩下的都是多余的)。
      • 返回新 Fiber。
    • 如果 type 不同:彻底无法复用
      • 将该旧节点及其所有兄弟节点标记删除。
      • 创建一个全新的 Fiber 节点。

第二种情况:文本节点 Diff (Text Node)

场景:新的 JSX 是字符串或数字。

流程逻辑: 这是最简单的情况。

  1. 检查旧节点是否是 HostText(文本节点)。
  2. :复用旧 Fiber,更新内部的 pendingProps(即文本内容),打上 Update 标记。
  3. :标记删除旧节点,创建一个新的文本 Fiber 节点。

第三种情况:多节点 Diff (Multi Node / Array) —— 最核心、最复杂

场景:新的 JSX 是一个数组。 例如:<ul> 下面的多个 <li> 发生了排序、增删。

React 采用 “两轮遍历法” 来处理这种情况。

第一轮遍历:处理“更新”的情况(假设没有移动)

React 猜测大部分情况下,节点只是属性变了,位置没变。

  1. 同时遍历 新 JSX 数组旧 Fiber 链表 (index i = 0 开始)。
  2. 比较 newChildren[i]oldFiber
  3. 判断能否复用(Key 和 Type 都相同):
    • 能复用:复用旧 Fiber,i++,继续下一个。
    • 不能复用(Key 不同):立即跳出第一轮循环。React 意识到顺序可能乱了,不再盲目比对。
    • 不能复用(Key 相同但 Type 不同):创建一个新 Fiber 替换旧的,旧的标记删除,然后继续。

第一轮结束后,会有 4 种结果:

  1. 新旧同时遍历完:完美!说明只是简单的更新属性,Diff 结束。
  2. 新没完,旧完了:说明新增了节点。将剩下的新 JSX 全部创建为新 Fiber 并标记插入(Placement)。
  3. 新完了,旧没完:说明删除了节点。将剩下的旧 Fiber 全部标记删除(Deletion)。
  4. 新旧都没完(最常见):说明中间有节点 Key 不匹配,发生了移动、乱序、插入或删除。 -> 进入第二轮遍历

第二轮遍历:处理“移动”的情况

这时候,我们要处理剩下的新 JSX 和剩下的旧 Fiber。

  1. 建立 Map

    • 把剩下的 旧 Fiber 全部放入一个 Map 中:Map<Key, Fiber>
    • 这样可以把 O(n) 的查找变成 O(1)。
  2. 遍历剩下的新 JSX

    • 拿着新节点的 key 去 Map 里找。
    • 找到(能复用)
      • 从 Map 中取出旧 Fiber 进行复用。
      • 判断移动 (Right Shift 逻辑)
        • 记录一个变量 lastPlacedIndex(上一个复用节点在旧数组中的位置)。
        • 如果 oldIndex < lastPlacedIndex:说明该节点原本在前面,现在被“挤”到后面了,标记为 Placement (移动)
        • 如果 oldIndex >= lastPlacedIndex:位置正常,不需要移动,更新 lastPlacedIndex = oldIndex
      • 从 Map 中移除该项。
    • 没找到
      • 说明是新节点,创建新 Fiber,标记 Placement (插入)
  3. 收尾工作

    • 遍历结束后,检查 Map 里还有没有剩下的旧 Fiber。
    • 如果有,说明这些旧节点在新数组里不存在了,全部标记删除