导航
导航
文章目录󰁋
  1. 一、虚拟 DOM 到底是什么
  2. 二、为什么要有虚拟 DOM
    1. 有哪些现成的虚拟 DOM 库
  3. 三、Snabbdom 上手
    1. 建项目
    2. 先跑几个例子找感觉
    3. 模块,Snabbdom 保持小巧的关键
  4. 四、源码主线,从 h 到 patch
    1. 整条链路只有四步
    2. h 函数
    3. VNode
    4. patch 的整体走向
    5. init
    6. patch
    7. createElm
    8. patchVnode
  5. 五、updateChildren,diff 算法的核心
    1. 先算一笔账,为什么只比同层
    2. 双端比较,四种命中情况
    3. 四种都不命中怎么办
    4. 循环怎么结束
  6. 六、这套算法反过来约束了什么
  7. 总结
  8. 参考

虚拟DOM原理分析 Snabbdom源码与diff算法拆解

面试被问「虚拟 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') 
let s = ''
for (var key in element) {
s += key + ','
}
console.log(s)

// 打印结果 align,title,lang,translate,dir,hidden,accessKey,draggable,spellcheck,aut ocapitalize,contentEditable,isContentEditable,inputMode,offsetParent,off setTop,offsetLeft,offsetWidth,offsetHeight,style,innerText,outerText,onc opy,oncut,onpaste,onabort,onblur,oncancel,oncanplay,oncanplaythrough,onc hange,onclick,onclose,oncontextmenu,oncuechange,ondblclick,ondrag,ondrag end,ondragenter,ondragleave,ondragover,ondragstart,ondrop,ondurationchan ge,onemptied,onended,onerror,onfocus,oninput,oninvalid,onkeydown,onkeypr ess,onkeyup,onload,onloadeddata,onloadedmetadata,onloadstart,onmousedown ,onmouseenter,onmouseleave,onmousemove,onmouseout,onmouseover,onmouseup, onmousewheel,onpause,onplay,onplaying,onprogress,onratechange,onreset,on resize,onscroll,onseeked,onseeking,onselect,onstalled,onsubmit,onsuspend ,ontimeupdate,ontoggle,onvolumechange,onwaiting,onwheel,onauxclick,ongot pointercapture,onlostpointercapture,onpointerdown,onpointermove,onpointe rup,onpointercancel,onpointerover,onpointerout,onpointerenter,onpointerl eave,onselectstart,onselectionchange,onanimationend,onanimationiteration ,onanimationstart,ontransitionend,dataset,nonce,autofocus,tabIndex,click ,focus,blur,enterKeyHint,onformdata,onpointerrawupdate,attachInternals,n amespaceURI,prefix,localName,tagName,id,className,classList,slot,part,at tributes,shadowRoot,assignedSlot,innerHTML,outerHTML,scrollTop,scrollLef t,scrollWidth,scrollHeight,clientTop,clientLeft,clientWidth,clientHeight ,attributeStyleMap,onbeforecopy,onbeforecut,onbeforepaste,onsearch,eleme ntTiming,previousElementSibling,nextElementSibling,children,firstElement Child,lastElementChild,childElementCount,onfullscreenchange,onfullscreen error,onwebkitfullscreenchange,onwebkitfullscreenerror,setPointerCapture ,releasePointerCapture,hasPointerCapture,hasAttributes,getAttributeNames ,getAttribute,getAttributeNS,setAttribute,setAttributeNS,removeAttribute ,removeAttributeNS,hasAttribute,hasAttributeNS,toggleAttribute,getAttrib uteNode,getAttributeNodeNS,setAttributeNode,setAttributeNodeNS,removeAtt ributeNode,closest,matches,webkitMatchesSelector,attachShadow,getElement sByTagName,getElementsByTagNameNS,getElementsByClassName,insertAdjacentE lement,insertAdjacentText,insertAdjacentHTML,requestPointerLock,getClien tRects,getBoundingClientRect,scrollIntoView,scroll,scrollTo,scrollBy,scr ollIntoViewIfNeeded,animate,computedStyleMap,before,after,replaceWith,re move,prepend,append,querySelector,querySelectorAll,requestFullscreen,web kitRequestFullScreen,webkitRequestFullscreen,createShadowRoot,getDestina tionInsertionPoints,ELEMENT_NODE,ATTRIBUTE_NODE,TEXT_NODE,CDATA_SECTION_ NODE,ENTITY_REFERENCE_NODE,ENTITY_NODE,PROCESSING_INSTRUCTION_NODE,COMME NT_NODE,DOCUMENT_NODE,DOCUMENT_TYPE_NODE,DOCUMENT_FRAGMENT_NODE,NOTATION _NODE,DOCUMENT_POSITION_DISCONNECTED,DOCUMENT_POSITION_PRECEDING,DOCUMEN T_POSITION_FOLLOWING,DOCUMENT_POSITION_CONTAINS,DOCUMENT_POSITION_CONTAI NED_BY,DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC,nodeType,nodeName,baseU RI,isConnected,ownerDocument,parentNode,parentElement,childNodes,firstCh ild,lastChild,previousSibling,nextSibling,nodeValue,textContent,hasChild Nodes,getRootNode,normalize,cloneNode,isEqualNode,isSameNode,compareDocu mentPosition,contains,lookupPrefix,lookupNamespaceURI,isDefaultNamespace ,insertBefore,appendChild,replaceChild,removeChild,addEventListener,remo veEventListener,dispatchEvent

