面试被问「虚拟 DOM 为什么快」,很多人会条件反射地答「因为操作 JS 对象比操作 DOM 快」。这个答案经不起追问。真要比单次操作,直接改一次 textContent 显然比走一整套 diff 流程省事。虚拟 DOM 真正解决的不是快慢,是在「状态变了」和「DOM 该怎么改」这两件事之间插了一层,让你只描述结果,不用手写变更过程。这篇把 Vue 2 底层用的 Snabbdom 从头拆一遍,h 函数怎么造 VNode,patch 怎么打补丁,diff 的双端比较到底比了哪四种情况,key 在其中起什么作用。读完你对「v-for 为什么别拿 index 当 key」会有一个能自己推导出来的解释。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 虚拟 DOM 是什么,用一个 JS 对象描述 DOM 到底描述了哪几个字段
- 为什么要有虚拟 DOM,它解决的是性能问题还是别的问题
- Snabbdom 上手,从建项目到用模块处理属性、样式、事件
- h 函数的重载怎么实现,VNode 的六个字段各管什么
- init 为什么写成高阶函数,patch 的完整分支走向
- createElm 和 patchVnode 的执行顺序,钩子函数在哪几个点被触发
- updateChildren 的双端比较,四种命中情况加两种收尾情况
- 这套算法反过来对写业务代码有什么约束,比如 key 该怎么选
一、虚拟 DOM 到底是什么
虚拟 DOM 就是用普通的 JavaScript 对象来描述真实 DOM。因为它不是真的 DOM 对象,所以叫 Virtual DOM。
那为什么非得拿一个普通对象去描述?先看看真实的 DOM 对象身上挂了多少东西。随便捞一个元素,把它的属性名全打印出来:
let element = document.querySelector('#app') |
这一坨输出还只是 Chrome 里一个 div 的属性名列表,几百个。如果每次状态变化都要在这样一个庞然大物上做增删改查,还要考虑各家浏览器的行为差异,代码很快就会变成一团乱麻。
所以换个思路,我不描述「怎么改」,我描述「现在应该长什么样」。用一个对象就够了:
{ |
六个字段,一个不多。sel 是选择器,data 装属性样式事件,children 和 text 互斥(一个节点要么有子节点要么有文本),elm 指向它对应的真实 DOM,key 用来做同一层节点的身份标识。一棵这样的对象树,就是一份 DOM 的快照。
二、为什么要有虚拟 DOM
先说结论,虚拟 DOM 解决的核心问题是状态跟踪,性能只是它顺带换来的东西。
顺着历史看一遍就清楚了。最早我们手动操作 DOM,麻烦不说,还得处理浏览器兼容,jQuery 把这层抹平了一部分,但项目一复杂,「哪个状态变了要改哪几个节点」这件事依然全靠人脑记,DOM 操作的复杂度随着页面复杂度一起涨。
再往后各种 MVVM 框架出现,把视图和状态的同步问题接管了。模板引擎也简化了视图的书写,但模板引擎有个致命短板,它不知道这次和上次相比哪里变了,只能整个重新渲染一遍。页面一大,整块重渲的代价就上来了,输入框失焦、滚动位置丢失这些副作用还得另外补。
虚拟 DOM 补的就是这一块。状态改变时不立即碰真实 DOM,先建一棵新的虚拟树描述「现在应该是什么样」,然后交给内部的 diff 去算出和上一棵树的差异,最后只把差异落到真实 DOM 上。它替你维护了上一次的状态,也替你算出了最小变更。
所以真正的收益不是「JS 对象比 DOM 快」,而是你写的是声明式代码,跑的是增量更新。开发体验和运行效率同时拿到了一部分,代价是多了一层 diff 的计算开销。页面结构越复杂、单次变更越局部,这笔买卖越划算;反过来如果每次都是整页数据全换,diff 就是纯开销。
还有一个常被忽略的好处。既然中间隔了一层描述,那这层描述往下渲染成什么就不一定是浏览器 DOM 了。同一棵虚拟树,可以:
- 维护视图和状态的关系,跟踪上一次状态
- 在复杂视图下把整块重渲降级成增量更新
- 渲染到服务端字符串做 SSR(Nuxt.js / Next.js)
- 渲染到原生控件(Weex / React Native)
- 渲染到小程序的自定义组件(mpvue / uni-app)

跨端能跑通,靠的就是这层抽象。虚拟树本身不认识 document,认识 document 的是渲染器那部分。
有哪些现成的虚拟 DOM 库
真要挑一个来读源码,我推荐 Snabbdom:
- Snabbdom
- Vue 2.x 内部使用的虚拟 DOM 就是改造过的 Snabbdom
- 通过模块可扩展,核心极小
- 源码用 TypeScript 写,类型定义本身就是文档
- 公认最快的虚拟 DOM 实现之一
- virtual-dom
选 Snabbdom 的理由很实在,核心代码算上注释也就几百行,一晚上能读完,而且读完就等于读懂了 Vue 2 渲染层的一半。响应式那一半我在 Vue响应式原理模拟 手写一个迷你版Vue 里拆过,两篇合起来正好是 Vue 2 的完整链路:数据变化触发渲染 Watcher,渲染 Watcher 产出新的虚拟树,虚拟树交给 patch 落地。
三、Snabbdom 上手
建项目
读源码之前先跑起来。Snabbdom 用的是 ES Module 写法,需要一个打包器,原文用的是 parcel,零配置,最省事:
# 创建项目目录 |
配置 package.json 的 scripts,一条起开发服务,一条出生产包:
"scripts": { |
然后建一个最简单的目录结构,一个 index.html 挂个 #app,一个入口 js:

装库:
yarn add snabbdom |
导入的写法是这样:
import { init, h, thunk } from 'snabbdom' |
Snabbdom 的核心只提供最基本的功能,当年那个版本对外只导出三个东西:
init()是一个高阶函数,返回patch()h()返回虚拟节点 VNode,这个函数你在用 Vue 的时候见过thunk()是一种优化策略,处理不可变数据时可以用它跳过不必要的重算
h() 眼熟吧,Vue 2 的入口就长这样:
new Vue({ |
这里有个坑要注意,导入时不能写 import snabbdom from 'snabbdom'。原因在源码末尾:它用的是具名 export 导出 API,没有 export default,所以默认导入拿到的是 undefined。

顺带说一句版本的事。上面这套导入路径是 Snabbdom 早期版本(Vue 2 内联的那一版)的形态,后来的版本对导出结构做过调整,模块也改成了从主入口具名导出。你要是照着最新版跑,导入路径以官方 README 为准,本文保留原始写法是为了和 Vue 2 里的实现对得上。
先跑几个例子找感觉
例子1
import { h, init } from 'snabbdom' |
有三个点值得停一下。第一次 patch(app, vnode) 传的第一个参数是真实 DOM 元素,Snabbdom 内部会先把它包成一个空的 VNode,这样后面就能统一按 VNode 处理。patch 的返回值是新的 VNode,它必须被接住,因为下一次 patch 要拿它当旧节点。还有 hook 里的 init 和 create,这是用户级钩子,创建 DOM 的过程中会被调用,打个 console.log(vnode.elm) 就能看到真实节点是什么时候挂上去的。
例子2
// 2. div中放置子元素 h1,p |
这个例子里藏着一个新手必踩的坑。想清空页面,第一直觉是 patch(oldVnode, null),跑起来会报错。为什么不行?因为 patch 内部一路都在读 vnode.sel、vnode.data、vnode.children,传 null 进去直接就崩了。正确做法是 patch(oldVnode, h('!')),! 这个选择器在 Snabbdom 里代表注释节点,用一个空注释把原来的内容顶掉,DOM 上留下一个 <!----> 占位。Vue 里 v-if 为假时留下的那个注释节点,用的就是同一套逻辑。
例子3 debug-patchVnode
下面这三个例子建议真的打断点跑一遍,比读十遍源码管用。这个是最简单的一条路径,新旧节点的 sel 都是 div、都没有 key,判定为同一节点,走进 patchVnode,只有 text 不同,于是直接改 textContent:
import { h, init } from 'snabbdom' |
例子4 debug-updateChildren
这个例子把「视频」和「微博」换了个位置,三个 li 都没写 key。你猜 Snabbdom 会怎么改?它不会去移动节点,因为没有 key 的情况下同位置的 li 全都判定为同一节点,最后结果是第二个和第三个 li 的文本各被改写了一次:
import { h, init } from 'snabbdom' |
例子5 debug-updateChildren-key
同样的顺序调整,这次每个 li 带上了 key。行为完全变了,Snabbdom 认出 b 和 c 只是换了位置,走的是 insertBefore 移动真实节点,一次文本改写都没有:
import { h, init } from 'snabbdom' |
模块,Snabbdom 保持小巧的关键
跑完上面五个例子你可能已经发现了,核心库压根不处理元素的属性、样式和事件。这不是遗漏,是刻意的设计。核心只负责「建节点、比差异、落 DOM」这条主线,其它一切通过模块往里插。
官方提供了 6 个模块,各管一类 DOM 特性:
| 模块 | 职责 | 要点 |
|---|---|---|
attributes |
用 setAttribute() 设置属性 |
会处理布尔类型属性(如 disabled) |
props |
用 element[attr] = value 设置属性 |
不处理布尔类型属性 |
class |
切换类样式 | 注意它做的是「切换」,初始类名一般写在 sel 选择器里 |
dataset |
设置 data-* 自定义属性 |
对应 element.dataset |
eventlisteners |
注册和移除事件 | 通过 on 字段传入 |
style |
设置行内样式,支持动画 | 额外提供 delayed / remove / destroy 三种时机 |
attributes 和 props 这对经常有人搞混。区别在于一个走 HTML 特性,一个走 DOM 属性。以 input 的值为例,setAttribute('value', x) 改的是初始值,el.value = x 改的是当前值,两者在用户输入过之后会不一致。Vue 模板里那些 :value、:checked 最终落到哪一边,判断依据也是同一套。
模块使用分三步:
- 导入需要的模块
- 在
init()中注册模块 - 用
h()创建 VNode 时把第二个参数写成对象,模块需要的数据放在对应字段里,其它参数往后移
import { init, h } from 'snabbdom' |
最后那两行值得留意。新的 vnode 只有文本没有 children,也没有 style 和 on 字段,patch 之后原来的 h1、p 会被整体移除,绑上去的 click 事件也会被 eventlisteners 模块在 update 钩子里清掉。模块不只负责「加」,也负责「减」,这是它必须挂在 patch 生命周期里而不是随便写个工具函数的原因。
需要说明的是,模块的导入路径在后续版本里改过,新版从主入口具名导出。上面这份写法对应的是 Vue 2 内联的那个时期,读源码时按你手上的版本对照 README 就行。
四、源码主线,从 h 到 patch
整条链路只有四步
- 用
h()创建 JavaScript 对象(VNode)描述真实 DOM init()注册模块,产出patch()patch()比较新旧两个 VNode- 把变化的内容更新到真实 DOM 树上
源码地址在 https://github.com/snabbdom/snabbdom ,src 目录长这样:

文件不多,核心就是 h.ts、vnode.ts 和 init 所在的那个文件,剩下的是各个模块和工具函数。下面按调用顺序一个个看。
h 函数
h() 最早见于 hyperscript,那个库用 JavaScript 创建超文本。Snabbdom 借了这个名字,但做的事不是创建超文本,而是创建 VNode。
先聊聊它的重载。所谓重载就是参数个数或类型不同的同名函数,JavaScript 里没有这个概念,写两个同名函数后一个会覆盖前一个:
function add (a, b) { |
上面这段跑出来两次都是三个数的版本在生效,add(1, 2) 会打印 NaN。TypeScript 有重载的语法,但那只是给类型系统看的声明,运行时依然是一个函数体,靠手动判断参数来分流。
Snabbdom 的 h() 就是这么干的。源码位置 src/h.ts:
// h函数的重载 |
前面那四行 export function h (...) 全是 TypeScript 的重载签名,没有函数体,真正干活的是最后那个 h (sel: any, b?: any, c?: any)。函数体里做的事情很朴素,先看有没有第三个参数,有就说明是 (sel, data, children/text) 的形态;没有第三个参数就看第二个参数是数组、是原始值、还是 VNode、还是普通对象,分别落到 children、text、children、data 上。
中间那段 is.primitive(children[i]) 的处理容易被跳过,但它挺关键。h('ul', ['a', 'b']) 这种写法里,数组元素是字符串而不是 VNode,这里会把它们包成只有 text 的文本 VNode。统一成 VNode 之后,后面的 diff 就不用再区分「这一项是节点还是字符串」了。
结尾那段 sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g' 是在识别 SVG 标签。SVG 元素必须用 createElementNS 带命名空间创建,用 createElement 造出来的 <svg> 是渲染不出图形的。所以 h 在这里给 data 打上 ns 标记,一路传到创建 DOM 的那步。逐个字符比较而不是用 startsWith,是为了省掉字符串方法的开销,这种写法在热路径上很常见。
VNode
一个 VNode 就是一个虚拟节点,用来描述一个 DOM 元素。如果这个 VNode 有 children,那它连同子孙就构成了一棵虚拟 DOM 树。
源码位置 src/vnode.ts:
export interface VNodeData { |
vnode() 这个工厂函数本身没什么内容,值得看的是那六个字段的分工。sel 和 key 是身份证,diff 判断「这两个节点是不是同一个」只看这两项。data 是给模块用的数据袋,你在 h() 第二个参数里写的 style、on、attrs 全在这儿。children 和 text 互斥,一个节点不能既有子节点又有文本。elm 是虚拟世界和真实世界之间的那根绳子,patch 的时候要靠它找到该改哪个真实节点。
VNodeData 接口最后那行 [key: string]: any 是留给第三方模块的口子,你自己写个模块塞个新字段进去,类型检查也不会拦你。
patch 的整体走向
patch(oldVnode, newVnode) 做的事就是打补丁:把新节点中变化的内容渲染到真实 DOM,最后返回新节点,作为下一次处理时的旧节点。
它的判断顺序是这样的:
- 先对比新旧 VNode 是不是同一个节点,判断依据只有两项,
key和sel相同 - 如果不是同一个节点,不做任何比较,直接照新节点建一棵新 DOM 插进去,再把旧的删掉
- 如果是同一个节点,看新 VNode 有没有
text,有并且和旧的不同,直接更新文本内容 - 如果新 VNode 有
children,就得判断子节点有没有变化,这个判断过程用的就是 diff 算法 - diff 只做同层级比较,不跨层

第二条要单独强调一下。很多人以为 diff 会努力复用,其实不会。只要 sel 或 key 有一个对不上,整棵子树直接丢弃重建,一点都不心疼。这是拿「可能多建几个节点」换「不用做跨层匹配」,因为跨层匹配的代价高得离谱,下面讲 updateChildren 的时候会算这笔账。
init
init(modules, domApi) 返回的是 patch() 函数,这是个典型的高阶函数。
为什么非要绕这一层?因为 patch() 在外部会被调用很多次,每次调用都依赖 modules、domApi、cbs 这几个东西。用高阶函数在 init() 内部形成闭包,返回的 patch() 就能一直访问到这些变量,不用每次重新组装。你也可以理解成一次性的依赖注入,注入完之后拿到的是一个已经配置好的 patch。
init() 在返回 patch() 之前干了一件正事:把所有模块里的钩子函数按类型收集到 cbs 对象里。源码位置 src/init.ts:
export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) { |
那个双重 for 循环就是在做钩子收集。外层遍历六种钩子类型(create、update、remove、destroy、pre、post),内层遍历你注册的每个模块,把模块上同名的钩子函数塞进 cbs[类型] 数组。收集完之后,patch 里要触发某类钩子只需要遍历一个扁平数组,不用每次再去各个模块上取。
domApi 这个参数也有意思。默认用的是 htmlDomApi,一套针对浏览器 DOM 的操作封装。但它是可替换的,你传一套面向别的宿主环境的实现进去,Snabbdom 就能把虚拟树渲染到别的地方去。前面说的跨端渲染,落到代码上就是这个口子。
patch
patch 的职责很明确:传入新旧 VNode,对比差异,把差异渲染到 DOM,然后返回新的 VNode 作为下一次 patch() 的 oldVnode。
完整的执行过程是这样的:
- 首先执行模块中的
pre钩子函数 - 判断
oldVnode是不是一个 VNode。首次渲染时传进来的是真实 DOM 元素,这时用emptyNodeAt()把它包成一个空的 VNode,后面就能统一处理 - 如果
oldVnode和vnode是同一节点(key和sel都相同),调用patchVnode()找差异并更新 DOM - 如果不是同一节点,走重建流程:先拿到旧节点对应的真实 DOM 和它的父节点,调用
createElm()把新 vnode 转换成真实 DOM 并记到vnode.elm,把新建的 DOM 插到旧节点后面,再移除旧节点 - 遍历
insertedVnodeQueue,执行用户设置的insert钩子函数 - 最后执行模块的
post钩子函数
源码位置 src/snabbdom.ts:
return function patch (oldVnode: VNode | Element, vnode: VNode): VNode { |
有个细节我第一次看的时候没绕明白:新建的 DOM 是用 insertBefore(parent, vnode.elm, api.nextSibling(elm)) 插进去的,也就是插到旧节点的下一个兄弟之前,等价于插到旧节点后面。为什么不先删旧的再插新的?因为先删就丢了位置信息,nextSibling 会拿不到。先插后删,位置才准。
还有 insertedVnodeQueue 这个队列。用户设置的 insert 钩子不是在创建节点时立刻触发的,而是攒到最后统一执行。原因很实际,insert 钩子的语义是「节点已经进入文档了」,创建那一刻它还挂在游离的父节点上,读 offsetHeight 之类的布局信息全是 0。等整棵树都插完再触发,拿到的才是有效值。
createElm
createElm(vnode, insertedVnodeQueue) 负责把一个 VNode 变成真实 DOM 元素并返回它。
执行过程按选择器分三条路:
- 首先触发用户设置的
init钩子函数 - 如果选择器是
!,创建注释节点(前面例子 2 里用h('!')清空页面,就是走的这条) - 如果选择器为空,创建文本节点
- 如果选择器不为空
- 解析选择器,把
#后面的部分设成id,.后面的部分设成class - 执行模块的
create钩子函数,属性、样式、事件在这一步被挂上去 - 如果 vnode 有
children,递归创建每个子 vnode 的 DOM 并追加到当前元素上 - 如果 vnode 的
text是 string 或 number,创建文本节点追加进去 - 执行用户设置的
create钩子函数 - 如果用户设置了
insert钩子,把这个 vnode 推进队列,留到 patch 结束时统一触发
- 解析选择器,把
function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node { |
解析选择器那几行是纯字符串切分,h('div#container.cls') 会被拆成标签 div、id container、class cls。注意 class 那一行做了 replace(/\./g, ' '),所以 div.a.b 能正确变成两个类名。
createElm 是递归的,子节点的创建也走同一个函数,整棵子树建完才 return。所以首次渲染是一次深度优先的构建,模块的 create 钩子会在每个元素上各触发一次。
patchVnode
patchVnode(oldVnode, vnode, insertedVnodeQueue) 处理的是「已确认是同一个节点」之后的事:对比两者差异,把差异渲染到 DOM。
执行过程:
- 首先执行用户设置的
prepatch钩子函数 - 把
oldVnode.elm直接赋给vnode.elm,真实 DOM 引用就这么传递下去了,新 vnode 不用重新查找节点 - 如果新旧 vnode 是同一个对象引用,直接返回,什么都不用做
- 执行
update钩子函数- 先执行模块的
update钩子函数,属性、样式、事件的增量更新在这一步完成 - 再执行用户设置的
update钩子函数
- 先执行模块的
- 如果
vnode.text未定义- 如果
oldVnode.children和vnode.children都有值- 调用
updateChildren() - 使用
diff算法对比子节点,更新子节点
- 调用
- 如果
vnode.children有值,oldVnode.children无值- 清空
DOM元素 - 调用
addVnodes(),批量添加子节点
- 清空
- 如果
oldVnode.children有值,vnode.children无值- 调用
removeVnodes(),批量移除子节点
- 调用
- 如果
oldVnode.text有值- 清空
DOM元素的内容
- 清空
- 如果设置了
vnode.text并且和oldVnode.text不相等- 如果老节点有子节点,全部移除
- 设置 DOM 元素的
textContent为vnode.text
- 最后执行用户设置的
postpatch钩子函数
- 如果
function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) { |
五、updateChildren,diff 算法的核心
终于到最硬的一块了。updateChildren 干的事只有一句话:对比新旧节点的 children,把差异落到 DOM。但它是整个虚拟 DOM 里最值得琢磨的一段代码。
先算一笔账,为什么只比同层
要对比两棵树的差异,最直觉的做法是拿第一棵树的每个节点,依次和第二棵树的每个节点比一遍,再算出最小编辑距离。这个通用树 diff 的复杂度是 O(n^3),一千个节点就是十亿次操作,页面还没渲染完人已经走了。
那怎么办?做一个假设。在真实的 DOM 操作里,我们极少会把一个父节点移动成某个子节点,跨层级的节点搬家几乎不发生。既然如此,跨层比较的那部分收益可以直接放弃。
于是只找同级别的子节点依次比较,然后再找下一级别的节点比较,复杂度降到 O(n)。

代价是什么?如果你真的把一个节点从第二层挪到了第三层,diff 不会认出这是「移动」,它会在旧位置删掉、在新位置重建。这是用一个不常见场景的性能,换所有常见场景的性能,很划算的交易。
双端比较,四种命中情况
同级别节点比较的时候,Snabbdom 给新老两个数组的开始和结尾各设一个标记索引,一共四个,遍历过程中往中间收拢。
每一轮循环,先按顺序试这四种组合:
oldStartVnode / newStartVnode(旧开始节点 / 新开始节点)oldEndVnode / newEndVnode(旧结束节点 / 新结束节点)oldStartVnode / newEndVnode(旧开始节点 / 新结束节点)oldEndVnode / newStartVnode(旧结束节点 / 新开始节点)

为什么是这四种而不是全排列?因为它们覆盖了实际业务里最高频的几种变化:列表尾部追加、列表头部插入、整体反转、以及首尾互换。命中任何一种,这一轮就能只做一次 patchVnode 加最多一次 DOM 移动。
第一种和第二种,头对头、尾对尾
如果 oldStartVnode 和 newStartVnode 是 sameVnode(key 和 sel 相同):
- 调用
patchVnode()对比和更新这两个节点 - 把旧开始和新开始索引一起往后移动,
oldStartIdx++/newStartIdx++

尾对尾同理,oldEndIdx-- / newEndIdx--。这两种情况都不需要移动 DOM,因为节点的相对位置本来就没变。列表末尾加一条数据走的就是这条路径,前面 n 个全部头对头命中,最后剩一个新节点走收尾逻辑插进去。
第三种,旧开始对新结束
oldStartVnode 和 newEndVnode 相同,说明原来排在最前面的节点跑到最后去了:
- 调用
patchVnode()对比和更新节点 - 把
oldStartVnode对应的真实 DOM 元素移动到右边,具体是插到oldEndVnode对应 DOM 的下一个兄弟之前 - 更新索引,
oldStartIdx++/newEndIdx--

第四种,旧结束对新开始
oldEndVnode 和 newStartVnode 相同,说明原来在最后的节点跑到最前面了:
- 调用
patchVnode()对比和更新节点 - 把
oldEndVnode对应的真实 DOM 元素移动到左边,插到oldStartVnode对应 DOM 之前 - 更新索引,
oldEndIdx--/newStartIdx++

这两种交叉命中是双端比较最漂亮的地方。数组整体反转的场景,靠它们能在一轮轮循环里全部消化掉,一次都不用去查找。
四种都不命中怎么办
那就只能老老实实查找了:
- 遍历老节点数组,用
newStartVnode的key去找有没有 key 相同的老节点 - 如果没找到,说明
newStartVnode是全新的节点,创建对应的 DOM 元素插到oldStartVnode对应 DOM 之前,然后newStartIdx++ - 如果找到了
- 再判断新节点和找到的老节点的
sel选择器是否相同 - 如果不相同,说明这个位置的节点被换成了别的标签,重新创建 DOM 插进去
- 如果相同,调用
patchVnode()更新,然后把elmToMove对应的 DOM 元素移动到左边,并把老数组里那一项置为undefined,防止后面重复处理
- 再判断新节点和找到的老节点的

这里就是 key 的价值所在。没有 key 的时候这条查找路径根本走不通,diff 只能退化成按位置逐个比较。回到前面例子 4 和例子 5 的差别:不带 key,「视频」和「微博」换位置变成了两次文本改写;带了 key,变成一次节点移动。前者看着好像也没慢多少,但如果 li 里面是个带内部状态的组件,或者是个用户正在输入的 input,按位置复用就会把状态串到错误的行上去。
这也是「别拿数组 index 当 key」的完整理由。index 是位置,不是身份。往列表头部插一条数据,所有元素的 index 全变了,key 跟着变,diff 判定为「每一项都不是同一个节点」,等于全部重建,还不如不写 key。真要写就写数据本身的稳定 id。
循环怎么结束
循环的终止条件是 oldStartIdx > oldEndIdx 或者 newStartIdx > newEndIdx,也就是任何一个数组先被遍历完。跳出循环之后还得收尾。
如果老节点数组先遍历完(oldStartIdx > oldEndIdx),说明新节点有剩余,把 newStartIdx 到 newEndIdx 之间剩下的节点批量创建并插入:

如果新节点数组先遍历完(newStartIdx > newEndIdx),说明老节点有剩余,把 oldStartIdx 到 oldEndIdx 之间剩下的节点批量删除:

批量删除那一步要留意,中间可能有被置为 undefined 的空位(前面查找命中时留下的),removeVnodes 里会跳过它们。
六、这套算法反过来约束了什么
读源码不是为了背流程,是为了知道自己写的代码会触发哪条路径。几条我认为最实用的:
key 必须稳定且唯一,别用 index,别用随机数。 用 Math.random() 当 key 是我见过最离谱的写法,每次渲染 key 都变,等于每次都全量重建,比不写 key 还糟。
同一个位置尽量别换标签名。 sel 变了就是整棵子树重建,子组件会重新走一遍完整的创建流程。条件渲染里 v-if / v-else 两个分支如果结构相近,Vue 默认会尝试复用,你不想复用才需要手动加不同的 key。
列表操作优先用「在两端增删」的形态。 双端比较对首尾变化最友好,中间大规模乱序会退化成查找加移动。这不是说中间不能改,是说如果有选择的话,数据结构上尽量让变化集中在两端。
长列表该虚拟滚动还是得虚拟滚动。 diff 是 O(n),n 是节点数。一万条数据的列表,哪怕只改一条,diff 也要走一万次比较。这时候该做的是减少 n,而不是指望 diff 变快。
还有一点得说清楚,Vue 3 的 diff 和这套已经不完全一样了。Vue 3 编译期会给模板打上 patchFlag,标记出哪些节点是动态的、动态在哪个属性上,运行时可以直接跳过静态节点。带 key 的列表也换成了「先做双端预处理,中间部分求最长递增子序列」的算法,移动次数更少。原理层面双端比较依然是理解的基础,但真读 Vue 3 源码时别拿这篇的结论直接套。
总结
把这一圈拆下来,能直接带走的结论有这些:
- 虚拟 DOM 是用普通 JS 对象描述真实 DOM,核心字段就六个,
sel和key是身份,data装模块数据,children和text互斥,elm连接虚拟与真实 - 它解决的是状态跟踪问题,性能是顺带的收益。真正划算的场景是「结构复杂 + 单次变更局部」
- 中间隔了一层描述,所以同一棵虚拟树能渲染到浏览器、服务端、原生控件和小程序
- Snabbdom 核心只做建节点、比差异、落 DOM,属性样式事件全靠六个模块插进来,
init用高阶函数把模块和 domApi 闭包进patch patch的第一个判断是「是不是同一节点」,只看key和sel,不同就整棵子树重建,绝不尝试复用patchVnode处理同一节点内部的差异,updateChildren处理子节点列表的差异- diff 只做同层比较,把
O(n^3)压到O(n),代价是放弃识别跨层移动 - 双端比较先试四种组合(头头、尾尾、旧头新尾、旧尾新头),都不中才用 key 去老数组里查找
- key 的全部意义就在最后那条查找路径上。用 index 当 key 等于没有身份信息,diff 退化成按位置复用
我的建议是把例子 4 和例子 5 都打上断点跑一遍,看着 oldStartIdx 和 newStartIdx 一步步往中间收,比看十张图都直观。这块跟 Vue 2 的响应式是配套的,数据怎么变成一次重新渲染,我在 Vue响应式原理模拟 手写一个迷你版Vue 和 Vue响应式原理从defineProperty到Proxy 这两篇里拆过,串起来看链路就完整了。