# snabbdom 简介
snabbdom 是瑞典语单词,原意"速度",是著名的虚拟 DOM 库, 是 diff 算法的鼻祖,Vue 源码借鉴了 snabbdom
npm i snabbdom -D
# 虚拟 DOM 和 h 函数
用 JavaScript 对象描述 DOM 的层次结构。 DOM 中的一切属性都在虚拟 DOM 中有对应的属性。

diff 是发生在虚拟 DOM 上的,新虚拟DOM 和老虚拟 DOM 进行 diff (精细化比较), 算出应该如何最小量更新,最后反映在真正的 DOM 上。

# 虚拟 DOM 如何被渲染函数(h函数)产生?
h 函数用来产生虚拟节点(vnode)
比如这样调用 h 函数:
h('a', { props: { href: 'http://www.baidu.com' } }, '江小鱼')
此时将得到这样的虚拟节点:
{
"sel": "a",
"data": {
props: {
href: 'http://www.baidu.com'
}
},
"text": "江小鱼"
}
2
3
4
5
6
7
8
9

虚拟节点有哪些属性:
{
"children": undefined, // 子元素 ,undefined 表示没有子元素
"data": {}, // 存放属性和样式等
"elm": undefined, // 对应真正的DOM, undefined表示这个虚拟DOM没上树
"key": undefined, // 当前节点唯一标识
"sel": "div", // 选择器
"text": "我是一个div盒子" // 文本内容
}
2
3
4
5
6
7
8
真正的 DOM 节点:

h 函数并不会在页面上真正产生标签,可以输出虚拟节点。
// index.js
import {
init,
classModule,
propsModule,
styleModule,
eventListenersModule,
h,
} from "snabbdom";
// 创建patch 函数 让虚拟节点上树
const patch = init([
classModule,
propsModule,
styleModule,
eventListenersModule
]);
// 创建虚拟节点
let myVnode1 = h(
'a',
{
props: {
href: 'https://www.baidu.com'
}
},
'江小鱼'
)
console.log(myVnode1, '虚拟节点')
const container = document.getElementById('container')
// 让虚拟节点上树
patch(container, myVnode1)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
h 函数可以嵌套使用,从而得到虚拟 DOM 树

h 函数的灵活传参

# h 函数的实现
只实现主要功能,放弃实现一些细节操作。
源码中 h 函数返回一个 vnode 函数

vnode 函数则返回一个包含 sel, data, children, text, elm, key 的对象