这一坨输出还只是 Chrome 里一个 div 的属性名列表,几百个。如果每次状态变化都要在这样一个庞然大物上做增删改查,还要考虑各家浏览器的行为差异,代码很快就会变成一团乱麻。

所以换个思路,我不描述「怎么改」,我描述「现在应该长什么样」。用一个对象就够了:

{
sel: "div",
data: {},
children: undefined,
text: "Hello Virtual DOM",
elm: undefined,
key: undefined
}

六个字段,一个不多。sel 是选择器,data 装属性样式事件,childrentext 互斥(一个节点要么有子节点要么有文本),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)

虚拟 DOM 作为中间层,可以分别渲染到浏览器 DOM、服务端字符串、原生应用和小程序

跨端能跑通,靠的就是这层抽象。虚拟树本身不认识 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,零配置,最省事:

# 创建项目目录
mkdir snabbdom-demo
# 进入项目目录
cd snabbdom-demo
# 创建 package.json
yarn init -y
# 本地安装 parcel
yarn add parcel-bundler

配置 package.jsonscripts,一条起开发服务,一条出生产包:

"scripts": {
"dev": "parcel index.html --open",
"build": "parcel build index.html"
}

然后建一个最简单的目录结构,一个 index.html 挂个 #app,一个入口 js:

snabbdom-demo 项目的目录结构,index.html 与 src 入口文件

装库:

yarn add snabbdom

导入的写法是这样:

import { init, h, thunk } from 'snabbdom'

Snabbdom 的核心只提供最基本的功能,当年那个版本对外只导出三个东西:

  • init() 是一个高阶函数,返回 patch()
  • h() 返回虚拟节点 VNode,这个函数你在用 Vue 的时候见过
  • thunk() 是一种优化策略,处理不可变数据时可以用它跳过不必要的重算

h() 眼熟吧,Vue 2 的入口就长这样:

new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')

这里有个坑要注意,导入时不能写 import snabbdom from 'snabbdom'。原因在源码末尾:它用的是具名 export 导出 API,没有 export default,所以默认导入拿到的是 undefined

snabbdom 源码末尾使用具名 export 导出 API,没有 export default

顺带说一句版本的事。上面这套导入路径是 Snabbdom 早期版本(Vue 2 内联的那一版)的形态,后来的版本对导出结构做过调整,模块也改成了从主入口具名导出。你要是照着最新版跑,导入路径以官方 README 为准,本文保留原始写法是为了和 Vue 2 里的实现对得上。

先跑几个例子找感觉

例子1

import { h, init } from 'snabbdom'

// 1. hello world
// 参数:数组,模块
// 返回值:patch函数,作用对比两个vnode的差异更新到真实DOM
let patch = init([])
// 第一个参数:标签+选择器
// 第二个参数:如果是字符串的话就是标签中的内容
let vnode = h('div#container.cls', {
hook: {
init (vnode) {
console.log(vnode.elm)
},
create (emptyVnode, vnode) {
console.log(vnode.elm)
}
}
}, 'Hello World')

let app = document.querySelector('#app')
// 第一个参数:可以是DOM元素,内部会把DOM元素转换成VNode
// 第二个参数:VNode
// 返回值:VNde
let oldVnode = patch(app, vnode)

// 假设的时刻
vnode = h('div', 'Hello Snabbdom')

patch(oldVnode, vnode)

有三个点值得停一下。第一次 patch(app, vnode) 传的第一个参数是真实 DOM 元素,Snabbdom 内部会先把它包成一个空的 VNode,这样后面就能统一按 VNode 处理。patch 的返回值是新的 VNode,它必须被接住,因为下一次 patch 要拿它当旧节点。还有 hook 里的 initcreate,这是用户级钩子,创建 DOM 的过程中会被调用,打个 console.log(vnode.elm) 就能看到真实节点是什么时候挂上去的。

例子2

// 2. div中放置子元素 h1,p
import { h, init } from 'snabbdom'

let patch = init([])

let vnode = h('div#container', [
h('h1', 'Hello Snabbdom'),
h('p', '这是一个p标签')
])

