资讯动态

Vue 3 diff算法核心解析:虚拟DOM、key与最长递增子序列优化指南

发布时间:2026/10/17 8:32:58 来源:尧图企业网站定制
刚接触 Vue 3 源码的人十有八九是被 diff 算法劝退的。一方面网上分析文章堆成山另一方面大部分都在逐行走源码看完记不住记住了也说不清面试被问到“Vue 3 的 diff 到底优化了什么”支支吾吾半天最后只能挤出一句“用了最长递增子序列”。如果现在的你正处于这个阶段这篇文章就是给你准备的。我会完全抛开源码逐行注释的讲法用最朴素的生活类比和实际案例把 diff 算法的核心逻辑拆开揉碎讲清楚。你不需要提前读过 Vue 源码甚至不需要对虚拟 DOM 有多深的理解只要写过几行 Vue 组件有一点 JavaScript 基础就能跟得上。读完你应该能回答这几个问题diff 到底 diff 了什么、Vue 3 比 Vue 2 强在哪、key 为什么不能乱写、最长递增子序列在里面扮演什么角色。下次面试官再问你有底气用自己的话讲明白。1. 先把问题搞清楚diff 究竟在解决什么事在聊 diff 算法之前得先说清楚它存在的意义。不然你只知道它“快”却不知道它为什么需要快理解就会一直飘着。1.1 操作 DOM 的成本到底有多高浏览器里最耗时的操作之一就是修改 DOM。每当你改掉页面上一个元素的内容、位置、样式浏览器都要重新计算样式、布局甚至触发重绘。如果在一个列表循环里每次数据变化都把整个列表重新渲染一遍性能会急剧下降。举个例子你有一个渲染 1000 条数据的列表。数据里某个字段变了理论上只需要更新对应那一条文本节点。但如果没有任何优化手段框架可能选择把整个ul里的内容全部重建那 1000 条数据对应的 1000 个真实 DOM 节点就要全部销毁、重新创建然后浏览器还要重新走一遍布局。这个代价非常高昂。虚拟 DOM 的出现就是为了在“数据变化”和“真实 DOM 操作”之间加一层缓冲。这层缓冲里保存的是轻量级的 JavaScript 对象也就是虚拟节点真实 DOM 上有什么结构虚拟节点就用对象结构描述一遍改起来成本极低。1.2 虚拟 DOM 只是第一步diff 才是关键光有虚拟 DOM 还不够。数据变了框架会生成一份新的虚拟 DOM 树然后你需要知道这棵新树和旧树之间到底差在哪里。这个过程和结果就是 diff 算法要负责的事。diff 算法的核心诉求是尽可能少地操作真实 DOM。能复用的节点就复用不能复用再考虑新建或删除。打个比方你有一面贴满便利贴的白板现在要根据一份新清单调整内容。如果每调整一次清空重贴效率太低了聪明人肯定是比对着新旧两份清单哪张还在就留着哪张是新内容就补上去哪张没用了就撕掉。diff 算法干的就是这件事。所以你可以把虚拟 DOM 理解为框架用来描述页面结构的“草稿”diff 算法则是拿着草稿去比对、找差异的“校对员”最后只有差异部分会落到真实 DOM 上。1.3 复杂度是怎么降下来的理论上两棵树之间做最小更新是比较复杂的问题。如果简单粗暴地递归对比两个树的每个节点然后再计算最小编辑距离性能会非常差。所以 Vue 和 React 都不约而同地做了同构假设——只在同一层级的节点之间做对比不跨层级移动。这个决策背后是工程对“理论最优”的妥协。跨层级的移动在真实业务中占比极低绝大多数更新是同一层列表里几个节点的增删、顺序调整。与其花高昂的代价追求理论最优解不如用可接受的复杂度把 99% 的场景做到极致。Vue 3 的 diff 算法本质上就是在同一层级的子节点比对里把各种情况都优化到接近极限。2. Vue 3 的 diff 核心patch 过程和子节点比对Vue 3 的 diff 不是一个大而全的算法而是分场景的组合拳。你不需要记住所有细节但必须理解它在不同情况下的应对策略。2.1 从 patch 入口开始理解在 Vue 3 里虚拟 DOM 的更新是从一个叫做 patch 的过程开始的。这个词字面意思是“打补丁”很形象就是拿着新虚拟节点去修补旧虚拟节点对应的真实 DOM。patch 函数接收几个参数其中最核心的是旧虚拟节点n1和新虚拟节点n2。如果n1不存在说明是全新挂载直接创建真实 DOM如果n1存在说明是更新这就需要对比新旧节点的差异。patch 的第一步是判断两个节点的类型是否一致。这个判断极其关键。如果节点类型不同比如从div变成了span从组件 A 变成了组件 B那没什么好比较的旧节点对应的真实 DOM 直接卸载重新创建新节点对应的 DOM。打个比方你原来墙上挂的是油画现在要换成照片那必须把旧画框取下来再装上新相框不存在“在油画基础上改成照片”的操作。2.2 同类型节点内部怎么比对如果两个节点类型相同接下来就要分门别类处理。普通元素节点要比对属性、事件、样式然后继续递归比对它的子节点。文本节点更简单直接对比文本内容是否变了变了就更新真实 DOM 的textContent。这里有一个容易忽略的细节对于一个普通元素节点Vue 3 在更新属性之前会先处理事件。事件在虚拟 DOM 里并不是简单地把回调函数绑上就行而是要对比新旧事件回调的差异旧事件不存在的新增新事件里没有的旧事件要解绑。所以 Vue 3 对事件做了缓存处理同一元素的事件处理器是复用的回调函数变了直接更新缓存的引用而不是重复绑定解绑。2.3 子节点比对空、文本、数组三种情况真正让 diff 算法发挥价值的地方是子节点的比对。patchChildren 的逻辑看起来复杂拆开就三条分支第一新子节点是文本旧子节点如果是数组说明结构大变直接清空旧子节点对应的真实 DOM然后设置文本。第二新子节点是数组旧子节点是文本那就把文本清掉逐个挂载新子节点。第三也是最重要的新旧子节点都是数组这时候才进入真正的 diff 算法也就是 patchKeyedChildren 的逻辑。第三种情况是日常开发里最常见、也是面试最爱考的。比如一个v-for渲染的列表数据重新排列了新旧虚拟节点数组长度可能不一样节点顺序可能变化还可能有新增有删除。所有这些情况都要在尽可能少操作 DOM 的前提下把页面更新到正确状态。2.4 为什么 Vue 3 的 diff 比 Vue 2 快Vue 2 的 diff 在处理子节点数组时使用的是双向循环 头尾指针的方式但整体逻辑相对朴素遇到列表重新排序的情况会走更复杂的路径去移动节点。Vue 3 在同样的场景下做了一层关键优化先进行前置和后置的相同节点处理把列表两端相同的部分先稳住只对中间“真正变化的部分”做复杂比对。这个优化思路值得多说几句。实际业务里列表更新最典型的情况是头部插入一条、尾部追加一条或者开头删掉一条。这些场景下新旧数组两端有大量节点是相同的。Vue 3 直接先把这些相同部分确定下来不需要进入复杂的移动逻辑省掉了大范围遍历。所以 Vue 3 的 diff 速度比 Vue 2 快不是靠某一个魔法优化而是在多种常见场景下都比 Vue 2 少做了无用功。这一块我下面展开讲。3. 双端比较与 key 的价值列表更新的关键路径这一节是整个 diff 算法最精彩的部分。把源码里的逻辑剥开就是一套非常清楚的策略。3.1 先看双端预处理做了什么假设旧列表是[A, B, C, D]新列表是[A, C, D, B]。一眼看过去只有 B 挪了位置。但如果让算法第一轮就去比对每个节点的相对位置成本会很高。Vue 3 的 patchKeyedChildren 会先处理两个特殊位置第一步从头部开始比对。旧列表头是 A新列表头也是 A相同直接复用指针对应向后移动。这一步叫头部处理。第二步从尾部开始比对。旧列表尾是 D新列表尾是 B不相同暂时停下。然后调转方向从尾部开始比对。旧列表尾是 D新列表尾是 B不相同暂时停下头部指针继续往后走。接着从尾部比对旧列表尾是 D新列表尾是 B不相同停止。再回头从头部开始发现旧列表头是 C新列表头是 D也不相同停止。实际上完整的算法流程会交替进行头头、尾尾、头尾、尾头的比对目的就是在进入复杂逻辑前先把两端能确定复用的节点全部标记好。这个阶段处理完后新旧列表会剩下一个“中间待处理区域”。3.2 中间区域的乱序处理中间区域的处理是最考验设计的。Vue 3 的处理方式分几步第一以新列表的中间区域节点为基准构建一个旧节点 key 到旧节点索引的映射表。第二遍历新列表中间区域用这个映射表快速判断新节点在旧列表里是否存在如果存在说明可以复用如果不存在说明需要新挂载。第三记录一个目前遍历到的“最大旧索引值”用来判断节点是否发生了位置移动。如果当前旧索引值小于之前记录的最大值说明这个节点需要移动位置。第四用一个数组记录新列表中间区域每个节点在旧列表中的索引位置然后计算这个数组的最长递增子序列得到不需要移动的节点集合其他节点再按序列关系各自插入。这一套流程里最关键的还有卸载逻辑。遍历过程中如果发现某个新节点在旧映射表里找不到对应旧节点就执行挂载。等新列表遍历完还要把旧列表中有而新列表中没有的节点卸载掉清掉对应的真实 DOM。3.3 key 在这里扮演的角色如果给每个列表项设置了稳定的 key那么上面说的映射表就能快速建立节点识别靠 key 值准确且高效。如果没有 key或者用 index 当 key问题就来了。用 index 当 key 时key 值等于数组下标。列表头部插入一条数据所有元素的 index 全部变了映射表原本可以复用的节点现在 key 对不上框架会识别成完全不同的节点于是这些节点全部要卸载重建。那之前组件内部的状态也都丢了。最典型的例子是列表内有输入框插入一条数据后输入框内容全丢了就是因为输入框对应的节点被当成新节点重建了。所以 key 的本质是唯一标识它的作用就是帮助 diff 算法在找对应关系时不会认错人。稳定、唯一的 key能最大程度让节点被复用减少真实 DOM 操作。3.4 最长递增子序列到底在做什么最长递增子序列英文缩写 LIS是这里容易让人一头雾水的部分。我的理解方式是这样的已知新列表中间区域的每个节点对应旧列表的索引如果这些索引在原旧列表中本来就是递增的说明它们在旧列表里的相对顺序和新列表一致不需要移动。举个例子新列表中间区域节点对应旧索引是[2, 3, 4]本身就是递增的那这些节点在旧列表里的顺序已经对了不用动。如果是[2, 0, 3]那就不是完全递增其中能构成最长递增子序列的是[2, 3]或[0, 3]这部分节点相对顺序正确不需要移动其他节点则插入到对应位置。Vue 3 自己实现了一个求最长递增子序列的函数返回的是索引数组。算法利用贪心 二分查找来优化效率时间复杂度做到 O(n log n)然后通过回溯得到最终序列。注意面试说到 LIS 时能把它解释成“找出哪些节点不用动”就够了不需要背下源码每一行。理解它解决了什么问题比背代码更有价值。4. 把核心逻辑写成一个简化模型学了理论不动手总觉得不够踏实。下面我用极简的代码框架来演示 patchKeyedChildren 的核心步骤目的在于加深理解。你不用拿它去跑生产环境这不是完整实现但逻辑脉络和 Vue 3 源码是对得上的。4.1 简化模型的代码骨架我没有把 Vue 3 源码的边界判断全部搬过来只保留了核心思路每一步加注释。function patchKeyedChildren(oldChildren, newChildren, container) { // 1. 双端预处理处理头部相同节点复用 let oldStart 0; let oldEnd oldChildren.length - 1; let newStart 0; let newEnd newChildren.length - 1; // 头部相同节点先全部确认 while (oldStart oldEnd newStart newEnd) { const oldNode oldChildren[oldStart]; const newNode newChildren[newStart]; if (oldNode.key newNode.key) { patchNode(oldNode, newNode, container); oldStart; newStart; } else { break; } } // 尾部相同节点倒着确认 while (oldStart oldEnd newStart newEnd) { const oldNode oldChildren[oldEnd]; const newNode newChildren[newEnd]; if (oldNode.key newNode.key) { patchNode(oldNode, newNode, container); oldEnd--; newEnd--; } else { break; } } // 2. 如果老节点全部处理完新节点还有剩余全部挂载 if (oldStart oldEnd) { for (let i newStart; i newEnd; i) { mountNode(newChildren[i], container); } return; } // 3. 如果新节点全部处理完老节点还有剩余全部卸载 if (newStart newEnd) { for (let i oldStart; i oldEnd; i) { unmountNode(oldChildren[i], container); } return; } // 4. 中间乱序区域建立 key - index 映射 const keyToOldIndexMap new Map(); for (let i oldStart; i oldEnd; i) { keyToOldIndexMap.set(oldChildren[i].key, i); } const toPatch []; let maxNewIndexSoFar 0; let moved false; // 5. 遍历新列表中间区域决定是复用还是挂载 for (let i newStart; i newEnd; i) { const newNode newChildren[i]; const oldIndex keyToOldIndexMap.get(newNode.key); if (oldIndex undefined) { // 新节点在旧列表里没有对应节点需要挂载 mountNode(newNode, container); } else { const oldNode oldChildren[oldIndex]; patchNode(oldNode, newNode, container); toPatch.push(oldIndex); if (oldIndex maxNewIndexSoFar) { moved true; } else { maxNewIndexSoFar oldIndex; } } } // 6. 有移动情况下根据最长递增子序列减少移动次数 if (moved) { const lis getLongestIncreasingSubsequence(toPatch); // 从后往前遍历把不在 LIS 里的节点移动到正确位置 // 这里省略具体 DOM 操作思路是尽量复用 moveNodesBasedOnLIS(toPatch, lis, newChildren, oldChildren, container); } }4.2 代码讲解与边界情况这段代码最值得注意的地方是中间区域的映射表建立。它用keyToOldIndexMap把旧节点的 key 映射到旧数组里的索引这样遍历新列表时可以用 O(1) 的时间判断新旧节点对应关系。同时我们用maxNewIndexSoFar记录当前已经遍历过的新节点在旧列表中的最大索引。如果后面某个节点的旧索引比这个大说明相对顺序没变不用移动。如果比这个小说明这个节点在新列表里被提前了需要移动。边界情况里很多新手会漏掉“旧节点全部处理完但新节点还有剩余”和“新节点全部处理完但旧节点还有剩余”这两个分支。这两个分支对应的是纯新增和纯删除必须在进入中间区域处理前就判断否则 index 计算会混乱。这里简化实现省去了组件、Fragment 等类型节点的处理只聚焦元素节点。真实源码的 patch 函数里还有各种分支但 diff 核心逻辑的思路就是上面这条线。4.3 最长递增子序列的通俗推导很多人看到“最长递增子序列”就发怵。我们用一个最简单的例子说明它到底在算什么。假设新列表中间区域节点对应旧索引数组是[3, 1, 2, 0]。我们需要找到一个最长的索引子序列让这些索引在原数组里是递增的也就是相对顺序保持一致。在这个例子里[1, 2]是递增的[3]单独一个也算但[1, 2]更长。还有[0]只能单独算。所以最长递增子序列可能是[1, 2]。如果选择保留这两个节点不动那节点 3 和节点 0 就要通过移动或插入来调整位置。这个选择非常聪明。我们不需要让所有节点都不动只需要让“最多数量”的节点保持相对位置不变其余节点都围绕它们做插入这样 DOM 操作次数最少。5. 实际踩坑记录diff 相关的问题排查与经验理论知识补上了接下来分享一些我在业务开发中踩过的和 diff 相关的坑以及排查思路。这些内容在源码分析文章里不太容易看到但对一线开发者非常实用。5.1 用 index 当 key 的后果到底长什么样很多新手知道“不要用 index 当 key”但不知道为什么。我直接说一个可复现的场景。列表渲染一组带输入框的待办事项。页面初始化时数据是[{ id: 1, text: }, { id: 2, text: }]。用户在第一个输入框里输入了“今天写周报”此时第一个输入框的值是“今天写周报”。这是原始状态。现在在列表头部插入一条新待办数据变成[{ id: 3, text: }, { id: 1, text: }, { id: 2, text: }]。如果用 index 当 key那么原来 id 为 1 的数据渲染的输入框原来 key 为 0新列表里 id 为 3 的数据对应 key 为 0。Vue 的 diff 算法通过 key 判断认为 key 为 0 的节点还是同一个节点所以不会重新创建这个输入框而是复用它并尝试用新数据更新它。但输入框的 value 是用户输入的虚拟 DOM 的更新不会覆盖用户的输入状态于是你会在第一个输入框里看到“今天写周报”但它现在对应的却是 id 为 3 的新数据。用 id 当 key 就能规避这个问题。因为 id 是稳定且唯一的插入新数据后id 为 1 的数据对应的节点 key 还是 1Vue 能准确找到这个节点并复用输入内容也跟着保留。经验凡是列表项可能被增删、排序一律使用数据本身唯一的业务 id 作为 key。如果数据里确实没有 id可以生成一个持久化的唯一标识作为 key而不是用数组下标。5.2 为什么频繁触发更新的列表还是卡顿diff 算法已经把性能优化得很好但实际项目里列表卡顿有个很常见的原因列表项本身的渲染成本太高或者列表项里包含了大量组件和图片资源。diff 只负责“找到差异并更新”但不负责“更新本身执行多快”。如果你的每一项是一个巨大组件props 没有做精细控制父组件数据一变所有列表项都会走一遍更新流程即使节点内容没有变化也依然会执行组件内部的 render 和 diff。遇到这种情况优化方向不是去改 diff而是减少不必要的更新。给子组件用上v-memo或者合理拆分组件让没有变化的列表项跳过更新。Vue 3 新增的v-memo指令就是用来配合 diff 做精细控制的它接受一个依赖数组只有当依赖变化时才更新组件。实测在长列表场景下效果明显。5.3 从性能面板反推 diff 是否在做无谓操作排查 diff 相关性能问题时我常用的方法是在浏览器 Performance 面板里录制一段操作。如果看到一个很长的“Update”任务展开后能看到脚本执行细节。配合虚拟 DOM 更新的时间点可以判断是哪一次数据变化触发了大范围的节点更新。另一种技巧是在开发环境里用代理打印关键的 patch 日志分析每次数据变化后实际被 patch 的节点数。如果数据只改了一条但 patch 的节点数是全部列表项那基本可以断定是没有用 key 或者 key 不稳定导致节点无法识别。这些手段能帮你把问题定位到具体原因而不是一直停留在“感觉页面很卡”的层面。5.4 关于 Vue 3 静态标记和 diff 的关系再补充一个很容易混淆的知识点。Vue 3 编译模板时会给虚拟 DOM 节点打上静态标记比如动态文本、动态属性、动态事件等都有对应的 patch 标记。这些标记的意义在于patch 阶段可以根据标记跳过不必要的内容比对只有带标记的部分才做详细比对。这个机制和 diff 算法是配合关系。diff 解决的是“哪些节点发生了变化”静态标记解决的是“节点里哪些内容可能变化”。两者结合让更新路径变得非常短。这也是为什么 Vue 3 在某些场景下比 Vue 2 快的重要原因不只是 diff 的功劳。6. 最后分享两个实际体会第一个体会是学 diff 算法不要一上来就啃源码。先看高层的流程描述自己画一遍列表更新的各种情况再对照源码去验证理解效率会高很多。我见过太多人把源码抄了一遍但仍然讲不明白因为流程性的东西靠抄是记不住的理解靠的是推演。第二个体会面试讲 diff 时不要机械地背“第一步干嘛第二步干嘛”。最好的方式是拿一个具体例子比如一个乱序列表画图演示 Vue 3 是怎么从两头压缩、中间建映射表、用最长递增子序列确定移动范围的。面试官想看的不是你的记忆力而是你能不能把一个复杂流程讲得让别人听明白。能用大白话讲清楚背后取舍的人才是真正理解了它。如果这篇文章帮你把 diff 的脉络理清了建议你打开 Vue 3 源码里的patchKeyedChildren再从头看一遍那会儿你会有一种“轻舟已过万重山”的感觉。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