React 原理
/ 10 min read
useState 后的更新流程?
用户点击 -> setState ↓ Scheduler: 排队,定优先级 ↓ Render 阶段:
复制/复用 Current 树 -> WIP 树
执行组件函数,拿新 JSX
Diff: 对比找不同
Flags: 给 WIP 节点打标签 (增/删/改) ↓ Commit 阶段:
根据 Flags 修改真实 DOM
Swap: current 指针指向 WIP
执行 useLayoutEffect ↓ 浏览器 Paint (绘制) ↓ Passive: 执行 useEffect
Diff 之前的 Fiber 树遍历
-
向上标记 (Mark Update): 触发更新(如 setState)时,React 从当前 Fiber 节点向上遍历至根节点,将沿途所有父节点的 childLanes 标记为 dirty,形成一条通往根的“更新链路”。
-
向下调和 (Top-Down Reconciliation): WorkLoop 总是从根节点启动 beginWork。在向下遍历时,React 依据 childLanes 进行判断:
- Bailout(剪枝):若当前节点的 childLanes 为空,说明其子树无更新,直接复用旧 Fiber 节点(clone),跳过整个子树的遍历。
- Traversal(寻址):若 childLanes 命中,则沿链路向下查找,直至找到 lanes 匹配的目标组件。
-
目标处理与级联:
- 目标节点:执行组件函数(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 会这样认为:
- 旧的 B 变成了 A -> 更新(修改 DOM 内容)。
- 旧的 C 变成了 B -> 更新(修改 DOM 内容)。
- 最后新增一个 C -> 插入。 明明只是 B 和 C 往后挪了一位,React 却把它们都修改了一遍。这是巨大的性能浪费。
有了 Key 之后
React 会先遍历旧列表,生成一个 Map 对象:
{ 'key_b': FiberB, 'key_c': FiberC }
当遍历新列表 [A(key_a), B(key_b), C(key_c)] 时:
- A:查 Map,没找到
key_a-> 创建新节点。 - B:查 Map,找到了
key_b-> 直接复用 FiberB(只做移动操作)。 - C:查 Map,找到了
key_c-> 直接复用 FiberC(只做移动操作)。
结论:Key 让 Diff 算法从“顺序比对”变成了“哈希查找”,极大地减少了 DOM 的销毁和重建。
2. 比性能更严重的问题:状态(State)错乱
这是面试中常考的“坑”。如果你用 Index 作为 Key,可能会导致组件状态张冠李戴。
假设你有一个列表,每一项都有一个 <input> 输入框。
- 渲染:
[{id: 1, text: '老王'}, {id: 2, text: '老李'}]。Key 用 Index (0, 1)。 - 操作:你在第一个输入框(老王)里打字,输入了 “Hello”。
- 此时,Dom 节点(Index 0)保存了内部状态 “Hello”。
- 更新:你在列表头部插入了
id: 3的“小张”。- 新数据:
[小张, 老王, 老李]。
- 新数据:
- Diff 过程(用 Index 做 Key):
- React 对比 Index 0:旧的是“老王”,新的是“小张”。React 认为这是同一个组件,只是 props 变了。于是把 props 改成了“小张”。
- 但是! DOM 节点是被复用的,组件实例也是复用的。
- 结果:
- 第一个输入框显示的文字变成了“小张”,但是输入框里保留的内容依然是 “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 个,但旧的可能是一排兄弟节点,React 需要去尝试匹配。
- 第一步:比对 Key。
- 如果
key不同:说明不是同一个组件。标记该旧节点删除,继续尝试匹配旧列表里的下一个兄弟。 - 如果
key相同:进入第二步。
- 如果
- 第二步:比对 Type。
- 如果
type也相同:复用成功!- 复用旧 Fiber 对象。
- 将剩下的其他旧兄弟节点全部标记删除(因为新内容只有一个,匹配到了一个,剩下的都是多余的)。
- 返回新 Fiber。
- 如果
type不同:彻底无法复用。- 将该旧节点及其所有兄弟节点标记删除。
- 创建一个全新的 Fiber 节点。
- 如果
第二种情况:文本节点 Diff (Text Node)
场景:新的 JSX 是字符串或数字。
流程逻辑: 这是最简单的情况。
- 检查旧节点是否是
HostText(文本节点)。 - 是:复用旧 Fiber,更新内部的
pendingProps(即文本内容),打上 Update 标记。 - 否:标记删除旧节点,创建一个新的文本 Fiber 节点。
第三种情况:多节点 Diff (Multi Node / Array) —— 最核心、最复杂
场景:新的 JSX 是一个数组。
例如:<ul> 下面的多个 <li> 发生了排序、增删。
React 采用 “两轮遍历法” 来处理这种情况。
第一轮遍历:处理“更新”的情况(假设没有移动)
React 猜测大部分情况下,节点只是属性变了,位置没变。
- 同时遍历 新 JSX 数组 和 旧 Fiber 链表 (index
i = 0开始)。 - 比较
newChildren[i]和oldFiber。 - 判断能否复用(Key 和 Type 都相同):
- 能复用:复用旧 Fiber,
i++,继续下一个。 - 不能复用(Key 不同):立即跳出第一轮循环。React 意识到顺序可能乱了,不再盲目比对。
- 不能复用(Key 相同但 Type 不同):创建一个新 Fiber 替换旧的,旧的标记删除,然后继续。
- 能复用:复用旧 Fiber,
第一轮结束后,会有 4 种结果:
- 新旧同时遍历完:完美!说明只是简单的更新属性,Diff 结束。
- 新没完,旧完了:说明新增了节点。将剩下的新 JSX 全部创建为新 Fiber 并标记插入(Placement)。
- 新完了,旧没完:说明删除了节点。将剩下的旧 Fiber 全部标记删除(Deletion)。
- 新旧都没完(最常见):说明中间有节点 Key 不匹配,发生了移动、乱序、插入或删除。 -> 进入第二轮遍历。
第二轮遍历:处理“移动”的情况
这时候,我们要处理剩下的新 JSX 和剩下的旧 Fiber。
-
建立 Map:
- 把剩下的 旧 Fiber 全部放入一个 Map 中:
Map<Key, Fiber>。 - 这样可以把 O(n) 的查找变成 O(1)。
- 把剩下的 旧 Fiber 全部放入一个 Map 中:
-
遍历剩下的新 JSX:
- 拿着新节点的
key去 Map 里找。 - 找到(能复用):
- 从 Map 中取出旧 Fiber 进行复用。
- 判断移动 (Right Shift 逻辑):
- 记录一个变量
lastPlacedIndex(上一个复用节点在旧数组中的位置)。 - 如果
oldIndex < lastPlacedIndex:说明该节点原本在前面,现在被“挤”到后面了,标记为 Placement (移动)。 - 如果
oldIndex >= lastPlacedIndex:位置正常,不需要移动,更新lastPlacedIndex = oldIndex。
- 记录一个变量
- 从 Map 中移除该项。
- 没找到:
- 说明是新节点,创建新 Fiber,标记 Placement (插入)。
- 拿着新节点的
-
收尾工作:
- 遍历结束后,检查 Map 里还有没有剩下的旧 Fiber。
- 如果有,说明这些旧节点在新数组里不存在了,全部标记删除。