let app = document.querySelector('#app')

let oldVnode = patch(app, vnode)

setTimeout(() => {
vnode = h('div#container', [
h('h1', 'Hello World'),
h('p', 'Hello P')
])
patch(oldVnode, vnode)

// 清空页面元素 -- 错误
// patch(oldVnode, null)
patch(oldVnode, h('!'))
}, 2000);

这个例子里藏着一个新手必踩的坑。想清空页面,第一直觉是 patch(oldVnode, null),跑起来会报错。为什么不行?因为 patch 内部一路都在读 vnode.selvnode.datavnode.children,传 null 进去直接就崩了。正确做法是 patch(oldVnode, h('!'))! 这个选择器在 Snabbdom 里代表注释节点,用一个空注释把原来的内容顶掉,DOM 上留下一个 <!----> 占位。Vue 里 v-if 为假时留下的那个注释节点,用的就是同一套逻辑。

例子3 debug-patchVnode

下面这三个例子建议真的打断点跑一遍,比读十遍源码管用。这个是最简单的一条路径,新旧节点的 sel 都是 div、都没有 key,判定为同一节点,走进 patchVnode,只有 text 不同,于是直接改 textContent

import { h, init } from 'snabbdom'

let patch = init([])

// 首次渲染
let vnode = h('div', 'Hello World')
let app = document.querySelector('#app')
let oldVnode = patch(app, vnode)

// patchVnode 的执行过程
vnode = h('div', 'Hello Snabbdom')
patch(oldVnode, vnode)

例子4 debug-updateChildren

这个例子把「视频」和「微博」换了个位置,三个 li 都没写 key。你猜 Snabbdom 会怎么改?它不会去移动节点,因为没有 key 的情况下同位置的 li 全都判定为同一节点,最后结果是第二个和第三个 li 的文本各被改写了一次:

import { h, init } from 'snabbdom'

let patch = init([])

// 首次渲染
let vnode = h('ul', [
h('li', '首页'),
h('li', '视频'),
h('li', '微博')
])
let app = document.querySelector('#app')
let oldVnode = patch(app, vnode)

// updateChildren 的执行过程
vnode = h('ul', [
h('li', '首页'),
h('li', '微博'),
h('li', '视频')
])
patch(oldVnode, vnode)

例子5 debug-updateChildren-key

同样的顺序调整,这次每个 li 带上了 key。行为完全变了,Snabbdom 认出 bc 只是换了位置,走的是 insertBefore 移动真实节点,一次文本改写都没有:

import { h, init } from 'snabbdom'

let patch = init([])

// 首次渲染
let vnode = h('ul', [
h('li', { key: 'a' }, '首页'),
h('li', { key: 'b' }, '视频'),
h('li', { key: 'c' }, '微博')
])
let app = document.querySelector('#app')
let oldVnode = patch(app, vnode)

// updateChildren 的执行过程
vnode = h('ul', [
h('li', { key: 'a' }, '首页'),
h('li', { key: 'c' }, '微博'),
h('li', { key: 'b' }, '视频')
])
patch(oldVnode, vnode)

模块,Snabbdom 保持小巧的关键

跑完上面五个例子你可能已经发现了,核心库压根不处理元素的属性、样式和事件。这不是遗漏,是刻意的设计。核心只负责「建节点、比差异、落 DOM」这条主线,其它一切通过模块往里插。

官方提供了 6 个模块,各管一类 DOM 特性:

