在 React 的 DOM Diffing(协调 Reconciliation)算法中,无论是单节点还是多节点(列表),其核心目标都是最小化对真实 DOM 的操作。React 会比较新旧两棵虚拟 DOM 树,找出差异,然后只更新真实 DOM 中必要的部分。
1. 单节点(Single Node)的处理
当 React 比较两个单独的 VNode(虚拟节点)时,它会遵循以下步骤:
-
类型比较 (Type Comparison) :
- 如果新旧 VNode 的类型不同 (例如,旧的是
<div>
,新的是<span>
),React 会认为这是一个完全不同的组件或元素。它会销毁旧的 VNode 及其所有子树 ,并创建新的 VNode 及其所有子树,然后插入到 DOM 中。这是最昂贵的操作。 - 如果新旧 VNode 的类型相同 (例如,都是
<div>
),React 认为它们是同一个组件或元素,可以进行复用。
- 如果新旧 VNode 的类型不同 (例如,旧的是
-
属性比较 (Props Comparison) :
- 在类型相同的情况下,React 会比较新旧 VNode 的属性(props)。
- 它会找出发生变化的属性,并只更新真实 DOM 上对应的属性。例如,如果
className
变了,就只更新className
;如果style
变了,就只更新style
。 - 对于事件监听器,React 会高效地添加、移除或更新事件处理器。
-
子节点递归 (Children Recursion) :
- 在属性比较完成后,React 会递归地对子节点进行 Diff。
- 如果 VNode 没有子节点,或者子节点是文本节点,则 Diff 过程结束。
- 如果 VNode 有子节点,React 会进入多节点(列表)的 Diff 阶段来处理这些子节点。
2. 多节点(列表)的处理:两轮遍历
当 React 处理一个组件的多个子节点(通常是列表)时,情况会变得复杂。因为子节点的数量、顺序和内容都可能发生变化。为了高效地处理这些情况,React 采用了一种**两轮遍历(Two Passes)**的启发式算法,并严重依赖 key
属性。
核心问题: 如何在 O(n) 的复杂度下,高效地识别出列表中的增、删、改、移操作?
key
属性的作用:
key
是 React 用来识别列表中每个元素的唯一标识。它帮助 React 跟踪每个元素在多次渲染中是否是同一个实体。
- 没有
key
或key
不唯一:React 无法准确识别元素,可能会导致性能问题(不必要的 DOM 重建)和状态错乱(组件内部状态保留在错误的元素上)。 - 有了
key
:React 可以根据key
来判断元素是否被移动、删除或新增,从而进行更精准的 DOM 操作。
第一轮遍历:从头开始比较 (Head to Head)
这一轮遍历从新旧子节点列表的头部 开始,向后进行比较。它主要处理更新 、类型变化导致的替换 以及头部元素的增删。
-
指针初始化:
oldStartIndex
指向旧列表的第一个元素。newStartIndex
指向新列表的第一个元素。
-
比较过程:
-
同时比较
oldChildren[oldStartIndex]
和newChildren[newStartIndex]
。 -
如果它们的
key
和type
都相同:- React 认为它们是同一个元素。
- 对这两个 VNode 进行单节点 Diff(比较属性和递归子节点)。
oldStartIndex
和newStartIndex
同时向后移动一位。
-
如果它们的
key
或type
不同:- 这一轮比较停止。因为从当前位置开始,头部匹配已经失效,可能发生了元素的插入、删除或移动。
-
处理情况:
- 元素更新 :如果
key
和type
相同,会进行属性和子节点的更新。 - 头部新增/删除:如果新列表在头部有新增元素,或旧列表在头部有删除元素,第一轮遍历会很快停止,剩下的由第二轮或第三阶段处理。
- 元素类型变化 :如果
type
不同,会直接替换。
第二轮遍历:从尾部开始比较 (Tail to Tail)
这一轮遍历从新旧子节点列表的尾部 开始,向前进行比较。它主要处理更新 、类型变化导致的替换 以及尾部元素的增删。
-
指针初始化:
oldEndIndex
指向旧列表的最后一个元素。newEndIndex
指向新列表的最后一个元素。
-
比较过程:
-
同时比较
oldChildren[oldEndIndex]
和newChildren[newEndIndex]
。 -
如果它们的
key
和type
都相同:- React 认为它们是同一个元素。
- 对这两个 VNode 进行单节点 Diff。
oldEndIndex
和newEndIndex
同时向前移动一位。
-
如果它们的
key
或type
不同:- 这一轮比较停止。
-
处理情况:
-
元素更新:同第一轮。
-
尾部新增/删除:同第一轮。
-
元素移动 (优化) :
- 从头部移动到尾部:如果元素从列表头部移动到了尾部,第一轮遍历会处理头部,第二轮遍历会处理尾部,直到两个指针相遇,高效完成更新。
- 从尾部移动到头部:类似地,如果元素从尾部移动到头部,第二轮遍历会高效处理。
两轮遍历结束后的情况 (复杂情况处理)
在两轮遍历结束后,如果 oldStartIndex
仍然小于等于 oldEndIndex
,或者 newStartIndex
仍然小于等于 newEndIndex
,说明旧列表或新列表中还有未处理的元素。这通常意味着发生了复杂的元素移动、新增或删除。
此时,React 会进入第三个阶段:
-
旧列表剩余元素映射 (Map Old Children by Key) :
- React 会将旧列表中从
oldStartIndex
到oldEndIndex
之间的所有剩余元素,以它们的key
为键,存储在一个Map
中。
- React 会将旧列表中从
-
新列表剩余元素遍历 (Iterate New Children) :
-
React 遍历新列表中从
newStartIndex
到newEndIndex
之间的所有剩余元素。 -
对于新列表中的每一个元素
newChild
:-
如果
newChild
在Map
中找到了相同的key
:- 说明这是一个移动 或更新的元素。
- React 会将该元素从旧列表的
Map
中移除,并对newChild
和Map
中找到的旧 VNode 进行单节点 Diff。 - 如果
newChild
的位置与旧 VNode 在 DOM 中的位置不同,React 会进行 DOM 移动操作。
-
如果
newChild
在Map
中没有找到相同的key
:- 说明这是一个新增的元素。
- React 会创建新的 DOM 节点并插入到正确的位置。
-
-
-
旧列表剩余元素删除 (Delete Remaining Old Children) :
- 在遍历完新列表后,
Map
中如果还有剩余的旧元素,说明这些元素在新列表中已经不存在了。 - React 会将这些剩余的旧元素对应的真实 DOM 节点进行删除。
- 在遍历完新列表后,
总结
-
单节点处理 :主要比较 VNode 的
type
和props
,然后递归处理子节点。 -
多节点处理(两轮遍历) :
- 第一轮(从头到头) :处理列表头部元素的更新、替换、增删。
- 第二轮(从尾到尾) :处理列表尾部元素的更新、替换、增删,并优化了头部和尾部元素的移动。
- 剩余阶段 :如果两轮遍历后仍有未处理元素,则使用
key
属性和Map
来识别复杂的移动、新增和删除操作。
这种两轮遍历的策略,结合 key
属性,使得 React 的 Diffing 算法在大多数常见场景(如列表头部/尾部增删、简单移动)下能够达到接近 O(n) 的效率,从而保证了高性能的 DOM 更新。