很多人一说到前端性能优化,就会提到虚拟 DOM 的 diff 算法。我面试过不少前端开发者,几乎都能说出“同层比较”“key 的作用”“双端比较”这些关键词,但往回追问“为什么跨层节点不移动”“为什么列表一定要给稳定 key”“双端比较到底比普通循环好在哪里”,能讲清楚的人其实不多。这篇文章就把 diff 算法从设计动机到代码执行,再到实际场景里的坑,完整捋一遍。不管你是刚学框架原理的新手,还是已经写过一段时间业务代码、想补基础的老手,这篇都值得花十分钟看完。
1. 虚拟 DOM 究竟解决了什么问题:先搞清楚 diff 在给谁打工
1.1 直接操作 DOM 的“昂贵”体现在哪里
浏览器里的真实 DOM 结构,是一个节点数可能成百上千的复杂树。你对它做的每一次增删改查,都会触发浏览器的布局计算和样式重算。早期用原生 JS 写页面,最直观的感受就是:频繁操作 DOM 之后,页面开始掉帧,滚动变得卡顿,输入框打字都开始有延迟。
这里有一个很容易被忽略的点:真正昂贵的不是“修改 DOM 节点本身”,而是修改之后浏览器做的那一整套计算,包括计算元素位置和大小的布局过程,以及把像素画到屏幕上的绘制过程。更麻烦的是,如果你在循环里读取offsetWidth、getBoundingClientRect()这类属性,浏览器为了给你准确的值,会被迫提前执行一次强制同步布局,性能会进一步恶化。
这也是为什么后来出现了“批量更新”的思路:先把要做的修改攒起来,一次性交给浏览器,尽量减少布局和绘制的触发次数。虚拟 DOM 就是这个思路的核心载体。
1.2 虚拟 DOM 的本质:把渲染问题变成计算问题
虚拟 DOM 说白了就是一棵保存在内存里的普通 JavaScript 对象树。每个节点不是真实元素,而是一个轻量描述:这个节点标签是什么,有哪些属性,子节点长什么样。它不参与浏览器布局,创建和修改的成本比真实 DOM 低好几个数量级。
举个例子,一个简单的虚拟节点长这样:
const vNode = { tag: 'div', props: { id: 'app', className: 'container' }, children: [ { tag: 'p', props: {}, children: 'Hello World' } ] };框架要做的事情,就是维护“当前页面应该长什么样”这棵对象树。当数据变化时,先生成一棵新的对象树,然后拿新树和旧树做一次比对,算出两者之间最小的差异点,最后只把差异部分同步到真实 DOM 上。这个“比对差异”的过程,就是 diff 算法。
所以你可以把 diff 理解成中间层:上面接数据驱动的组件渲染,下面接高效的 DOM 更新。前端框架之所以能用“数据变了我就重绘整个页面”这种朴素思路支撑起复杂应用,靠的就是 diff 算法把“重绘整个页面”的成本降到真正只更新一小部分。
1.3 diff 算法在完整渲染链路里的位置
一次完整的数据变化到页面更新,流程大致是这样的:组件状态发生变化,执行渲染函数生成新的 vnode,然后新旧 vnode 一起进入补丁阶段,也就是执行 diff 逻辑,最终得到一批具体的 DOM 操作指令,再统一执行。
这里有个很重要但经常被忽略的事实:diff 本身不操作真实 DOM,它只负责“算出要改哪里”。真实 DOM 的改动是在 diff 结束后,由另一段代码根据差异结果去执行的。搞清楚这个分工,你就不会再把“diff 算法”和“渲染过程”混为一谈了。diff 只是渲染链路中承担“差异计算”的一环,优化渲染性能时,我们需要分别看待 diff 的效率和真实 DOM 更新的频率。
2. diff 算法的三大核心假设:为什么它能做到 O(n) 复杂度
树形结构的最小编辑距离问题,在计算机领域是一个很复杂的问题,直接暴力求解可以达到 O(n^3) 甚至更高。这意味着如果一棵树有 1000 个节点,最坏情况下要做十亿次量级的比较计算。这套逻辑放在运行在用户浏览器里的前端框架上,根本跑不动。
所以现代框架的 diff 算法没有追求数学上的最优解,而是采取了三个非常务实的假设,把复杂度降到了近似 O(n)。理解这三个假设,等于已经理解了 diff 设计思想的一半。
2.1 假设一:同层比较,放弃跨层移动
这是最核心的一条。diff 算法在对比新旧两棵树时,只会对同一层级的节点做比较:根节点和根节点比,第一层子节点和第一层子节点比,第二层和第二层比,依次往下推动。它不会去尝试计算“把某个深层节点移动到另一棵子树下”的最优路径。
这个假设成立的基础是:在真实业务场景中,开发者几乎不会把一个 DOM 节点从页面某个区域直接移到另一个区域。绝大多数数据变化带来的 UI 变化,都是同一层级内的节点增删、顺序调整、属性更新。跨层级的结构性变化往往伴随着组件的卸载和重建,与其去精细地移动节点,不如直接重建子树来得划算。
因为放弃了跨层比较,diff 算法在每一层只需要做一次线性的遍历和比较,整棵树的复杂度就从 O(n^3) 砍到了接近 O(n)。
2.2 假设二:节点类型不同就是完全不同的东西
diff 比较两个节点时,会先看它们是不是同一类节点。这里说的“同一类”主要指标签名是否一致,比如都是 div、都是 span、都是某个自定义组件类型。如果标签名都不一样,后面的属性比较、子节点比较就完全没有意义了,直接采用最干脆的策略:把这个节点连同一整棵子树从旧 DOM 里移除,按新节点重建一棵新的子树挂上去。
这个思路用一句话解释就是:标签名本身就是最强烈的“结构变化信号”。div 变成 ul,里面再逐项比没有任何价值,因为父容器的语义都变了,子节点的复用概率极低,直接重建反而是最可靠、效率最高的做法。
要注意的是,类型相同不等于 props 相同,更不等于子节点结构相同。类型相同的前提下,才会继续往下走属性 diff 和子节点 diff。
2.3 假设三:key 就是节点在列表中的“身份证”
前面两个假设决定了 diff 的主干逻辑,而 key 解决的则是“列表对比时,怎么知道两个节点是不是同一个东西”的关键问题。
假设有一个渲染出来的列表项,它们的标签、类名完全一样,但内容是三条不同的文字。此时数据变了,列表重新生成 vnode,diff 拿到新旧两组子节点,如果没有 key,它只能按顺序一一对应去比较。这会导致一个尴尬的结果:你在列表头部插入一条数据,旧列表的第 1 项本应该继续保留,但因为按顺序比较,框架会觉得第 1 项的内容突然变成了新数据,它会去更新这个节点的文本内容,然后继续往后错位更新每一项。
这样做的结果是:只有一项变更的操作,变成了整个列表所有节点都经历一次内容更新。更严重的问题是,如果列表项内部有用户输入状态、滚动位置、展开状态这些内部状态,按位置比较会导致状态被错误地复用到另一个根本没有关系的节点上,用户体验直接翻车。
给列表项设置一个稳定且唯一的 key 之后,diff 就能精确识别出“这一项还是原来那一项”,只是位置移动了,那么只需要把真实 DOM 节点移动到目标位置,而不需要销毁重建,更不会错误复用其他项的状态。
3. 一次完整 diff 的执行过程:从根节点走到叶子节点
理论说完了,现在我们把 diff 的执行链路从头到尾走一遍。虽然不同框架的实现细节有差异,但核心逻辑和步骤顺序基本是一致的。
3.1 节点类型不同的分支处理:直接整棵替换
diff 接到的第一个问题永远是:新节点和旧节点到底还能不能复用同一个真实 DOM 元素。判断标准主要是两个:标签名是否相同、key 是否相同。任何一个对不上,就认为这是两个完全不同的东西。
此时 diff 会做这样几件事:如果有旧节点,先把旧节点对应的真实 DOM 从父级中移除;如果新节点存在,则根据新 vnode 创建全新的真实 DOM 树并插入对应位置。这期间会触发组件的卸载钩子和挂载钩子,对自定义组件而言,相当于一次“销毁重建”级别的更新。这种操作成本很高,所以平时写代码时应该尽量避免在不必要的地方改变节点的标签名或 key。
3.2 节点类型相同的属性更新:逐项对比 props
当节点可以复用,接下来就是更新属性。diff 会把新旧 vnode 上的属性拿出来逐项比较,然后只更新发生变化的部分。
这里的属性包括常规的 HTML 属性、DOM 属性、样式对象、事件监听器等多个类别。它们各自有一套更新逻辑:
- 普通属性:旧属性列表里有、新列表里没有的,直接删除;新列表里有、旧列表里没有的,直接设置;两边都有但值不同的,做赋值更新。
- 样式对象:以新样式为准,逐键对比旧样式,删除新样式中不存在的键,更新值不同的键,保留两者都存在的键。这也是为什么内联样式对象写法的性能通常比逐字符串拼接要好,因为框架能精确到某个样式属性的粒度过细。
- 事件监听器:同样以 key 为单位对比,事件名没变就替换回调函数,新事件不存在就移除监听。
这里有个优化细节值得留意:属性 diff 的粒度是“属性名级别”,而不是“整个属性对象级别”。所以把不相关的属性拆成不同的 key,在框架层就能更精准地只做局部更新,而不是让整个 style 对象重新赋值。
3.3 列表子节点对比的核心环节:双端比较算法
子节点对比是 diff 中最复杂的部分,也是很多面试官喜欢深挖的部分。当新旧节点都是数组时,diff 会在这一层做列表算法优化。
最朴素的做法是两层循环,新列表每一项都去旧列表里找同 key 节点,找不到就新建,找到就把节点移过来。这个方案虽然可行,但在实际性能上不够理想,尤其是遇到顺序颠倒、局部移动等常见场景时会产生大量多余操作。因此框架普遍采用了双端比较策略。
双端比较维护四个指针:旧列表的头和尾、新列表的头和尾。整个比较过程围绕这四种匹配情况进行:
| 比较方式 | 匹配条件 | 对应操作 |
|---|---|---|
| 新头与旧头 | 两者同 key 且同标签 | 复用节点,新旧头指针同时向后移动 |
| 新尾与旧尾 | 两者同 key 且同标签 | 复用节点,新旧尾指针同时向前移动 |
| 新尾与旧头 | 两者同 key 且同标签 | 复用节点,把旧头对应的 DOM 节点移动到尾部 |
| 新头与旧尾 | 两者同 key 且同标签 | 复用节点,把旧尾对应的 DOM 节点移动到头部 |
每次匹配成功后,相应指针向内收缩,直到新头索引超过新尾索引,或者旧头索引超过旧尾索引时停止。最后把剩余没有匹配到的旧节点删除,把剩余的新节点新建插入。
为什么双端比较比单纯从头遍历一遍效果好?举一个最经典的例子:整个列表顺序反转。如果只从头部一个方向比较,除了第一个节点能对上,后续所有节点都会被视为“要移动”,等于做了 n 次移动操作。但双端比较可以在第一轮里就发现旧头等同于新尾,第二轮发现新头部对应旧尾,把首尾交替移动,操作次数能大幅减少,接近原地调整的效果。
如果四种匹配方式全部没命中,说明当前节点所在的位置完全没有对应上,此时框架会先建一个以 key 为索引的旧节点映射表,再拿着新节点去表里查。查到了就复用旧节点并移动位置,查不到就创建新节点。这个映射过程把所有查找从线性复杂度降到了常数时间,整个列表 diff 因此能维持接近 O(n) 的效率。
3.4 递归出口和真实 DOM 更新的时机
diff 是一个递归的过程:处理完当前节点,就进入它的 children 数组继续往下处理,直到没有子节点为止。对每个叶子节点,最后会落到文本内容或属性值的直接更新。这个递归天然保证了树里每个节点最多被访问固定次数,不会出现回头反复比较的情况。
还有一点需要明确:diff 阶段计算出来的所有差异,会先被记录在一个补丁集合里,而不是边 diff 边修改真实 DOM。等到整棵树处理完毕,再统一执行真实 DOM 操作。这种“先计算、后提交”的设计,保证了 diff 与渲染解耦,也方便框架进行批量渲染和任务调度,避免一次数据更新触发多次页面重绘。
4. key 的作用远比表面复杂:几个真实场景里的关键抉择
4.1 为什么 index 作为 key 是列表更新时的头号坑
网上关于“不要用 index 作为 key”的讨论已经很多了,但很多人只是记结论,不理解根因。这里我用一个非常简单但足够说明问题的例子来解释。
假设有一个列表渲染了 A、B、C 三个项,给这三项分别标注 key 为 0、1、2,也就是数组下标。现在用户执行了一个“在最前面插入 D”的操作,新列表变成 D、A、B、C,此时四个项的 index 分别变为 0、1、2、3。
diff 拿到新旧列表后,用双端比较进行的第一次匹配,会发现新头部 D 在旧列表里找不到任何对应 key。但问题在于,旧列表里 key 为 0、1、2 的节点都还在。按照 key 对应关系,框架会认为 key=0 的旧节点 A 对应新列表里的 key=0 节点,也就是 D,于是对这个原本是 A 的真实 DOM 节点做了内容替换,把它改成了 D 的样子。
结果就是:你只是在头部插入了一条数据,框架却老老实实把原来的 A 节点改成了 D,B 节点改成了 A,C 节点改成了 B,最后还要新建一个 C 节点。列表的每一项都经历了重建或内容更新。
如果只是文字内容倒还好,最多浪费点性能。可怕的是当列表项是输入框、开关、折叠面板这类带有内部状态的可交互组件时,用户的输入内容、展开状态会被错误地留在原来的节点上,导致状态在不同条目之间“串门”。比如你给 A 项的输入框输入了“你好”,头部插入 D 之后,这个输入框还在原来的 DOM 位置上,但此时它对应的数据条目已经变成了 D,你输入的内容就跑到了 D 下面。用户看到的现象就是我明明没有在 D 里输入内容,D 却带着一串陌生文字。
4.2 什么样的 key 才算是“稳定且唯一”
要避免上面的问题,key 必须满足两个条件:在同一个列表的所有兄弟节点中唯一,并且在数据自身的整个生命周期内保持稳定。所谓稳定,指的是这条数据从创建到删除,它的 key 不会因为位置变化、筛选条件变化、列表操作等原因而改变。
一条数据如果有一个数据库主键 id,直接用这个 id 就是最理想的选择。没有 id 的时候,可以考虑用业务上具备唯一性的字段来做,比如订单号、课程编号、用户编号。如果是前端临时维护的数据,可以在创建的时候用工具函数给每个条目生成一个唯一的标识字段,然后用它做 key。
这里要说一下容易踩的认知误区:key 并不要求全局唯一,只需要在同一个父节点下的兄弟节点之间不重复。不同列表里是可以用相同 key 的,因为它们处于不同的 diff 分支中,彼此不会产生干扰。还有一点,虽然框架对 key 的类型没有强制要求,数字和字符串都是合法的,但保持类型一致会更稳妥,因为比较时如果出现 “1” 和数字 1 混用,有些实现会判定为不相同,从而误判为两个节点。
4.3 事件绑定和组件状态复用也会受 key 影响
key 影响的不只是 DOM 节点复用,还直接影响组件实例的状态承接。同样一个自定义组件,如果框架认为两次渲染是同一个实例,就会保留它被创建以来积累的所有内部状态,比如表单里的未提交内容、内部定时器、动画状态。只要 key 和类型没变,组件会直接走更新流程而不是重新挂载。
这个机制用得好,可以省下很多初始化的工作量。比如一个复杂的表单组件在列表项来回移动时,只要 key 稳定,用户填了一半的内容就能原样保留。反过来,如果你希望某些场景下组件强制重新初始化,也可以在组件的 key 里拼上一个随时间变化的字段,逼着 diff 认为这是新节点,从而触发重建。这个技巧在做“重置表单”“重新拉取数据”这类需求时非常实用。
5. 实战中关于 diff 算法最常见的三个认知误区
5.1 误区一:diff 写得越复杂,性能就越好
很多人会以为双端比较算法比简单循环“高级”,所以性能一定更好。这个判断过于绝对。双端比较的收益是有前提条件的,它最擅长处理的是“顺序调整”和“头部尾部插入删除”这类场景。如果列表是完全随机打乱,双端比较的命中率会下降,最终还是得靠 key 映射表来兜底,本质上的操作量并不会比优化前少太多。
真正值得追求的不是“复杂的比较策略”,而是“让大部分比较都命中第一种或第二种情况”。实际操作中,这意味着让新旧节点在结构上尽量保持相似。比如不要在一个稳定的列表外层频繁切换容器标签;不要在数据没变的情况下每次渲染重新生成一份结构变化很大的 vnode。结构越稳定,diff 要做的有效工作就越少。
5.2 误区二:用了虚拟 DOM 就不需要关心渲染次数
虚拟 DOM 只是降低了“每次更新到底要操作多少真实 DOM”的成本,但并没有降低“生成 vnode”和“执行 diff 逻辑”本身的成本。如果一个大型组件树在数据变更时整棵重新执行渲染函数,哪怕最终 diff 出来的真实 DOM 操作只有一处,前面所有 vnode 的创建、属性的拷贝、树结构的生成,一样都是已经发生的真实计算。
这就是为什么框架还提供了各种跳过渲染的优化手段。核心思路是在数据没有变化时,直接复用上一次的 vnode 结构,跳过重新生成和 diff 的完整流程。业务代码里常见的对应手法包括:把不变的部分拆成独立模块,让它们不会因为父级数据变化而重新渲染;对复杂的静态列表使用内存化封装,避免重复计算;以及避免在渲染函数内部直接创建新的内联对象、内联函数,因为每次渲染都会生成新的引用,会破坏子级优化判断时的“值相同”条件。
5.3 误区三:diff 的耗时可以直接通过肉眼判断
想要真正验证 diff 是否有优化空间,还是要借助工具来量化。比较方便的做法是在开发者工具里的性能录制面板,手动触发一次列表重排,观察录制结果里脚本执行时间、布局计算时间和绘制时间分别花了多少。脚本执行时间主要包含 vnode 生成和 diff 计算,布局时间则反映了真实 DOM 操作带来的影响。
如果一次总量不算大的数据变化,却产生了远超预期的脚本执行时间或布局时间,最值得怀疑的就是两类问题:一类是列表 key 不合理,导致 diff 没有命中可复用节点,产生大量销毁和重建;另一类是组件层级设计不合理,导致一次很小的状态变化触发了一片大范围的重新渲染。对这两类问题分别对症处理,通常比盲目“优化 diff 算法”本身要有效得多。
写在最后的个人经验
我做项目时遇到过最头疼的一次线上问题,就是一个排序功能:用户点一次排序,整个列表所有项全部闪烁、输入框内容错乱。当时排查到最后,原因就是代码里图省事写了数组下标做 key。换成稳定业务 id 之后,问题立刻消失,性能也恢复了。那次之后我对 key 的重视程度提高了非常多,现在写列表组件基本都会先问一句:这些数据有没有天然唯一字段?没有的话我会在数据进入渲染层之前统一补一个标识。
还有一个小技巧可以推荐给大家:如果你怀疑自己的列表存在错误的节点复用,但又看不出来,可以临时把列表项的 key 改成随机生成的值,强制每次全部重建。如果这样做了之后页面的表现反而更稳定,说明原来的 key 设计确实存在问题。当然这只是一种诊断手段,线上不能这么干,但它能帮你快速确认方向。
理解虚拟 DOM 的 diff 算法,不是为了在面试时背出几个术语,而是为了在遇到真实的性能问题和状态混乱问题时,心里有一张清晰的排查地图,知道该往哪个方向思考。