模块 职责 要点
attributes setAttribute() 设置属性 会处理布尔类型属性(如 disabled
props element[attr] = value 设置属性 不处理布尔类型属性
class 切换类样式 注意它做的是「切换」,初始类名一般写在 sel 选择器里
dataset 设置 data-* 自定义属性 对应 element.dataset
eventlisteners 注册和移除事件 通过 on 字段传入
style 设置行内样式,支持动画 额外提供 delayed / remove / destroy 三种时机

attributesprops 这对经常有人搞混。区别在于一个走 HTML 特性,一个走 DOM 属性。以 input 的值为例,setAttribute('value', x) 改的是初始值,el.value = x 改的是当前值,两者在用户输入过之后会不一致。Vue 模板里那些 :value:checked 最终落到哪一边,判断依据也是同一套。

模块使用分三步:

  1. 导入需要的模块
  2. init() 中注册模块
  3. h() 创建 VNode 时把第二个参数写成对象,模块需要的数据放在对应字段里,其它参数往后移
import { init, h } from 'snabbdom'
// 1. 导入模块
import style from 'snabbdom/modules/style'
import eventlisteners from 'snabbdom/modules/eventlisteners'
// 2. 注册模块
let patch = init([
style,
eventlisteners
])
// 3. 使用 h() 函数的第二个参数传入模块需要的数据(对象)
let vnode = h('div', {
style: {
backgroundColor: 'red'
},
on: {
click: eventHandler
}
}, [
h('h1', 'Hello Snabbdom'),
h('p', '这是p标签')
])

function eventHandler () {
console.log('点击我了')
}

let app = document.querySelector('#app')

let oldVnode = patch(app, vnode)


vnode = h('div', 'hello')
patch(oldVnode, vnode)

最后那两行值得留意。新的 vnode 只有文本没有 children,也没有 styleon 字段,patch 之后原来的 h1p 会被整体移除,绑上去的 click 事件也会被 eventlisteners 模块在 update 钩子里清掉。模块不只负责「加」,也负责「减」,这是它必须挂在 patch 生命周期里而不是随便写个工具函数的原因。

需要说明的是,模块的导入路径在后续版本里改过,新版从主入口具名导出。上面这份写法对应的是 Vue 2 内联的那个时期,读源码时按你手上的版本对照 README 就行。

四、源码主线,从 h 到 patch

整条链路只有四步

  • h() 创建 JavaScript 对象(VNode)描述真实 DOM
  • init() 注册模块,产出 patch()
  • patch() 比较新旧两个 VNode
  • 把变化的内容更新到真实 DOM 树上

源码地址在 https://github.com/snabbdom/snabbdom ,src 目录长这样:

Snabbdom 源码 src 目录结构,包含 h.ts、vnode.ts、init 与 modules 目录

文件不多,核心就是 h.tsvnode.ts 和 init 所在的那个文件,剩下的是各个模块和工具函数。下面按调用顺序一个个看。

h 函数

h() 最早见于 hyperscript,那个库用 JavaScript 创建超文本。Snabbdom 借了这个名字,但做的事不是创建超文本,而是创建 VNode。

先聊聊它的重载。所谓重载就是参数个数或类型不同的同名函数,JavaScript 里没有这个概念,写两个同名函数后一个会覆盖前一个:

function add (a, b) { 
console.log(a + b)
}
function add (a, b, c) {
console.log(a + b + c)
}
add(1, 2)
add(1, 2, 3)

上面这段跑出来两次都是三个数的版本在生效,add(1, 2) 会打印 NaN。TypeScript 有重载的语法,但那只是给类型系统看的声明,运行时依然是一个函数体,靠手动判断参数来分流。

Snabbdom 的 h() 就是这么干的。源码位置 src/h.ts

// h函数的重载
export function h (sel: string): VNode
export function h (sel: string, data: VNodeData | null): VNode
export function h (sel: string, children: VNodeChildren): VNode
export function h (sel: string, data: VNodeData | null, children: VNodeChildren): VNode
export function h (sel: any, b?: any, c?: any): VNode {
let data: VNodeData = {}
let children: any
let text: any
let i: number
// 处理参数,实现重载的机制
if (c !== undefined) {
// 处理三个参数的情况
// sel、data、children/text
if (b !== null) {
data = b
}
if (is.array(c)) {
children = c
// 如果 c 是字符串或者数字
} else if (is.primitive(c)) {
text = c
} else if (c && c.sel) {
children = [c]
}
} else if (b !== undefined && b !== null) {
// 处理两个参数的情况
if (is.array(b)) {
children = b
// 如果 b 是字符串或者数字
} else if (is.primitive(b)) {
text = b
// 如果 b 是 VNode
} else if (b && b.sel) {
children = [b]
} else { data = b }
}
if (children !== undefined) {
// 处理 children 中的原始值(string/number)
for (i = 0; i < children.length; ++i) {
// 如果 child 是 string/number,创建文本节点
if (is.primitive(children[i])) children[i] = vnode(undefined, undefined, undefined, children[i], undefined)
}
}
if (
sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g' &&
(sel.length === 3 || sel[3] === '.' || sel[3] === '#')
) {
// 如果是 svg,添加命名空间
addNS(data, children, sel)
}
// 返回 VNode
return vnode(sel, data, children, text, undefined)
};

// 导出模块
export default h;

前面那四行 export function h (...) 全是 TypeScript 的重载签名,没有函数体,真正干活的是最后那个 h (sel: any, b?: any, c?: any)。函数体里做的事情很朴素,先看有没有第三个参数,有就说明是 (sel, data, children/text) 的形态;没有第三个参数就看第二个参数是数组、是原始值、还是 VNode、还是普通对象,分别落到 childrentextchildrendata 上。

中间那段 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 {
props?: Props
attrs?: Attrs
class?: Classes
style?: VNodeStyle
dataset?: Dataset
on?: On
hero?: Hero
attachData?: AttachData
hook?: Hooks
key?: Key
ns?: string // for SVGs
fn?: () => VNode // for thunks
args?: any[] // for thunks
is?: string // for custom elements v1
[key: string]: any // for any other 3rd party module
}

export interface VNode {
// 选择器
sel: string | undefined
// 节点数据:属性/样式/事件等
data: VNodeData | undefined
// 子节点,和 text 只能互斥
children: Array<VNode | string> | undefined
// 记录 vnode 对应的真实 DOM
elm: Node | undefined
// 节点中的内容,和 children 只能互斥
text: string | undefined
// 优化用
key: Key | undefined
}

export function vnode (sel: string | undefined,
data: any | undefined,
children: Array<VNode | string> | undefined,
text: string | undefined,
elm: Element | Text | undefined): VNode {
const key = data === undefined ? undefined : data.key
return { sel, data, children, text, elm, key }
}

vnode() 这个工厂函数本身没什么内容,值得看的是那六个字段的分工。selkey 是身份证,diff 判断「这两个节点是不是同一个」只看这两项。data 是给模块用的数据袋,你在 h() 第二个参数里写的 styleonattrs 全在这儿。childrentext 互斥,一个节点不能既有子节点又有文本。elm 是虚拟世界和真实世界之间的那根绳子,patch 的时候要靠它找到该改哪个真实节点。

VNodeData 接口最后那行 [key: string]: any 是留给第三方模块的口子,你自己写个模块塞个新字段进去,类型检查也不会拦你。

patch 的整体走向

patch(oldVnode, newVnode) 做的事就是打补丁:把新节点中变化的内容渲染到真实 DOM,最后返回新节点,作为下一次处理时的旧节点。

它的判断顺序是这样的:

  • 先对比新旧 VNode 是不是同一个节点,判断依据只有两项,keysel 相同
  • 如果不是同一个节点,不做任何比较,直接照新节点建一棵新 DOM 插进去,再把旧的删掉
  • 如果是同一个节点,看新 VNode 有没有 text,有并且和旧的不同,直接更新文本内容
  • 如果新 VNode 有 children,就得判断子节点有没有变化,这个判断过程用的就是 diff 算法
  • diff 只做同层级比较,不跨层

patch 的判断流程图,先判断是否同一节点,再分别走重建与 patchVnode 两条路径

第二条要单独强调一下。很多人以为 diff 会努力复用,其实不会。只要 selkey 有一个对不上,整棵子树直接丢弃重建,一点都不心疼。这是拿「可能多建几个节点」换「不用做跨层匹配」,因为跨层匹配的代价高得离谱,下面讲 updateChildren 的时候会算这笔账。

init

init(modules, domApi) 返回的是 patch() 函数,这是个典型的高阶函数。

为什么非要绕这一层?因为 patch() 在外部会被调用很多次,每次调用都依赖 modulesdomApicbs 这几个东西。用高阶函数在 init() 内部形成闭包,返回的 patch() 就能一直访问到这些变量,不用每次重新组装。你也可以理解成一次性的依赖注入,注入完之后拿到的是一个已经配置好的 patch。

init() 在返回 patch() 之前干了一件正事:把所有模块里的钩子函数按类型收集到 cbs 对象里。源码位置 src/init.ts

export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {
let i: number
let j: number
const cbs: ModuleHooks = {
create: [],
update: [],
remove: [],
destroy: [],
pre: [],
post: []
}

const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi

for (i = 0; i < hooks.length; ++i) {
cbs[hooks[i]] = []
for (j = 0; j < modules.length; ++j) {
const hook = modules[j][hooks[i]]
if (hook !== undefined) {
(cbs[hooks[i]] as any[]).push(hook)
}
}
}
...

return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node
const insertedVnodeQueue: VNodeQueue = []
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()

if (!isVnode(oldVnode)) {
oldVnode = emptyNodeAt(oldVnode)
}

if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode, insertedVnodeQueue)
} else {
elm = oldVnode.elm!
parent = api.parentNode(elm) as Node

createElm(vnode, insertedVnodeQueue)

if (parent !== null) {
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
removeVnodes(parent, [oldVnode], 0, 0)
}
}