h 函数在不考虑重载的情况下,必须传3个参数的情况被调用的方式
h('div', {}, [])
h('div', {}, '文字')
h('div', {}, h())
2
3
# vnode 函数
// vnode 函数就是把传入的 5个参数组合成对象返回
export default function (sel, data, children, text, elm) {
return {
sel,
data,
children,
text,
elm
}
}
2
3
4
5
6
7
8
9
10
11
# h 函数的调用方式
默认规定必须传递 3 个参数,那么此时 h 函数就会有 3 种形态的调用方式
h(sel, data, c)
- 第一种形态 c 是文字或者数子
let myVnode1 = h('div', {}, '文字')
- 第二种形态 c 是数组
let myVnode2 = h('div', {}, [
h('p', {}, '西瓜'),
h('p', {}, '香蕉'),
h('p', {}, [
h('span', {}, '土豆1'),
h('span', {}, '土豆2')
])
])
2
3
4
5
6
7
8
- 第三种形态 c 是 h()
let myVnode3 = h('div', {}, h('p', {}, '冬瓜'))
完整代码
// h.js
import vnode from './vnode'
// 不考虑重载功能,必须传递3个参数,缺一不可
// 形态1 h('div', {}, '文字')
// 形态2 h('div', {}, [])
// 形态3 h('div', {}, h())
export default function (sel, data, c) {
// 检查参数个数
if (arguments.length !== 3) {
throw new Error('参数个数不对');
}
if (typeof c == 'string' || typeof c == 'number') {
// 形态1
return vnode(sel, data, undefined, c, undefined)
} else if (Array.isArray(c)) {
// 形态2
let children = []
// 遍历 c
for (let i = 0; i < c.length; i++) {
// 检查 c[i] 必须是一个对象,同时必须有 sel
if (!(typeof c[i] == 'object') && c[i].hasOwnProperty('sel')) {
// 不满足
throw new Error('传入的数组中,有项不是h函数')
}
// 此处是不需要执行c[i]的,外界执行
// 收集调用的结果
children.push(c[i])
}
// children 收集完毕,返回虚拟节点
// 不考虑 文本和标签同时都有的情况,只考虑只存在标签情况
return vnode(sel, data, children, undefined, undefined)
} else if (typeof c == 'object' && c.hasOwnProperty('sel')) {
// 形态3 h 函数被调用, 返回 vnode 对象
// 传入的 c 是唯一的 children
let children = [c]
return vnode(sel, data, children, undefined, undefined)
} else {
throw new Error('第3个参数不对')
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# diff 算法原理
# 体验 diff 如何更新
首先创建一个 myVnode1 点击按钮后更新为 myVnode2
import {
init,
classModule,
propsModule,
styleModule,
eventListenersModule,
h,
} from "snabbdom";
// 创建patch 函数 让虚拟节点上树
const patch = init([
classModule,
propsModule,
styleModule,
eventListenersModule
]);
// 创建虚拟节点
let myVnode1 = h('ul', {}, [
h('li', {}, 'A'),
h('li', {}, 'B'),
h('li', {}, 'C'),
h('li', {}, 'D')
])
console.log(myVnode1, '虚拟节点')
const container = document.getElementById('container')
const btn = document.getElementById('btn')
patch(container, myVnode1)
let myVnode2 = h('ul', {}, [
h('li', {}, 'A'),
h('li', {}, 'B'),
h('li', {}, 'C'),
h('li', {}, 'D'),
h('li', {}, 'E')
])
// 点击按钮后,将vnode1 变成 vnode2
btn.onclick = function () {
patch(myVnode1, myVnode2)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

由上面演示效果可以看出,每次点击按钮后,都追在最后追加一个E的 DOM 节点, 内部使用的是最小量更新, 其他节点是没有变化的。
此时可能会有两个疑问
- 前面的
A B C D是否有被更新 - 如果在最前面添加
E其他节点怎么样
虚拟节点
let myVnode2 = h('ul', {}, [
h('li', {}, 'E'),
h('li', {}, 'A'),
h('li', {}, 'B'),
h('li', {}, 'C'),
h('li', {}, 'D'),
])
2
3
4
5
6
7
8
针对第一个疑问,在页面上手动将A节点改成丑八怪,此时点击按钮如果丑八怪重新变成了A则说明节点是有更新的,如果没有变化则说明在点击按钮的时候仅仅追加E节点。
如图可见是仅仅追加了E

第二个疑问,在前面添加E,那DOM节点是在最前面的A直接添加E其他节点不变,
还是在最后的D节点添加E之后,把A变成E B变成A C变成B D变成C E变成D

依然采用手动修改DOM节点内容进行验证,如图可见是采用的形式2方式进行更新的。

为什么不是形式1方式更新呢,形式1比形式2明显效率会高,这是因为没有追加唯一标识key的原因,key 是唯一标识。
修改下虚拟节点代码在看效果
let myVnode2 = h('ul', {}, [
h('li', {key: 'E'}, 'E'),
h('li', {key: 'A'}, 'A'),
h('li', {key: 'B'}, 'B'),
h('li', {key: 'C'}, 'C'),
h('li', {key: 'D'}, 'D')
])
2
3
4
5
6
7

- 只有是同一个虚拟节点,才进行精细化比较
虚拟节点
myVnode1外层标签是 ul , 虚拟节点myVnode2外层标签是 ol 。 此时在更新的时候,并不是把ul直接变成ol,其他不变。 而是暴力删除,重新更新。
let myVnode1 = h('ul', {}, [
h('li', {key: 'A'}, 'A'),
h('li', {key: 'B'}, 'B'),
h('li', {key: 'C'}, 'C'),
h('li', {key: 'D'}, 'D')
])
2
3
4
5
6
7
let myVnode2 = h('ol', {}, [
h('li', {key: 'A'}, 'A'),
h('li', {key: 'B'}, 'B'),
h('li', {key: 'C'}, 'C'),
h('li', {key: 'D'}, 'D')
])
2
3
4
5
6

- 只进行同层比较,不会进行跨层比较 。
myVnode1和myVnode2虽然内容相同都是A B C D,myVnode2外层还有一个section
let myVnode1 = h('ul', {}, [
h('p', {key: 'A'}, 'A'),
h('p', {key: 'B'}, 'B'),
h('p', {key: 'C'}, 'C'),
h('p', {key: 'D'}, 'D')
])
2
3
4
5
6
let myVnode2 = h('ol', {}, h('section', {}, [
h('p', {key: 'A'}, 'A'),
h('p', {key: 'B'}, 'B'),
h('p', {key: 'C'}, 'C'),
h('p', {key: 'D'}, 'D')
]))
2
3
4
5
6
7

# diff 算法体验心得
- 最小量更新 key 很重要是唯一标识告诉diff算法,在更改前后他们是同一个DOM节点。
- 只有是同一个虚拟节点,才进行精细化比较,否则就是暴力删除旧的,插入新的。(如何定义是同一个虚拟节点?选择器相同且key相同)
- 只进行同层比较,不会进行跨层比较。即使是同一片虚拟节点,但是跨层了,精细化比较不diff。会暴力删除旧的,然后插入新的。
# diff 处理新旧节点不是同一个节点时
snabbdom 的 init 函数调用有会返回一个 patch 函数,而path的作用是处理 vnode。

// init.ts 截取片段
// 是否是元素节点
if (isElement(api, oldVnode)) {
oldVnode = emptyNodeAt(oldVnode);
} else if (isDocumentFragment(api, oldVnode)) {
// 是否是 DocumentFragment 节点
oldVnode = emptyDocumentFragmentAt(oldVnode);
}
2
3
4
5
6
7
8
首先判断当前 oldVnode 是否是元素,如果是元素的需要转成vnode
// init.ts 截取片段
function isElement(
api: DOMAPI,
vnode: Element | DocumentFragment | VNode
): vnode is Element {
return api.isElement(vnode as any);
}
2
3
4
5
6
7
// htmldomapi.ts
function isElement(node: Node): node is Element {
return node.nodeType === 1;
}
2
3
4
函数 isElement 是根据节点的nodeType 的值来判断当前节点是哪个类型的节点。
nodeType有12种不同的节点类型,如图。其中1 3 8 11是需要重点关注的
1代表元素3代表元素或属性中的文本内容8代表注释11代表轻量级的 Document 对象,能够容纳文档的某个部分

如果是元素则需要通过emptyNodeAt生成vnode

如果是documentFragment则需要通过emptyDocumentFragmentAt生成vnode

判断完 isElement 和 isDocumentFragment 后,oldVnode 会得到虚拟节点。此时在判断是否是同一个 vnode。
如果是的话,则进行精细化比较,如果不是则暴力删除老的节点进而重新生成新的节点。
流程图如下:
图 1-1
# 如何定义"同一个节点"
源码中是根据sameVnode函数来进行判断,判断节点的 key 相同且 sel 相同
// init.ts 片段截取
function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
const isSameKey = vnode1.key === vnode2.key;
const isSameIs = vnode1.data?.is === vnode2.data?.is;
const isSameSel = vnode1.sel === vnode2.sel;
return isSameSel && isSameKey && isSameIs;
}
2
3
4
5
6
7
8
如果不是同一个节点,则删除老节点,创建新节点,这个过程是需要递归操作。

# 虚拟 DOM 如何通过 diff 变成真正的 DOM
import h from './mysnabbdom/h'
import patch from './mysnabbdom/patch'
// 形态1
let myVnode1 = h('div', {}, '你好')
console.log(myVnode1, 'myVnode1')
const container = document.getElementById('container')
patch(container, myVnode1)
2
3
4
5
6
7
8
9
# patch 函数
- oldVnode 是虚拟节点还是DOM节点
根据图1-1 ,path函数的流程,首先先判断当前节点是否是元素标签,如果是则转成虚拟节点
import vnode from "./vnode";
export default function (oldVnode, newVnode) {
// 判断传入的第一个参数是DOM节点还是虚拟节点
if(oldVnode.sel == '' || oldVnode.sel == undefined) {
// 传入的第一个参数是DOM节点,此时需要包装为虚拟节点
oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode)
}
console.log(oldVnode, 'oldVnode虚拟-A')
}
2
3
4
5
6
7
8
9

# oldVnode 和 newVnode 是不是同一个节点(不是同一个节点,暴力删除旧的,生成新的)
// 判断oldVnode 和 newVnode 是不是同一个节点
if (oldVnode.key == newVnode.key && oldVnode.sel == newVnode.sel) {
console.log('是同一个节点-A')
} else {
console.log('不是同一个节点,暴力插入新的,删除旧的')
console.log(newVnode, 'newVnode-B')
createElement(newVnode, oldVnode.elm)
}
2
3
4
5
6
7
8
- 第一种形式 本文节点的生成
// 形态1
let myVnode1 = h('h1', {}, '你好')
2
// createElement.js
// 真正创建节点
// 将 vnode 创建为DOM,插入到 pivot (标杆节点)这个元素之前
export default function (vnode, pivot) {
console.log('目的是把虚拟节点', vnode, '插入到标杆', pivot, '前面')
// 创建一个DOM 节点,这个节点现在是孤儿节点(还没有放到DOM渲染树上)
let domNode = document.createElement(vnode.sel)
// 注意:此处不考虑子节点和文本共存的情况
if (vnode.text !== '' && (vnode.children == undefined || vnode.children.length == 0)) {
// 内部是文字
domNode.innerText = vnode.text
// 将孤儿节点上树
pivot.parentNode.insertBefore(domNode, pivot)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
点击按钮后可以创建文本节点,并上树如图

- 第二种形式如果是一个嵌套的文本节点,那此时就不能正常显示了,需要递归操作
let myVnode11 = h('ul', {}, [
h('li', {}, '你好'),
h('li', {}, '你也好'),
h('li', {}, '大家好')
])
2
3
4
5
继续完善 createElement 函数,递归创建子节点,在递归的时候,是没有 pivot 标杆的,去掉 pivot,
此时createElement 函数的目的是把虚拟节点真正的变成DOM并且需要给这个虚拟节点添加elm属性,返回vnode.elm。
将虚拟节点上树的操作由createElement里变更到patch 里。
// createElement.js
// 真正创建节点
// - 将 vnode 创建为DOM,插入到 pivot 这个元素之前
// + 将 vnode 创建为DOM,是孤儿节点,不进行插入
// - export default function (vnode, pivot) {
export default function createElement(vnode) {
// - console.log('目的是把虚拟节点', vnode, '插入到标杆', pivot, '前面')
console.log('目的是把虚拟节点', vnode, '补充生elm属性,在patch里上树')
// 创建一个DOM 节点,这个节点现在是孤儿节点(还没有放到DOM渲染树上)
let domNode = document.createElement(vnode.sel)
// 注意:此处不考虑子节点和文本共存的情况
if (vnode.text !== '' && (vnode.children == undefined || vnode.children.length == 0)) {
// 内部是文字
domNode.innerText = vnode.text
// - 将孤儿节点上树
// - pivot.parentNode.insertBefore(domNode, pivot)
// + 上树的操作放到 patch 里,而不是在这里, 给vnode补充elm属性
vnode.elm = domNode
} else if (Array.isArray(vnode.children) && vnode.children.length) {
// 递归创建子节点
// 递归的结束条件是什么时候?
// 在递归的时候,是没有 pivot 标杆的,需要修改
}
// 返回elm ,递归使用, elm是一个纯DOM对象
return vnode.elm
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// patch.js
export default function (oldVnode, newVnode) {
// 判断传入的第一个参数是DOM节点还是虚拟节点
if(oldVnode.sel == '' || oldVnode.sel == undefined) {
// 传入的第一个参数是DOM节点,此时需要包装为虚拟节点
oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode)
console.log(oldVnode, 'oldVnode虚拟-A')
}
console.log(newVnode, 'newVnode-A')
// 判断oldVnode 和 newVnode 是不是同一个节点
if (oldVnode.key == newVnode.key && oldVnode.sel == newVnode.sel) {
console.log('是同一个节点-A')
} else {
console.log('不是同一个节点,暴力插入新的,删除旧的')
console.log(newVnode, 'newVnode-B')
// - createElement(newVnode, oldVnode.elm) // 去掉标杆 pivot
let newVnodeElm = createElement(newVnode)
// + 上树操作,在老节点前插入DOM
if(oldVnode.elm.parentNode && newVnodeElm) {
oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
}
// 删除老节点
oldVnode.elm.parentNode.removeChild(oldVnode.elm)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
createElement 改造后,只专注于创建元素,继续递归创建子节点
// createElement.js
if (Array.isArray(vnode.children) && vnode.children.length) {
// 递归创建子节点
// 递归的结束条件是什么时候?
// 在递归的时候,是没有 pivot 标杆的,需要修改
for (let i = 0; i < vnode.children.length; i++) {
// 得到当前这个 children
let ch = vnode.children[i]
// 创建出他的DOM, 一旦调用 createElement 意味着
// 创建出了DOM,并且他的elm属性指向了创建出的DOM,但是还没有上树,是一个孤儿节点
let chDOM = createElement(ch)
domNode.appendChild(chDOM)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14

# diff 处理新旧节点是同一个节点时(精细化比较)
精细化比较的时候,流程图如下

# newVnode 和 oldVnode 在内存中是同一个对象?
新旧节点 text 不同的情况
let myVnode2 = h('section', {}, [
h('p', {}, 'A'),
h('p', {}, 'B'),
h('p', {}, 'C')
])
2
3
4
5
6
let myVnode22 = h('section', {}, '你好')
oldVnode 和 newVnode 在内存中是同一个对象?如果是的话什么都不用做
// patch.js
// 判断新旧 vnode 是否是同一个对象
if (oldVnode === newVnode) {
return
}
2
3
4
5
oldVnode 和 newVnode 不是同一个对象,newVnode 有 text 属性,但没有 children newVnode 中的 text 和 oldVnode中的 text 不同,则直接覆盖oldVnode.elm 中的text。即使oldVnode.elm 中是children也会被覆盖掉。
// 判断新vnode有没有text属性
if (newVnode.text !== undefined && (newVnode.children == undefined || newVnode.children.length == 0)) {
console.log('新vnode 有 text属性')
if (newVnode.text != oldVnode.text) {
// 如果新的虚拟节点中的text 和老的虚拟节点的text不同
// 那么直接让新的text写入老的elm即可
// 如果老的elm中的是children 那么也会立即覆盖
oldVnode.elm.innerText = newVnode.text
}
}
2
3
4
5
6
7
8
9
10

# newVnode 有没有 text 属性?
newVnode 没有 text 属性,即有 children
let myVnode3 = h('section', {}, '我是老的DOM,只是文字不是children')
let myVnode33 = h('section', {}, [
h('p', {}, 'A'),
h('p', {}, 'B'),
h('p', {}, 'C')
])
2
3
4
5
console.log('新vnode 没有text属性,即有children')
// 判断老的是否有children
if (oldVnode.children != undefined && oldVnode.children.length > 0) {
// 老的有 children 此时就是最复杂的情况,新老都有children
} else {
// 老的没有 children 新的有 children
// 先清空老的节点内容
oldVnode.elm.innerHTML = ''
// 遍历新的vnode的子节点,创建DOM并上树
for (let i = 0; i < newVnode.children.length; i++) {
let dom = createElement(newVnode.children[i])
oldVnode.elm.appendChild(dom)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14

# newVnode 有没有 children?(diff 更新子节点)
let myVnode4 = h('ul', {}, [
h('li', { key: 'A' }, 'A'),
h('li', { key: 'B' }, 'B'),
h('li', { key: 'C' }, 'C')
])
2
3
4
5
let myVnode44 = h('ul', {}, [
h('li', { key: 'A' }, 'A'),
h('li', { key: 'B' }, 'B'),
h('li', { key: 'D' }, 'D'),
h('li', { key: 'E' }, 'E'),
h('li', { key: 'C' }, 'C')
])
2
3
4
5
6
7
实际操作中可能会出现的情况,比如以下的情况
第一种情况:插入,老节点中没有的,需要创建出来D E

第二种情况:删除的情况

第三种情况:更新情况

但实际情况远非如此, 经典的 diff 算法优化策略,4种命中查找方式
- 1、新前与旧前
- 2、新后与旧后
- 3、新后与旧前 (此种情况发生了,涉及移动节点,把旧前指向的这个节点移动到旧后的后面)
- 4、新前与旧后 (此种情况发生了,涉及移动节点,把旧后指向的这个节点移动到旧前的前面)
新(旧)前:新(旧)的虚拟节点当中的所有没有处理的开头的第一个节点
新(旧)后:新(旧)的虚拟节点当中的所有没有处理的节点的最后一个节点
对于同一个节点,命中一种就不在进行命中判断了。
新前和旧前进行对比,是否是同一个节点,如果是,则表示不是新增,也不是删除,是更新,就是命中 1 情况了,此时就不在命中 2 情况了。
← 初识虚拟 DOM