React 虚拟 DOM Diff 算法详解
React 的 Diff 算法基于启发式原则,将一般的树比较问题简化为线性时间。其核心假设包括:只比较同一层级的节点,不跨层级重用节点 ;不同类型的元素视为全新节点,不做局部复用 ;使用 key
提示哪些子节点在不同渲染中保持稳定 blog.csdn.netlegacy.reactjs.org。在实践中,这意味着 React 实现了一个 O(n) 的算法:首先比较根节点,如果类型(或 key)不同,则销毁旧树、重新构建新树legacy.reactjs.org;如果类型相同,则保留该 DOM 实例,仅更新其属性(props/attributes)后递归对子节点执行 difflegacy.reactjs.orglegacy.reactjs.org。与此对应,React 只在同一层级内一一比较子节点列表,否则视为整个子树替换blog.csdn.netdevpress.csdn.net。这一原则有效避免了传统树比较的 O(n^3) 复杂度问题devpress.csdn.net。此外,key
提供了稳定的标识:当列表中存在 key
时,React 会根据 key
先建立映射,再对比匹配的节点;移动操作会复用节点并调整位置 ,避免不必要的删除和创建legacy.reactjs.orgdevpress.csdn.net。
-
同级比较(same-level) :React 只在同一层级的子列表中做比较,不会跨层寻找相似节点blog.csdn.netdevpress.csdn.net。如果一个节点在前后两次更新中层级发生变化,算法不会尝试跨层匹配,而是直接将该子树整体替换,这一点依赖于假设"跨层变化很少见"devpress.csdn.net。
-
类型不同即重建 :若新旧两节点类型(或组件类型)不同,React 视为完全不同的节点,销毁旧树所有子节点再创建新树legacy.reactjs.orgblog.csdn.net。例如,将
<div>
换成<p>
,或换用不同的组件,都会触发全量卸载与重建。 -
键值匹配(key) :
key
是区别同层节点的唯一标识。当子节点带有稳定的key
时,React 会以key
为基准匹配旧节点和新节点,并据此移动或复用 DOM 节点legacy.reactjs.orgdevpress.csdn.net。例如,在列表头部插入元素时,如果使用了key
,React 能识别出其他节点"只是位置变化",仅新增头部节点;若未使用key
,则可能误将所有节点都当作替换和重新渲染,效率较低legacy.reactjs.orglegacy.reactjs.org。
节点对比的具体流程
React 的 Diff 过程自上而下递归进行,对每个节点执行如下步骤:
-
节点匹配判定 :比较新旧节点的类型和
key
。若两个节点是完全相同的对象(指针相同)或(对 Vue/Snabbdom 可选)都标记为静态节点且key
相同,则可直接复用,跳过后续更新jonny-wei.github.iovuejs.org。否则进入下一步。 -
类型不同处理 :若新旧节点类型不同(或
key
不同),React 立即销毁旧节点所在子树并创建新节点legacy.reactjs.orgblog.csdn.net。旧节点的所有 DOM 子树卸载,组件执行componentWillUnmount
;新节点创建完成后,执行componentDidMount
。 -
属性更新 :若节点类型相同,则更新该节点的属性(props/attributes)。对于 DOM 元素,会比较属性差异,逐一更新变化的属性或样式legacy.reactjs.org;对于组件节点,会更新组件实例的 props 并调用相应生命周期(如
componentDidUpdate
),然后获取组件新的 render 输出。 -
子节点递归 :接下来递归处理该节点的所有子节点列表。如果新的子节点列表为空而旧列表非空,则卸载所有旧子节点;反之,若新列表有额外节点,则创建并插入这些节点。默认情况下,React 会同时遍历 旧列表和新列表,按索引依次比较差异legacy.reactjs.org。例如,在列表末尾添加元素时,前面的节点匹配后只需插入末端新节点,开销很小legacy.reactjs.org;但如果在头部插入,React(无
key
时)会认为第一个元素变了,因此导致后续元素全部移动,效率较差legacy.reactjs.org。 -
键控列表优化 :当子节点包含
key
时,React 会使用键进行精确匹配。实现时通常先构建旧子节点的key->index
映射表,再遍历新子节点列表:对于每个新节点,查表找到是否存在对应的旧节点索引。如果找不到(新节点),则创建新 DOM;找到则复用该旧节点并调用递归 diff,然后将旧节点位置可能移到当前索引前legacy.reactjs.orgblog.csdn.net。遍历结束后,多余的旧节点会被删除。通过key
,React 能准确识别节点移动而非简单替换,从而只对位置变化的节点进行 DOM 操作,避免大范围的重建legacy.reactjs.orgdevpress.csdn.net。
示例代码: 下面给出一个简化的 JavaScript 伪代码示例,演示上述 diff 逻辑。注意这仅为示例,真实 React 源码比这复杂:
javascript
function diff(oldNode, newNode) {
if (!oldNode) {
// 新节点,无旧节点:创建新节点并插入
mount(newNode);
} else if (!newNode) {
// 旧节点无对应新节点:删除旧节点
unmount(oldNode);
} else if (oldNode.tag !== newNode.tag || oldNode.key !== newNode.key) {
// 类型或 key 不同:替换节点
replaceNode(oldNode, newNode);
} else {
// 类型相同:复用节点,更新属性
updateProps(oldNode, newNode);
// 递归 diff 子节点列表
diffChildren(oldNode.children || [], newNode.children || []);
}
}
function diffChildren(oldChildren, newChildren) {
// 构建旧子节点的 key 映射
const oldKeyMap = {};
oldChildren.forEach((child, idx) => {
if (child.key != null) oldKeyMap[child.key] = idx;
});
// 遍历新子节点
newChildren.forEach((newChild, newIdx) => {
const key = newChild.key;
const oldIdx = (key != null ? oldKeyMap[key] : null);
if (oldIdx != null) {
// 找到可复用的旧节点,递归 diff
diff(oldChildren[oldIdx], newChild);
// (可选:如果需要移动位置,则将 oldChildren[oldIdx] 从原位移动到 newIdx)
oldChildren[oldIdx] = null; // 标记为已处理
} else {
// 没有对应旧节点:创建新节点并插入
mount(newChild, newIdx);
}
});
// 删除未处理的旧节点
oldChildren.forEach(child => {
if (child) unmount(child);
});
}
上例中,diff
函数检查节点是否存在、类型是否相同,然后分别调用对应操作。diffChildren
函数演示了基于 key
的子节点匹配:首先创建一个旧节点映射,再遍历新节点列表进行对比和复用,最后移除多余旧节点。通过类似逻辑,React 只对必要的节点执行更新或移动操作。
图:Diff 算法更新节点的流程(伪代码示意) 。上图展示了 diff 过程中更新(patch)节点的判断逻辑:首先检查新旧节点是否完全相同或静态不变(若是,则直接复用)jonny-wei.github.io;否则,如果新节点是文本节点且内容不同,则只更新文本;否则递归进入子节点对比并更新其属性或子树jonny-wei.github.iojonny-wei.github.io。这些分支逻辑确保了对于不同情况(静态节点、文本节点、复合节点)采取最小的更新策略。
Vue、Snabbdom 与 React 算法对比
React、Vue(及其底层的 Snabbdom)在 Diff 实现上有相似之处,也有关键差异:
-
编译优化与静态标记 :React 的虚拟 DOM 纯粹运行时实现,每次渲染都会重新生成新树并全量遍历对比vuejs.org,无法跳过静态子树的 diff。相比之下,Vue(尤其是 Vue 3)在编译阶段就可标记静态节点和优化路径:静态子树(
isStatic
或v-once
)会被缓存复用,不在每次更新中重新 diffjonny-wei.github.iovuejs.org;编译器还会生成Patch Flags ,指示某些节点仅需要做特定更新,大幅减少不必要的检查和 DOM 操作vuejs.org。总之,Vue 利用编译时信息实现了"静态节点跳过"和目标渲染(targeted updates),而 React 则始终需要在运行时完全遍历新旧树。 -
双端比较 vs 线性比较 :Vue 2 和 Snabbdom 采用经典的"双端指针"算法对比同层列表:同时维护旧节点数组和新节点数组的头尾指针,先尝试匹配头头、尾尾、头尾、尾头这四种情况,然后再使用
key
映射处理剩余节点jonny-wei.github.ioblog.csdn.net。这种方法可快速识别首尾节点的插入移动场景。React 官方文档则并未特别提及双端指针,而是默认顺序遍历。实际上,React 可以看作使用了简化的列表对比:无key
时按索引直接比较,有key
时用映射重排,但并没有显式做首尾双向匹配的优化。目前 React 的 diff 并未内置最长递增子序列(LIS)算法优化。Vue 3 在此基础上进一步引入了 LIS 优化:对于乱序的带 key 列表,会先计算新节点在旧列表中索引的最长递增子序列,只移动不在该序列内的节点,从而将复杂度从 O(n²) 降低到 O(n log n)blog.csdn.net。React 本身不使用 LIS,也无静态标记,其列表重排依赖简单的键值匹配逻辑。 -
算法收敛性 :总体来看,React、Vue 以及 Snabbdom 的核心思路趋于一致:都是先做元素类型判断、保留同类型节点,依赖
key
保持子节点稳定,避免跨层遍历devpress.csdn.netblog.csdn.net。Snabbdom(Vue 2 的底层)和 React 都遵循 "O(n) 级别启发式"思想devpress.csdn.net。不同之处在于:Vue/Snabbdom 针对节点移动有更复杂的双端比对逻辑,并且 Vue 3 利用编译时信息(静态分析、Patch Flags)进行优化;而 React 则相对简单直接,依赖开发者提供key
来保证最优更新。
综上,React 的 diff 算法通过"同层比较 + 键值匹配"实现高效更新,核心在于最大程度地复用旧树节点并最小化 DOM 操作legacy.reactjs.orglegacy.reactjs.org。Vue 3 在此基础上进一步利用编译器优化(静态标记、补丁标志)及 LIS 等算法,对典型场景做了专项优化jonny-wei.github.iovuejs.orgblog.csdn.net。Snabbdom 作为轻量级库,其双端 diff 算法与 React 思想类似,都优先匹配同层同 key
节点,只是实现细节略有差异blog.csdn.netdevpress.csdn.net。
参考资料: React 官方《Reconciliation》文档legacy.reactjs.orglegacy.reactjs.org以及 Vue/Snabbdom 源码和社区分析jonny-wei.github.ioblog.csdn.netvuejs.orgdevpress.csdn.netblog.csdn.net等。上述内容综合了多方资料,以期完整揭示 React diff 算法的核心原理与细节。