for (i = 0; i < insertedVnodeQueue.length; ++i) {
insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i])
}
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()
return vnode
}
}

那个双重 for 循环就是在做钩子收集。外层遍历六种钩子类型(createupdateremovedestroyprepost),内层遍历你注册的每个模块,把模块上同名的钩子函数塞进 cbs[类型] 数组。收集完之后,patch 里要触发某类钩子只需要遍历一个扁平数组,不用每次再去各个模块上取。

domApi 这个参数也有意思。默认用的是 htmlDomApi,一套针对浏览器 DOM 的操作封装。但它是可替换的,你传一套面向别的宿主环境的实现进去,Snabbdom 就能把虚拟树渲染到别的地方去。前面说的跨端渲染,落到代码上就是这个口子。

patch

patch 的职责很明确:传入新旧 VNode,对比差异,把差异渲染到 DOM,然后返回新的 VNode 作为下一次 patch()oldVnode

完整的执行过程是这样的:

  • 首先执行模块中的 pre 钩子函数
  • 判断 oldVnode 是不是一个 VNode。首次渲染时传进来的是真实 DOM 元素,这时用 emptyNodeAt() 把它包成一个空的 VNode,后面就能统一处理
  • 如果 oldVnodevnode 是同一节点(keysel 都相同),调用 patchVnode() 找差异并更新 DOM
  • 如果不是同一节点,走重建流程:先拿到旧节点对应的真实 DOM 和它的父节点,调用 createElm() 把新 vnode 转换成真实 DOM 并记到 vnode.elm,把新建的 DOM 插到旧节点后面,再移除旧节点
  • 遍历 insertedVnodeQueue,执行用户设置的 insert 钩子函数
  • 最后执行模块的 post 钩子函数

