# snabbdom 简介

snabbdom 是瑞典语单词,原意"速度",是著名的虚拟 DOM 库, 是 diff 算法的鼻祖,Vue 源码借鉴了 snabbdom

npm i snabbdom -D
1

# 虚拟 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' } }, '江小鱼')
1

此时将得到这样的虚拟节点:

{
	"sel": "a",
    "data": {
		props: {
	      href: 'http://www.baidu.com'		
        }
    },
  "text": "江小鱼"
}
1
2
3
4
5
6
7
8
9

静态图片

虚拟节点有哪些属性:

{
	"children": undefined, // 子元素 ,undefined 表示没有子元素
    "data": {}, // 存放属性和样式等
    "elm": undefined, // 对应真正的DOM, undefined表示这个虚拟DOM没上树
    "key": undefined, // 当前节点唯一标识
    "sel": "div", // 选择器
    "text": "我是一个div盒子" // 文本内容
}
1
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)
1
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())
1
2
3

# vnode 函数

// vnode 函数就是把传入的 5个参数组合成对象返回
export default function (sel, data, children, text, elm) {

	return {
		sel,
        data,
        children,
        text,
        elm
	}
}
1
2
3
4
5
6
7
8
9
10
11

# h 函数的调用方式

默认规定必须传递 3 个参数,那么此时 h 函数就会有 3 种形态的调用方式

h(sel, data, c)

  • 第一种形态 c 是文字或者数子
let myVnode1 = h('div', {}, '文字')
1
  • 第二种形态 c 是数组
let myVnode2 = h('div', {}, [
	h('p', {}, '西瓜'),
	h('p', {}, '香蕉'),
	h('p', {}, [
		h('span', {}, '土豆1'),
		h('span', {}, '土豆2')
	])
])
1
2
3
4
5
6
7
8
  • 第三种形态 c 是 h()
let myVnode3 = h('div', {}, h('p', {}, '冬瓜'))
1

完整代码

// 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个参数不对')
	}
}

1
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)
}

1
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'),
])

1
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')
])
1
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')
])

1
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')
])
1
2
3
4
5
6

静态图片

  • 只进行同层比较,不会进行跨层比较 。 myVnode1myVnode2 虽然内容相同都是A B C DmyVnode2 外层还有一个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')
])
1
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')
]))

1
2
3
4
5
6
7

静态图片

# diff 算法体验心得

  • 最小量更新 key 很重要是唯一标识告诉diff算法,在更改前后他们是同一个DOM节点。
  • 只有是同一个虚拟节点,才进行精细化比较,否则就是暴力删除旧的,插入新的。(如何定义是同一个虚拟节点?选择器相同且key相同)
  • 只进行同层比较,不会进行跨层比较。即使是同一片虚拟节点,但是跨层了,精细化比较不diff。会暴力删除旧的,然后插入新的。

# diff 处理新旧节点不是同一个节点时

snabbdominit 函数调用有会返回一个 patch 函数,而path的作用是处理 vnode

静态图片

// init.ts 截取片段
// 是否是元素节点
if (isElement(api, oldVnode)) {
  oldVnode = emptyNodeAt(oldVnode);
} else if (isDocumentFragment(api, oldVnode)) {
// 是否是 DocumentFragment 节点
  oldVnode = emptyDocumentFragmentAt(oldVnode);
}
1
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);
}
1
2
3
4
5
6
7
// htmldomapi.ts
function isElement(node: Node): node is Element {
  return node.nodeType === 1;
}
1
2
3
4

函数 isElement 是根据节点的nodeType 的值来判断当前节点是哪个类型的节点。 nodeType有12种不同的节点类型,如图。其中1 3 8 11是需要重点关注的

  • 1 代表元素
  • 3 代表元素或属性中的文本内容
  • 8 代表注释
  • 11 代表轻量级的 Document 对象,能够容纳文档的某个部分

静态图片

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

静态图片

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

静态图片

判断完 isElementisDocumentFragment 后,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;
}
1
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)
1
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')
}
1
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)
}
1
2
3
4
5
6
7
8
  • 第一种形式 本文节点的生成
// 形态1
let myVnode1 = h('h1', {}, '你好')
1
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)
  }

}
1
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', {}, '大家好')
])
1
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
}
1
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)
  }
}

1
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)
  }
}
1
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')
])

1
2
3
4
5
6
let myVnode22 = h('section', {}, '你好')
1

oldVnodenewVnode 在内存中是同一个对象?如果是的话什么都不用做

// patch.js
// 判断新旧 vnode 是否是同一个对象
if (oldVnode === newVnode) {
  return
}
1
2
3
4
5

oldVnodenewVnode 不是同一个对象,newVnodetext 属性,但没有 children newVnode 中的 textoldVnode中的 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
  }
}
1
2
3
4
5
6
7
8
9
10

静态图片

# newVnode 有没有 text 属性?

newVnode 没有 text 属性,即有 children

let myVnode3 = h('section', {},  '我是老的DOM,只是文字不是children')
1
let myVnode33 = h('section', {}, [
  h('p', {}, 'A'),
  h('p', {}, 'B'),
  h('p', {}, 'C')
])
1
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)
  }
}
1
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')
])
1
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')
])
1
2
3
4
5
6
7

实际操作中可能会出现的情况,比如以下的情况

第一种情况:插入,老节点中没有的,需要创建出来D E

静态图片

第二种情况:删除的情况

静态图片

第三种情况:更新情况

静态图片

但实际情况远非如此, 经典的 diff 算法优化策略,4种命中查找方式

  • 1、新前与旧前
  • 2、新后与旧后
  • 3、新后与旧前 (此种情况发生了,涉及移动节点,把旧前指向的这个节点移动到旧后的后面)
  • 4、新前与旧后 (此种情况发生了,涉及移动节点,把旧后指向的这个节点移动到旧前的前面)

新(旧)前:新(旧)的虚拟节点当中的所有没有处理的开头的第一个节点

新(旧)后:新(旧)的虚拟节点当中的所有没有处理的节点的最后一个节点

对于同一个节点,命中一种就不在进行命中判断了。

新前和旧前进行对比,是否是同一个节点,如果是,则表示不是新增,也不是删除,是更新,就是命中 1 情况了,此时就不在命中 2 情况了。