源码位置 src/snabbdom.ts

return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node
// 保存新插入节点的队列,为了触发钩子函数
const insertedVnodeQueue: VNodeQueue = []
// 执行模块的 pre 钩子函数
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()

// 如果 oldVnode 不是 VNode,创建 VNode 并设置 elm
if (!isVnode(oldVnode)) {
// 把 DOM 元素转换成空的 VNode
oldVnode = emptyNodeAt(oldVnode)
}
// 如果新旧节点是相同节点(key 和 sel 相同)
if (sameVnode(oldVnode, vnode)) {
// 找节点的差异并更新 DOM
patchVnode(oldVnode, vnode, insertedVnodeQueue)
} else {
// 如果新旧节点不同,vnode 创建对应的 DOM
// 获取当前的 DOM 元素
elm = oldVnode.elm!
parent = api.parentNode(elm) as Node
// 触发 init/create 钩子函数,创建 DOM
createElm(vnode, insertedVnodeQueue)

if (parent !== null) {
// 如果父节点不为空,把 vnode 对应的 DOM 插入到文档中
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
// 移除老节点
removeVnodes(parent, [oldVnode], 0, 0)
}
}
// 执行用户设置的 insert 钩子函数
for (i = 0; i < insertedVnodeQueue.length; ++i) {
insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i])
}
// 执行模块的 post 钩子函数
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()
// 返回 vnode
return 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 {
let i: any
let data = vnode.data
if (data !== undefined) {
// 执行用户设置的 init 钩子函数
const init = data.hook?.init
if (isDef(init)) {
init(vnode)
data = vnode.data
}
}
const children = vnode.children
const sel = vnode.sel
if (sel === '!') {
// 如果选择器是!,创建评论节点
if (isUndef(vnode.text)) {
vnode.text = ''
}
vnode.elm = api.createComment(vnode.text!)
} else if (sel !== undefined) {
// Parse selector
// 如果选择器不为空
// 解析选择器
// Parse selector
const hashIdx = sel.indexOf('#')
const dotIdx = sel.indexOf('.', hashIdx)
const hash = hashIdx > 0 ? hashIdx : sel.length
const dot = dotIdx > 0 ? dotIdx : sel.length
const tag = hashIdx !== -1 || dotIdx !== -1 ? sel.slice(0, Math.min(hash, dot)) : sel
const elm = vnode.elm = isDef(data) && isDef(i = data.ns)
? api.createElementNS(i, tag, data)
: api.createElement(tag, data)
if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot))
if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/\./g, ' '))
// 执行模块的 create 钩子函数
for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode)
// 如果 vnode 中有子节点,创建子 vnode 对应的 DOM 元素并追加到 DOM 树上
if (is.array(children)) {
for (i = 0; i < children.length; ++i) {
const ch = children[i]
if (ch != null) {
api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue))
}
}
} else if (is.primitive(vnode.text)) {
// 如果 vnode 的 text 值是 string/number,创建文本节点并追加到 DOM 树
api.appendChild(elm, api.createTextNode(vnode.text))
}
const hook = vnode.data!.hook
if (isDef(hook)) {
// 执行用户传入的钩子 create
hook.create?.(emptyNode, vnode)
if (hook.insert) {
// 把 vnode 添加到队列中,为后续执行 insert 钩子做准备
insertedVnodeQueue.push(vnode)
}
}
} else {
// 如果选择器为空,创建文本节点
vnode.elm = api.createTextNode(vnode.text!)
}
// 返回新创建的 DOM
return vnode.elm
}

解析选择器那几行是纯字符串切分,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.childrenvnode.children 都有值
      • 调用 updateChildren()
      • 使用 diff 算法对比子节点,更新子节点
    • 如果 vnode.children 有值, oldVnode.children 无值
      • 清空 DOM 元素
      • 调用 addVnodes() ,批量添加子节点
    • 如果 oldVnode.children 有值, vnode.children 无值
      • 调用 removeVnodes() ,批量移除子节点
    • 如果 oldVnode.text 有值
      • 清空 DOM 元素的内容
    • 如果设置了 vnode.text 并且和 oldVnode.text 不相等
      • 如果老节点有子节点,全部移除
      • 设置 DOM 元素的 textContentvnode.text
    • 最后执行用户设置的 postpatch 钩子函数
function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
const hook = vnode.data?.hook
// 首先执行用户设置的 prepatch 钩子函数
hook?.prepatch?.(oldVnode, vnode)
const elm = vnode.elm = oldVnode.elm!
const oldCh = oldVnode.children as VNode[]
const ch = vnode.children as VNode[]
// 如果新老 vnode 相同返回
if (oldVnode === vnode) return
if (vnode.data !== undefined) {
// 执行模块的 update 钩子函数
for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
// 执行用户设置的 update 钩子函数
vnode.data.hook?.update?.(oldVnode, vnode)
}
// 如果 vnode.text 未定义
if (isUndef(vnode.text)) {
// 如果新老节点都有 children
if (isDef(oldCh) && isDef(ch)) {
// 使用 diff 算法对比子节点,更新子节点
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
} else if (isDef(ch)) {
// 如果新节点有 children,老节点没有 children
// 如果老节点有text,清空dom 元素的内容
if (isDef(oldVnode.text)) api.setTextContent(elm, '')
// 批量添加子节点
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
// 如果老节点有children,新节点没有children
// 批量移除子节点
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
// 如果老节点有 text,清空 DOM 元素
api.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
// 走到这里说明设置了 vnode.text,并且和老的文本不一样
if (isDef(oldCh)) {
// 如果老节点有 children,移除
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
}
// 设置 DOM 元素的 textContent 为 vnode.text
api.setTextContent(elm, vnode.text!)
}
// 最后执行用户设置的 postpatch 钩子函数
hook?.postpatch?.(oldVnode, vnode)
}

五、updateChildren,diff 算法的核心

终于到最硬的一块了。updateChildren 干的事只有一句话:对比新旧节点的 children,把差异落到 DOM。但它是整个虚拟 DOM 里最值得琢磨的一段代码。

先算一笔账,为什么只比同层

要对比两棵树的差异,最直觉的做法是拿第一棵树的每个节点,依次和第二棵树的每个节点比一遍,再算出最小编辑距离。这个通用树 diff 的复杂度是 O(n^3),一千个节点就是十亿次操作,页面还没渲染完人已经走了。

那怎么办?做一个假设。在真实的 DOM 操作里,我们极少会把一个父节点移动成某个子节点,跨层级的节点搬家几乎不发生。既然如此,跨层比较的那部分收益可以直接放弃。

于是只找同级别的子节点依次比较,然后再找下一级别的节点比较,复杂度降到 O(n)

diff 只做同层比较,同色框表示同一层级的节点在各自层内两两对应

代价是什么?如果你真的把一个节点从第二层挪到了第三层,diff 不会认出这是「移动」,它会在旧位置删掉、在新位置重建。这是用一个不常见场景的性能,换所有常见场景的性能,很划算的交易。

双端比较,四种命中情况

同级别节点比较的时候,Snabbdom 给新老两个数组的开始和结尾各设一个标记索引,一共四个,遍历过程中往中间收拢。

每一轮循环,先按顺序试这四种组合:

  • oldStartVnode / newStartVnode(旧开始节点 / 新开始节点)
  • oldEndVnode / newEndVnode(旧结束节点 / 新结束节点)
  • oldStartVnode / newEndVnode(旧开始节点 / 新结束节点)
  • oldEndVnode / newStartVnode(旧结束节点 / 新开始节点)

双端比较的四种组合示意,新老数组各有开始与结束两个索引

为什么是这四种而不是全排列?因为它们覆盖了实际业务里最高频的几种变化:列表尾部追加、列表头部插入、整体反转、以及首尾互换。命中任何一种,这一轮就能只做一次 patchVnode 加最多一次 DOM 移动。

第一种和第二种,头对头、尾对尾

如果 oldStartVnodenewStartVnodesameVnodekeysel 相同):

  • 调用 patchVnode() 对比和更新这两个节点
  • 把旧开始和新开始索引一起往后移动,oldStartIdx++ / newStartIdx++

旧开始节点与新开始节点匹配,patchVnode 之后两个开始索引同时右移

尾对尾同理,oldEndIdx-- / newEndIdx--。这两种情况都不需要移动 DOM,因为节点的相对位置本来就没变。列表末尾加一条数据走的就是这条路径,前面 n 个全部头对头命中,最后剩一个新节点走收尾逻辑插进去。

第三种,旧开始对新结束

oldStartVnodenewEndVnode 相同,说明原来排在最前面的节点跑到最后去了:

  • 调用 patchVnode() 对比和更新节点
  • oldStartVnode 对应的真实 DOM 元素移动到右边,具体是插到 oldEndVnode 对应 DOM 的下一个兄弟之前
  • 更新索引,oldStartIdx++ / newEndIdx--

旧开始节点与新结束节点匹配,对应的真实 DOM 被移动到右侧

第四种,旧结束对新开始

oldEndVnodenewStartVnode 相同,说明原来在最后的节点跑到最前面了:

  • 调用 patchVnode() 对比和更新节点
  • oldEndVnode 对应的真实 DOM 元素移动到左边,插到 oldStartVnode 对应 DOM 之前
  • 更新索引,oldEndIdx-- / newStartIdx++

旧结束节点与新开始节点匹配,对应的真实 DOM 被移动到左侧

这两种交叉命中是双端比较最漂亮的地方。数组整体反转的场景,靠它们能在一轮轮循环里全部消化掉,一次都不用去查找。

四种都不命中怎么办

那就只能老老实实查找了:

  • 遍历老节点数组,用 newStartVnodekey 去找有没有 key 相同的老节点
  • 如果没找到,说明 newStartVnode 是全新的节点,创建对应的 DOM 元素插到 oldStartVnode 对应 DOM 之前,然后 newStartIdx++
  • 如果找到了
    • 再判断新节点和找到的老节点的 sel 选择器是否相同
    • 如果不相同,说明这个位置的节点被换成了别的标签,重新创建 DOM 插进去
    • 如果相同,调用 patchVnode() 更新,然后把 elmToMove 对应的 DOM 元素移动到左边,并把老数组里那一项置为 undefined,防止后面重复处理

四种情况都不命中时,用 key 建立索引表在老节点数组中查找并移动

这里就是 key 的价值所在。没有 key 的时候这条查找路径根本走不通,diff 只能退化成按位置逐个比较。回到前面例子 4 和例子 5 的差别:不带 key,「视频」和「微博」换位置变成了两次文本改写;带了 key,变成一次节点移动。前者看着好像也没慢多少,但如果 li 里面是个带内部状态的组件,或者是个用户正在输入的 input,按位置复用就会把状态串到错误的行上去。

这也是「别拿数组 index 当 key」的完整理由。index 是位置,不是身份。往列表头部插一条数据,所有元素的 index 全变了,key 跟着变,diff 判定为「每一项都不是同一个节点」,等于全部重建,还不如不写 key。真要写就写数据本身的稳定 id。

循环怎么结束

循环的终止条件是 oldStartIdx > oldEndIdx 或者 newStartIdx > newEndIdx,也就是任何一个数组先被遍历完。跳出循环之后还得收尾。

如果老节点数组先遍历完(oldStartIdx > oldEndIdx),说明新节点有剩余,把 newStartIdxnewEndIdx 之间剩下的节点批量创建并插入:

老节点先遍历完,新数组剩余的节点被批量插入到右侧

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

新节点先遍历完,老数组剩余的节点被批量删除

批量删除那一步要留意,中间可能有被置为 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,核心字段就六个,selkey 是身份,data 装模块数据,childrentext 互斥,elm 连接虚拟与真实
  • 它解决的是状态跟踪问题,性能是顺带的收益。真正划算的场景是「结构复杂 + 单次变更局部」
  • 中间隔了一层描述,所以同一棵虚拟树能渲染到浏览器、服务端、原生控件和小程序
  • Snabbdom 核心只做建节点、比差异、落 DOM,属性样式事件全靠六个模块插进来,init 用高阶函数把模块和 domApi 闭包进 patch
  • patch 的第一个判断是「是不是同一节点」,只看 keysel,不同就整棵子树重建,绝不尝试复用
  • patchVnode 处理同一节点内部的差异,updateChildren 处理子节点列表的差异
  • diff 只做同层比较,把 O(n^3) 压到 O(n),代价是放弃识别跨层移动
  • 双端比较先试四种组合(头头、尾尾、旧头新尾、旧尾新头),都不中才用 key 去老数组里查找
  • key 的全部意义就在最后那条查找路径上。用 index 当 key 等于没有身份信息,diff 退化成按位置复用

我的建议是把例子 4 和例子 5 都打上断点跑一遍,看着 oldStartIdxnewStartIdx 一步步往中间收,比看十张图都直观。这块跟 Vue 2 的响应式是配套的,数据怎么变成一次重新渲染,我在 Vue响应式原理模拟 手写一个迷你版VueVue响应式原理从defineProperty到Proxy 这两篇里拆过,串起来看链路就完整了。

参考