easycodesniper

blog by chen qiyi

Vue2虚拟DOM和diff算法

学习路径:

  1. snabbdom简介
  2. snabbdom的h函数
  3. diff算法原理
  4. 手写diff算法

snabbdom简介

snabbdom是著名的虚拟DOM库,是diff算法的鼻祖,Vue源码借鉴了snabbdom

安装:

1
npm i -S snabbdom

虚拟DOM和h函数

虚拟DOM

  1. 用JavaScript对象来描述dom的结构层次,dom中的一切属性都在虚拟dom中有对应的属性
  1. 新虚拟dom和老虚拟dom进行diff(精细化比较),算出应该如何最小量更新,最后反映到真实dom上

h函数

h函数用来创建虚拟节点(vnode)

比如这样调用h函数:

1
h('a', {props: {href: 'www.kyriecqy.github.io'}}, 'cqy')

将得到这样的虚拟节点:

1
{'sel':'a', 'data': {props: {href: 'www.kyriecqy.github.io'}}, 'text': 'cqy'}

它表示的真正的dom节点:

1
<a href='www.kyriecqy.github.io'>cqy</a>

虚拟节点拥有的属性:

  1. children:子元素
  2. data:属性、样式等
  3. elm:这个节点对应的真正的dom,是一个纯dom对象
  4. key:节点的唯一标识
  5. sel:这个节点的css选择器
  6. text:文本内容

使用h函数创建虚拟dom演示:

1
2
3
4
//创建虚拟节点
var vnode = h('a', {props: {href: 'https://www.kyriecqy.github.io'}}, 'cqy')

console.log(vnode);

h函数还可以嵌套使用,从而得到虚拟dom树:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//创建虚拟节点
var vnode = h('ul', [
h('li', '欧文'),
h('li', '杜兰特'),
h('li', '哈登')
])

//得到的虚拟dom树
{
"sel": "ul",
"data": '',
"children": [
{"sel": "li", "data": '', "text": "欧文", "elm": 'li'},
{"sel": "li", "data": '', "text": "杜兰特", "elm": 'li'},
{"sel": "li", "data": '', "text": "哈登", "elm": 'li'}
],
"elm": 'ul'
}

使用snabbdom创建真实dom演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//snabbdom包中的东西
import {init} from 'snabbdom/init'
import {classModule} from 'snabbdom/modules/class'
import {propsModule} from 'snabbdom/modules/props'
import {styleModule} from 'snabbdom/modules/style'
import {eventListenersModule} from 'snabbdom/modules/eventlisteners'
import {h} from 'snabbdom/h'

//创建patch函数
const patch = init([classModule,propsModule,styleModule,eventListenersModule])

//创建虚拟节点
var vnode = h('a', {props: {href: 'https://www.kyriecqy.github.io'}}, 'cqy')

console.log(vnode);

//让虚拟dom上树
const container = document.getElementById('container')
patch(container,vnode)

了解完h函数的基本用途和使用方法,接下来就是手写h函数

手写h函数

官方的h函数重载功能较强,即可以实现不同数量参数的传入

1
2
3
4
5
6
7
8
9
10
//h函数默认三个参数,sel,data,children

//但也支持这样的写法,可以传入不同数量的参数
h('div')
h('div', '一些文字')
h('div', [])
h('div', h())
h('div', {}, '一些文字')
h('div', {}, [ 在里面嵌套h函数 ])
h('div', {}, h())

我们在之后手写的h函数必须传入三个参数,相当于一个低配版的官方h函数
所以只重载以下类型的参数:

1
2
3
h('div', {}, '一些文字')
h('div', {}, [ 在里面嵌套h函数 ])
h('div', {}, h())

下面是vnode函数和h函数的代码

1
2
3
4
5
6
//函数的功能:将收到的参数整合成一个对象返回
export default function vnode(sel, data, children, text, elm) {
return {
sel, data, children, text, 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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import vnode from './vnode'

//编写一个低配版本的h函数,必须接收3个参数
//相当于重载功能较弱,只支持以下形式
//情况一:h('div', {}, '一些文字')
//情况二:h('div', {}, [ 在里面嵌套h函数 ])
//情况三:h('div', {}, h())
export default function h(sel, data, c) {
//检查参数个数
if(arguments.length != 3) {
throw new Error('这个h函数必须传入三个函数')
}

//检查参数c的类型
if(typeof c == 'string' || typeof c == 'number') {
//说明h函数的参数类型是情况一
//将参数依次传给vnode函数,
//vnode的第三个参数是children,情况一显然没有children,所以传入undefined
//vnode的第五个参数是elm,只有在上树(即生成了真实dom时)才给她加上值,所以传入undefined

//直接交给vnode函数整合成一个对象
return vnode(sel, data, undefined, c, undefined)

} else if(Array.isArray(c)) {
//说明h函数的参数类型是情况二
var children = []
//遍历c,c的每一项也必须是h函数
for(let i=0; i<c.length; i++) {
if(!(typeof c[i] == 'object' && c[i].hasOwnProperty('sel'))) {
throw new Error('传入的数组中有项不是h函数')
}
//c[i]是h函数,h函数的返回值是编译好的虚拟节点,则将他收集起来
//在这里其实有一个h函数的嵌套
children.push(c[i])
}
//收集完毕了,可以返回虚拟节点,这个虚拟节点是有children的
return vnode(sel, data, children, undefined, undefined)
} else if(typeof c == 'object' && c.hasOwnProperty('sel')) {
//如果c的类型是一个对象,且它有sel这个属性(因为h函数必须有sel属性),那么c就是一个h函数
//说明h函数的参数类型是情况三
var children = []
children.push(c)
return vnode(sel, data, children, undefined, undefined)
} else {
throw new Error('第三个参数传入的不对')
}
}

在index.js中来测试h函数

1
2
3
4
5
6
7
8
9
10
//在这个测试案例中包含了三种情况
import h from './mysnabbdom/h'

var myVnode = h('div', {}, [
h('p', {}, '欧文'),
h('p', {}, '杜兰特'),
h('p', {}, h('div', {}, '哈登')),
])

console.log(myVnode);

控制台打印的结果:我们成功的生成了虚拟dom节点

diff算法

体验diff算法

先使用官方的方法来调用h函数等生成虚拟dom并完成真实dom的转化:
vnode1是我们第一个创建的虚拟dom节点,并将他渲染在浏览器,
接下来我们定义第二个虚拟节点vnode2,vnode2只是在vnode1的基础上在最后又加上了一项,当我们点击button按钮,就会将vnode2渲染上浏览器

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
import {init} from 'snabbdom/init'
import {classModule} from 'snabbdom/modules/class'
import {propsModule} from 'snabbdom/modules/props'
import {styleModule} from 'snabbdom/modules/style'
import {eventListenersModule} from 'snabbdom/modules/eventlisteners'
import {h} from 'snabbdom/h'

//创建patch函数
const patch = init([classModule,propsModule,styleModule,eventListenersModule])

//创建虚拟节点
var vnode1 = h('ul', {}, [
h('li', {}, '欧文'),
h('li', {}, '杜兰特'),
h('li', {}, '哈登')
])

//让虚拟dom上树
const container = document.getElementById('container')
patch(container,vnode1)

var vnode2 = h('ul', {}, [
h('li', {}, '欧文'),
h('li', {}, '杜兰特'),
h('li', {}, '哈登'),
h('li', {}, 'cqy')
])

const btn = document.getElementById('btn')

btn.onclick = function() {
patch(vnode1, vnode2)
}

情景一:
我们接下来要关注的是diff算法是如何最小量更新dom的,是把之前的dom销毁将新dom渲染上去还是用其他的方法?
注意看控制台中的变化:当点击按钮,只是在ul中的最后添加了一项,而两个虚拟虚拟dom中相同的部分没有被重新渲染

现在我们改变一下vnode2,与vnode1相比在它的头部添加一项数据

1
2
3
4
5
6
7
var vnode2 = h('ul', {}, [
//数据添加在头部
h('li', {}, 'cqy'),
h('li', {}, '欧文'),
h('li', {}, '杜兰特'),
h('li', {}, '哈登'),
])

来看看这次按下按钮之后控制台的变化:将新的数据添加在前面时,所有的li都进行了重新渲染,好像看上去diff算法也没这么厉害,其实是我们没有给他加上key,在之前我们说过,key是节点的唯一标识

再来看看加上key属性之后的变化:这个时候它就好像认识了每个节点一样,对比vnode1和vnode2,只在开头增加了一项,没有改变其他

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var vnode2 = h('ul', {}, [
h('li', {key: 'a'}, '欧文'),
h('li', {key: 'b'}, '杜兰特'),
h('li', {key: 'c'}, '哈登'),
])


var vnode2 = h('ul', {}, [
//数据添加在头部
h('li', {key: 'd'}, 'cqy'),
h('li', {key: 'a'}, '欧文'),
h('li', {key: 'b'}, '杜兰特'),
h('li', {key: 'c'}, '哈登'),
])

key十分关键,key服务于最小量更新,key可以大大提高diff算法的效率

情景二:
当我们将vnode2中的ul改成ol,来看一下变化:因为当ul变成ol是,整个虚拟dom的父节点改变了,vnode1和vnode2已经不能算是同一个虚拟节点(没有相同的选择器)

1
2
3
4
5
6
7
8
9
10
11
var vnode2 = h('ul', {}, [
h('li', {}, '欧文'),
h('li', {}, '杜兰特'),
h('li', {}, '哈登'),
])

var vnode2 = h('ol', {}, [
h('li', {}, '欧文'),
h('li', {}, '杜兰特'),
h('li', {}, '哈登'),
])

只有是同一个虚拟节点(选择器相同且key相同),才进行精细化比较,如果不是同一个虚拟节点,直接暴力销毁重新渲染

情景三:
然后看一下下面的代码,观察一下变化:我们给vnode2又嵌套了一层div,在点击按钮之后并没有如我们预期的只套一个div,而是重新渲染了整个虚拟dom节点

因为diff算法只进行同层比较,不会进行跨层比较
在这里vnode1和vnode2的第一层相同,都是div,但vnode1的第二层是一系列的p,而vnode2的第二层是div,他的第三层才是一系列p;
vnode1和vnode2中的一系列p节点存在了跨层的问题,不会进行diff算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var vnode1 = h('div', {}, [
h('p', {key: 'a'}, '欧文'),
h('p', {key: 'b'}, '杜兰特'),
h('p', {key: 'c'}, '哈登')
]
)

// ....

var vnode2 = h('div', {}, h('div', {}, [
h('p', {key: 'a'}, '欧文'),
h('p', {key: 'b'}, '杜兰特'),
h('p', {key: 'c'}, '哈登')
])
)

这么看下来diff算法好像也不是很精细化,但其实上面场景二、三一般不会出现,属于合理的优化

手写第一次上树

第一次上树就是老的vnode是一个真实的dom节点,我们第一次将虚拟dom变成真实dom节点的过程

先用一个简单的数据(有的是文本不是子节点)来进行手写

1
2
3
4
5
6
7
8
import h from './mysnabbdom/h'
import patch from './mysnabbdom/patch'

const container = document.getElementById('container')

const vnode = h('h1', {}, '凯里欧文')

patch(container, vnode)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/*
函数的作用是创建真正的dom节点
*/

export default function createElement(vnode) {
console.log(vnode);
//根据vnode的标签来创建真实dom,现在还是个孤儿节点,没有上树
let domNode = document.createElement(vnode.sel)
console.log(domNode);
//判断vnode是有文本还是子节点
if(vnode.text != '' && (vnode.children == undefined || vnode.children.length == 0)) {
//vnode有的是文本
domNode.innerText = vnode.text
}
//补充elm属性,elm属性是一个纯dom
//此处一定要补充好elm属性,因为在之后的新旧节点替换时,要根据旧节点的parentNode来插入新节点,没了这个elm属性就找不到他的父元素了
vnode.elm = domNode
// 返回真实dom
//console.log(vnode);
return domNode
}
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
import vnode from "./vnode";
import createElement from "./createElement";
/*
在patch函数中完成diff算法的精细化操作
*/

export default function patch(oldVnode, newVnode) {
//判断老节点是真实dom还是虚拟dom
if(oldVnode.sel == '' || oldVnode.sel == undefined) {
//说明是个真实dom,此时要将他包装成虚拟dom
//他的sel就是它的标签,data,children,text都定义为空,elm就是它本身
oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode)
}
//console.log(oldVnode);

//判断oldVnode和newVnode是不是同一个节点
if(oldVnode.sel == newVnode.sel && oldVnode.key == newVnode.key) {
//是同一个节点
console.log('yes');
} else {
//不是同一个节点,插入新的虚拟dom,暴力销毁旧的dom(先插后删,借用旧dom作为一个插入位置的标识)
let newVnodeElm = createElement(newVnode)
//将新节点插入到老节点之前
if(oldVnode.elm.parentNode && newVnodeElm) {
oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
}
}
}

得到的结果:

当有子节点的情况

1
2
3
4
5
6
7
8
9
10
11
12
import h from './mysnabbdom/h'
import patch from './mysnabbdom/patch'

const container = document.getElementById('container')

const vnode = h('ul', {}, [
h('li', {}, 'kyrie'),
h('li', {}, 'kd'),
h('li', {}, 'harden'),
])

patch(container, vnode)
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
/*
函数的作用是创建真正的dom节点
*/

export default function CreateElement(vnode) {
console.log(vnode);
//根据vnode的标签来创建真实dom,现在还是个孤儿节点,没有上树
let domNode = document.createElement(vnode.sel)
//console.log(domNode);
//判断vnode是有文本还是子节点
if(vnode.text != '' && (vnode.children == undefined || vnode.children.length == 0)) {
//vnode有的是文本
domNode.innerText = vnode.text

} else if(Array.isArray(vnode.children) && vnode.children.length > 0) {
//vnode拥有子节点,就要递归创建节点
for(let i = 0; i < vnode.children.length; i++) {
let ch = vnode.children[i]
console.log(ch);
//将子节点变成真实dom,并追加到父节点上
let chDom = CreateElement(ch)
domNode.appendChild(chDom)
}
}
//补充elm属性,elm属性是一个纯dom
vnode.elm = domNode
// 返回真实dom
//console.log(vnode);
return domNode
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import vnode from "./vnode";
import CreateElement from "./createElement";

export default function patch(oldVnode, newVnode) {
// ...

//判断oldVnode和newVnode是不是同一个节点
if(oldVnode.sel == newVnode.sel && oldVnode.key == newVnode.key) {
//是同一个节点
console.log('yes');
} else {
//不是同一个节点,插入新的虚拟dom,暴力销毁旧的dom(先插后删,借用旧dom作为一个插入位置的标识)
let newVnodeElm = CreateElement(newVnode)
//将新节点插入到老节点之前
if(oldVnode.elm.parentNode && newVnodeElm) {
oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
}

}
}

得到的结果:

新旧节点不是同一个节点

其实上述代码中我们也实现了新旧节点不是同一个节点的情况,因为当新旧节点不是同一个节点时,diff算法并不进行精细化比较,而是暴力拆除和重新渲染
,我们可以改变一下传入的数据来测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import h from './mysnabbdom/h'
import patch from './mysnabbdom/patch'

const container = document.getElementById('container')

const vnode = h('ul', {}, [
h('li', {}, 'kyrie'),
h('li', {}, 'kd'),
h('li', {}, 'harden'),
])

patch(container, vnode)

const vnode2 = h('div', {}, [
h('p', {}, '欧文'),
h('p', {}, '杜兰特'),
h('p', {}, '哈登'),
])

const btn = document.getElementById('btn')

btn.onclick = function() {
patch(vnode, vnode2)
}

新老节点是同一个节点

新节点有text属性时

在这种情况下,并不用关心老节点是text属性还是children属性,假设老节点也有text属性,新老节点的text属性相同就不进行改变,不相同就将新节点的text属性插入到老节点身上

1
2
3
4
5
6
7
const vnode = h('section', {}, [
h('h4', {}, 'kyrie'),
h('h4', {}, 'kd'),
h('h4', {}, 'harden'),
])

const vnode2 = h('section', {}, '我是新节点')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export default function patch(oldVnode, newVnode) {

// ...

if(oldVnode.sel == newVnode.sel && oldVnode.key == newVnode.key) {
//是同一个节点

//判断新节点有没有text属性
if(newVnode.text != undefined && (newVnode.children == undefined || newVnode.children.length == 0)) {
//新节点有text属性
if(oldVnode.text != newVnode.text) {
//不销毁老节点,而是将新节点的text写到老节点身上
//不用担心老节点上的是text还是children,新插入的text都会覆盖掉
oldVnode.elm.innerText = newVnode.text
}
} else { //新节点没有text属性,那么有的是children

}
} else {
//不是同一个节点
// ....
}
}

得到的结果:这个就是流程图中的1部分判断

老节点有text属性,新节点有children时

在这种情况下,清空老节点的内容,将新节点的children创建成真实dom之后插入到老节点中

1
2
3
4
5
6
7
const vnode = h('section', {}, '凯里欧文')

const vnode2 = h('section', {}, [
h('p', {}, 'kyrie'),
h('p', {}, 'kd'),
h('p', {}, 'harden')
])
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
export default function patch(oldVnode, newVnode) {
// ...

//判断oldVnode和newVnode是不是同一个节点
if(oldVnode.sel == newVnode.sel && oldVnode.key == newVnode.key) {
//是同一个节点

//判断新节点有没有text属性
if(newVnode.text != undefined && (newVnode.children == undefined || newVnode.children.length == 0)) {
//新节点有text属性
// ....
} else {//新节点没有text属性,有children
//判断老节点是有text还是children
if(oldVnode.children != undefined && oldVnode.children.length > 0) {
//老节点有的是children,是最复杂的情况
} else { //老节点有的是text
//清空老节点的text内容
oldVnode.elm.innerHTML = ''
for(let i=0; i<newVnode.children.length; i++) {
//将新节点的children依次传入创建真实dom,并追加在老节点里面
let dom = CreateElement(newVnode.children[i])
oldVnode.elm.appendChild(dom)
}
}
}
} else {
//不是同一个节点
// ...
}
}

得到的结果:这个就是流程图中的2部分判断

为了方便阅读,我们将patch中时是同一个节点的模块抽离出去,成为一个新的函数再引入进来

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
import CreateElement from "./createElement"

/*
对比同一个虚拟节点的内容,并更新
*/

export default function patchVnode(oldVnode, newVnode) {
//判断新节点有没有text属性
if(newVnode.text != undefined && (newVnode.children == undefined || newVnode.children.length == 0)) {
//新节点有text属性
if(oldVnode.text != newVnode.text) {
//不销毁老节点,而是将新节点的text写到老节点身上
//不用担心老节点上的是text还是children,新插入的text都会覆盖掉
oldVnode.elm.innerText = newVnode.text
}
} else {//新节点没有text属性,有children
//判断老节点是有text还是children
if(oldVnode.children != undefined && oldVnode.children.length > 0) {
//老节点有的是children,是最复杂的情况
} else { //老节点有的是text
//清空老节点的text内容
oldVnode.elm.innerHTML = ''
for(let i=0; i<newVnode.children.length; i++) {
//将新节点的children依次传入创建真实dom,并追加在老节点里面
let dom = CreateElement(newVnode.children[i])
oldVnode.elm.appendChild(dom)
}
}
}
}

diff算法更新子节点策略

四种命中查找:

  1. 新前与旧前
  2. 新后与旧后
  3. 新后与旧前
  4. 新前与旧后

所谓新前就是新节点中所有没有处理的节点的最前面

四种命中方式从上到下依次判断,命中一个就不再向下判断

前指针只会下移,后指针只会上移,且保证前指针一定在后指针前(新前 <= 新后 && 旧前 <= 旧后)

新节点有新增的情况

新前新后旧前旧后初始位置如图所示,

  1. 首先判断新前(a)和旧前(a),发现完全相同,命中1号查找方式,将旧前变成真实dom,新前、旧前指针下移
  2. 判断新前(b)和旧前(b),发现完全相同,命中1号查找方式,将旧前变成真实dom,新前、旧前指针下移
  3. 判断新前(c)和旧前(c),发现完全相同,命中1号查找方式,将旧前变成真实dom,新前、旧前指针下移
  4. 此时 旧前>旧后 ,结束循环(如果旧节点先循环完毕,说明新节点中要新增节点),新前指向d,新后指向e,此时的新前新后之间的节点(d,e)就是要新增的节点

删除节点的情况

  1. 首先判断新前(a)和旧前(a),发现完全相同,命中1号查找方式,将旧前变成真实dom,新前、旧前指针下移
  2. 判断新前(b)和旧前(b),发现完全相同,命中1号查找方式,将旧前变成真实dom,新前、旧前指针下移
  3. 判断新前(c)和旧前(d),不相同;使用2号查找方式,新后(d)和旧后(e),不相同;接着使用3、4号查找方式,都无法命中
  4. 这个时候会循环旧节点,来看看能不能找到和新节点中d相同的节点,如果找到了,将它变成真实dom,插入到已处理好的节点(a、b)之后,并将它的虚拟节点标为undefined,新前下移
  5. 此时 新前>新后 ,循环结束(如果新节点先循环完毕,说明旧节点中要删除节点),旧前指向c,旧后指向e,此时要删除的就是旧前旧后之间的节点c、e(不对标为undefined的节点进行操作)

当命中4的情况

当4号(新前和旧后)命中时,此时要移动节点,移动新前指向的节点到旧前指向的节点前面,已处理的节点后面

  1. 依次判断四种命中情况,发现命中4号查找方式,将新前指向的节点(e)变成真实dom,移动到旧前的前面,并将他的虚拟节点标为undefined,新前下移,旧后上移
  2. 此时新前(c),新后(m),旧前(a),旧后(d),依次判断四种命中情况,都没有命中;
  3. 循环旧节点,看看能不能找到和新前(c)相同的节点,如果找到了,将它变成真实dom,插入到已处理好的节点(e)之后、旧前节点之前,并将它的虚拟节点标为undefined,新前下移
  4. 此时新前(m),新后(m),旧前(a),旧后(d),依次判断四种命中情况,都没有命中;
  5. 循环旧节点,看看能不能找到和新前(m)相同的节点,如果没有找到相同的节点,就将新前指向的节点插入到已处理好的节点之后,新前下移
  6. 此时 新前>新后 ,循环结束(如果新节点先循环完毕,说明旧节点中要删除节点),旧前指向a,旧后指向d,此时要删除的就是旧前旧后之间的节点a、b、d(不对标为undefined的节点进行操作)

当命中3的情况

当3号(新后和旧前)命中时,此时要移动节点,移动新后指向的节点到旧后指向的节点后面,已处理好的节点之前

  1. 依次判断四种命中情况,发现命中3号查找方式,将新后指向的节点(a)变成真实dom,移动到旧后的后面,并将他的虚拟节点标为undefined,新后上移,旧前下移
  2. 此时新前(e),新后(b),旧前(b),旧后(d),依次判断四种命中情况,发现命中3号查找方式,将新后指向的节点(b)变成真实dom,移动到旧后的后面,已处理好的节点(a)的前面,并将他的虚拟节点标为undefined,新后上移,旧前下移
  3. 重复上述操作,直到全部移动完毕
代码实现diff算法

在patch函数中判断是同一个节点,然后调用patchVnode函数来实现相同节点内容的比较和更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import vnode from "./vnode";
import CreateElement from "./createElement";
import patchVnode from "./patchVnode";

export default function patch(oldVnode, newVnode) {
//判断老节点是真实dom还是虚拟dom
// ...

//判断oldVnode和newVnode是不是同一个节点
if(oldVnode.sel == newVnode.sel && oldVnode.key == newVnode.key) {
//是同一个节点
patchVnode(oldVnode, newVnode)

} else {
//不是同一个节点,插入新的虚拟dom,暴力销毁旧的dom(先插后删,借用旧dom作为一个插入位置的标识)
// ...
}
}

在patchVnode函数中判断到新旧节点都有children属性,调用updateChildren来对比和更新子节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import CreateElement from "./createElement"
import updateChildren from "./updateChildren"

/*
对比同一个虚拟节点的内容,并更新
*/

export default function patchVnode(oldVnode, newVnode) {
//判断新节点有没有text属性
if(newVnode.text != undefined && (newVnode.children == undefined || newVnode.children.length == 0)) {
//新节点有text属性
// ...
} else {//新节点没有text属性,有children
//判断老节点是有text还是children
if(oldVnode.children != undefined && oldVnode.children.length > 0) {
//老节点有的是children,是最复杂的情况
//传入旧的父节点,旧的子节点,新的子节点,根据新旧子节点的对比来对旧节点进行更新
updateChildren(oldVnode.elm, oldVnode.children, newVnode.children)
} else { //老节点有的是text
// ...
}
}
}

先实现命中四种命中方式的代码块

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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import patchVnode from "./patchVnode";

//判断节点是否相同
function checkSameVnode(a, b) {
return (a.sel == b.sel && a.key == b.key)
}
/*
函数作用是对比新旧节点的子节点内容,并对旧节点进行更新
接收三个参数:父节点,旧的子节点,新的子节点
*/
export default function updateChildren(parentElm, oldCh, newCh) {
console.log(parentElm, oldCh, newCh);
//旧前指针
let oldStartIdx = 0
//新前指针
let newStartIdx = 0
//旧后指针
let oldEndIdx = oldCh.length - 1
//新后指针
let newEndIdx = newCh.length - 1

//旧前节点
let oldStartVnode = oldCh[oldStartIdx]
//新前节点
let newStartVnode = newCh[newStartIdx]
//旧后节点
let oldEndVnode = oldCh[oldEndIdx]
//新后节点
let newEndVnode = newCh[newEndIdx]

while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
//新前和旧前相同,命中1号查找方式
if(checkSameVnode(oldStartVnode, newStartVnode)) {
// 进一步比较新前和旧前的内容
console.log('1');
patchVnode(oldStartVnode, newStartVnode)
//新前旧前指针后移,同时改变新前节点旧前节点
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]

} else if(checkSameVnode(oldEndVnode, newEndVnode)) { //新后与旧后相同,命中2号查找方式
// 进一步比较新后和旧后的内容
console.log('2');
patchVnode(oldEndVnode, newEndVnode)
//新后旧后指针前移,同时改变新后节点旧后节点
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if(checkSameVnode(oldStartVnode, newEndVnode)) { //新后与旧前相同,命中3号查找方式
console.log('3');
patchVnode(oldStartVnode, newEndVnode)
//当3号(新后和旧前)命中时,此时要移动节点,移动新后指向的节点到旧后指向的节点后面,已处理好的节点之前
//在这里直接移动旧节点,因为在patchNode中已经更加新旧节点的异同,将新节点中的内容插入到旧节点中了
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
//旧前指针后移,新后指针前移
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if(checkSameVnode(oldEndVnode, newStartVnode)) { //新前与旧后相同,命中4号查找方式
console.log('4');
patchVnode(oldEndVnode, newStartVnode)
//当4号(新前和旧后)命中时,此时要移动节点,移动新前指向的节点到旧前指向的节点前面,已处理的节点后面
//在这里直接移动旧节点,因为在patchNode中已经更加新旧节点的异同,将新节点中的内容插入到旧节点中了
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
//四种命中方式都没有命中
}
}

if(newStartIdx <= newEndIdx) {
//console.log('new中还有剩余');
for(let i = newStartIdx; i <= newEndIdx; i++) {
//将虚拟dom转化为真实dom再进行插入
parentElm.insertBefore(CreateElement(newCh[i]), oldCh[oldStartIdx].elm)
}
} else if(oldStartIdx <= oldEndIdx) {
//console.log('old中还有剩余');
for(let i = oldStartIdx; i <= oldEndIdx; i++) {
parentElm.removeChild(oldCh[i].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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
import patchVnode from "./patchVnode";
import CreateElement from "./createElement"

//判断节点是否相同
function checkSameVnode(a, b) {
return (a.sel == b.sel && a.key == b.key)
}

export default function updateChildren(parentElm, oldCh, newCh) {
//旧前指针
let oldStartIdx = 0
//新前指针
let newStartIdx = 0
//旧后指针
let oldEndIdx = oldCh.length - 1
//新后指针
let newEndIdx = newCh.length - 1

//旧前节点
let oldStartVnode = oldCh[oldStartIdx]
//新前节点
let newStartVnode = newCh[newStartIdx]
//旧后节点
let oldEndVnode = oldCh[oldEndIdx]
//新后节点
let newEndVnode = newCh[newEndIdx]

let keyMap = null

while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
//判断旧前旧后是否是已经处理过的项(可以先看下面,再回上来看这个if语句)
if(oldStartVnode == undefined) {
oldStartVnode = oldCh[++oldStartIdx]
} else if(oldEndVnode == undefined) {
oldEndVnode = oldCh[--oldEndIdx]
}
//新前和旧前相同,命中1号查找方式
if(checkSameVnode(oldStartVnode, newStartVnode)) {
// 进一步比较新前和旧前的内容
patchVnode(oldStartVnode, newStartVnode)
//新前旧前指针后移,同时改变新前节点旧前节点
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]

} else if(checkSameVnode(oldEndVnode, newEndVnode)) { //新后与旧后相同,命中2号查找方式
// 进一步比较新后和旧后的内容
patchVnode(oldEndVnode, newEndVnode)
//新后旧后指针前移,同时改变新后节点旧后节点
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if(checkSameVnode(oldStartVnode, newEndVnode)) { //新后与旧前相同,命中3号查找方式
patchVnode(oldStartVnode, newEndVnode)
//当3号(新后和旧前)命中时,此时要移动节点,移动新后指向的节点到旧后指向的节点后面,已处理好的节点之前
//在这里直接移动旧节点,因为在patchNode中已经更加新旧节点的异同,将新节点中的内容插入到旧节点中了
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
//旧前指针后移,新后指针前移
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if(checkSameVnode(oldEndVnode, newStartVnode)) { //新前与旧后相同,命中4号查找方式
patchVnode(oldEndVnode, newStartVnode)
//当4号(新前和旧后)命中时,此时要移动节点,移动新前指向的节点到旧前指向的节点前面,已处理的节点后面
//在这里直接移动旧节点,因为在patchNode中已经更加新旧节点的异同,将新节点中的内容插入到旧节点中了
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
---------------------------------------------------------
} else { //四种命中方式都没有命中

//keyMap用于存储对应key在旧节点中的位置
if(!keyMap) {
keyMap = {}
for(let i = oldStartIdx; i <= oldEndIdx; i++) {
const key = oldCh[i].key
if(key != undefined) {
keyMap[key] = i
}
}
}
//寻找当前项(newStartIdx)在keyMap中的位置
const idxInOld = keyMap[newStartVnode.key]
//判断,如果idxInOld是undefined,那么它就是全新的项,要插入节点
if(idxInOld == undefined) {
parentElm.insertBefore(CreateElement(newStartVnode), oldStartVnode.elm)
} else { // 不是undefined,那么它不是全新的项,要移动
//取出要移动的项
const elmToMove = oldCh[idxInOld]
patchVnode(elmToMove, newStartVnode)
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
//将处理过的项打上undefined标记,之后就会跳过他
oldCh[idxInOld] = undefined
}
//新前指针下移
newStartVnode = newCh[++newStartIdx]
}
-------------------------------------------------------
}

if(newStartIdx <= newEndIdx) {
console.log('new中还有剩余');
for(let i = newStartIdx; i <= newEndIdx; i++) {

parentElm.insertBefore(CreateElement(newCh[i]), oldCh[oldStartIdx].elm)
}
} else if(oldStartIdx <= oldEndIdx) {
console.log('old中还有剩余');
for(let i = oldStartIdx; i <= oldEndIdx; i++) {
if(oldCh[i]) {
parentElm.removeChild(oldCh[i].elm)
}
}
}
}

完整代码

调用h函数生成虚拟dom节点,已经调用patch函数进行diff算法的精细化比较

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
import h from './mysnabbdom/h'
import patch from './mysnabbdom/patch'

const container = document.getElementById('container')

const vnode = h('section', {}, [
h('p', {key: 'a'}, 'kyrie'),
h('p', {key: 'b'}, 'kd'),
h('p', {key: 'c'}, 'harden'),
h('p', {key: 'd'}, 'cqy'),
])

patch(container, vnode)

const vnode2 = h('section', {}, [
h('p', {key: 'a'}, 'kyrie'),
h('p', {key: 'b'}, 'kd'),
h('p', {key: 'h'}, 'crf'),
h('p', {key: 'c'}, 'harden'),
h('p', {key: 'd'}, 'cqy'),

])

const btn = document.getElementById('btn')

btn.onclick = function() {
patch(vnode, vnode2)
}

h函数

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
import vnode from './vnode'
/*
函数的功能是将传入的数据变成虚拟dom
*/
//编写一个低配版本的h函数,必须接收3个参数
//相当于重载功能较弱,只支持以下形式
//情况一:h('div', {}, '一些文字')
//情况二:h('div', {}, [ 在里面嵌套h函数 ])
//情况三:h('div', {}, h())
export default function h(sel, data, c) {
//检查参数个数
if(arguments.length != 3) {
throw new Error('这个h函数必须传入三个函数')
}

//检查参数c的类型
if(typeof c == 'string' || typeof c == 'number') {
//说明h函数的参数类型是情况一
//将参数依次传给vnode函数,
//vnode的第三个参数是children,情况一显然没有children,所以传入undefined
//vnode的第五个参数是elm,只有在上树(即生成了真实dom时)才给她加上数据,所以传入undefined
return vnode(sel, data, undefined, c, undefined)

} else if(Array.isArray(c)) {
//说明h函数的参数类型是情况二
var children = []
//遍历c,c的每一项也必须是h函数
for(let i=0; i<c.length; i++) {
if(!(typeof c[i] == 'object' && c[i].hasOwnProperty('sel'))) {
throw new Error('传入的数组中有项不是h函数')
}
//c[i]是h函数,则将他收集起来
//在这个时候c[i]就是h函数返回的虚拟dom
children.push(c[i])
}
//收集完毕了,可以返回虚拟节点,这个虚拟节点是有children的
return vnode(sel, data, children, undefined, undefined)
} else if(typeof c == 'object' && c.hasOwnProperty('sel')) {
//如果c的类型是一个对象,且它有sel这个属性(因为h函数必须有sel属性)
//说明h函数的参数类型是情况三
var children = []
children.push(c)
return vnode(sel, data, children, undefined, undefined)
} else {
throw new Error('第三个参数传入的不对')
}
}

vnode函数就是将参数整合成一个对象

1
2
3
4
5
6
7
//函数的功能:将收到的参数整合成一个对象返回
export default function vnode(sel, data, children, text, elm) {
const key = data.key
return {
sel, data, children, text, elm, key
}
}

patch函数用于对比新旧节点是否是同一个节点,是同一个节点就进行diff算法的精细化处理;不是同一个节点就直接插入新的暴力删除旧的

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
import vnode from "./vnode";
import CreateElement from "./createElement";
import patchVnode from "./patchVnode";

export default function patch(oldVnode, newVnode) {
//判断老节点是真实dom还是虚拟dom
if(oldVnode.sel == '' || oldVnode.sel == undefined) {
//说明是个真实dom,此时要将他包装成虚拟dom
//他的sel就是它的标签,data,children,text都定义为空,elm就是它本身
oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode)
}
//console.log(oldVnode);

//判断oldVnode和newVnode是不是同一个节点
if(oldVnode.sel == newVnode.sel && oldVnode.key == newVnode.key) {
//是同一个节点
//调用patchVnode来实现diff算法
patchVnode(oldVnode, newVnode)

} else {
//不是同一个节点,插入新的虚拟dom,暴力销毁旧的dom(先插后删,借用旧dom作为一个插入位置的标识)
let newVnodeElm = CreateElement(newVnode)
//将新节点插入到老节点之前
if(oldVnode.elm.parentNode && newVnodeElm) {
oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
}
//删除老节点
oldVnode.elm.parentNode.removeChild(oldVnode.elm)
}
}

由patchVnode来进行diff算法

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
import CreateElement from "./createElement"
import updateChildren from "./updateChildren"

/*
对比同一个虚拟节点的内容,并更新
*/

export default function patchVnode(oldVnode, newVnode) {
//判断新节点有没有text属性
if(newVnode.text != undefined && (newVnode.children == undefined || newVnode.children.length == 0)) {
//新节点有text属性
if(oldVnode.text != newVnode.text) {
//不销毁老节点,而是将新节点的text写到老节点身上
//不用担心老节点上的是text还是children,新插入的text都会覆盖掉
oldVnode.elm.innerText = newVnode.text
}
} else {//新节点没有text属性,有children
//判断老节点是有text还是children
if(oldVnode.children != undefined && oldVnode.children.length > 0) {
//老节点有的是children,这个时候新老节点都有children,是最复杂的情况
//传入老节点,老节点的children以及新节点的children
updateChildren(oldVnode.elm, oldVnode.children, newVnode.children)
} else { //老节点有的是text
//清空老节点的text内容
oldVnode.elm.innerHTML = ''
for(let i=0; i<newVnode.children.length; i++) {
//将新节点的children依次传入创建真实dom,并追加在老节点里面
let dom = CreateElement(newVnode.children[i])
oldVnode.elm.appendChild(dom)
}
}
}
}

CreateElement函数用来创建真实dom节点

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
/*
函数的作用是创建真正的dom节点
*/

export default function CreateElement(vnode) {
//根据vnode的标签来创建真实dom,现在还是个孤儿节点,没有上树
let domNode = document.createElement(vnode.sel)
//console.log(domNode);
//判断vnode是有文本还是子节点
if(vnode.text != '' && (vnode.children == undefined || vnode.children.length == 0)) {
//vnode有的是文本
domNode.innerText = vnode.text

} else if(Array.isArray(vnode.children) && vnode.children.length > 0) {
//vnode拥有子节点,就要递归创建节点
for(let i = 0; i < vnode.children.length; i++) {
let ch = vnode.children[i]
//console.log(ch);
//将子节点变成真实dom,并追加到父节点上
let chDom = CreateElement(ch)
domNode.appendChild(chDom)
}
}
//补充elm属性,elm属性是一个纯dom
vnode.elm = domNode
// 返回真实dom
//console.log(vnode);
return domNode
}

updateChildren函数是diff算法精细化比较的核心,使用了四种命中查找方法的优化策略

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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
import patchVnode from "./patchVnode";
import CreateElement from "./createElement"

//判断节点是否相同
function checkSameVnode(a, b) {
return (a.sel == b.sel && a.key == b.key)
}

export default function updateChildren(parentElm, oldCh, newCh) {
//旧前指针
let oldStartIdx = 0
//新前指针
let newStartIdx = 0
//旧后指针
let oldEndIdx = oldCh.length - 1
//新后指针
let newEndIdx = newCh.length - 1

//旧前节点
let oldStartVnode = oldCh[oldStartIdx]
//新前节点
let newStartVnode = newCh[newStartIdx]
//旧后节点
let oldEndVnode = oldCh[oldEndIdx]
//新后节点
let newEndVnode = newCh[newEndIdx]

let keyMap = null

while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
//判断旧前旧后是否是已经处理过的项(可以先看下面,再回上来看这个if语句)
if(oldStartVnode == undefined) {
oldStartVnode = oldCh[++oldStartIdx]
} else if(oldEndVnode == undefined) {
oldEndVnode = oldCh[--oldEndIdx]
}
//新前和旧前相同,命中1号查找方式
if(checkSameVnode(oldStartVnode, newStartVnode)) {
// 进一步比较新前和旧前的内容
patchVnode(oldStartVnode, newStartVnode)
//新前旧前指针后移,同时改变新前节点旧前节点
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]

} else if(checkSameVnode(oldEndVnode, newEndVnode)) { //新后与旧后相同,命中2号查找方式
// 进一步比较新后和旧后的内容
patchVnode(oldEndVnode, newEndVnode)
//新后旧后指针前移,同时改变新后节点旧后节点
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if(checkSameVnode(oldStartVnode, newEndVnode)) { //新后与旧前相同,命中3号查找方式
patchVnode(oldStartVnode, newEndVnode)
//当3号(新后和旧前)命中时,此时要移动节点,移动新后指向的节点到旧后指向的节点后面,已处理好的节点之前
//在这里直接移动旧节点,因为在patchNode中已经更加新旧节点的异同,将新节点中的内容插入到旧节点中了
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
//旧前指针后移,新后指针前移
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if(checkSameVnode(oldEndVnode, newStartVnode)) { //新前与旧后相同,命中4号查找方式
patchVnode(oldEndVnode, newStartVnode)
//当4号(新前和旧后)命中时,此时要移动节点,移动新前指向的节点到旧前指向的节点前面,已处理的节点后面
//在这里直接移动旧节点,因为在patchNode中已经更加新旧节点的异同,将新节点中的内容插入到旧节点中了
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else { //四种命中方式都没有命中

//keyMap用于存储对应key在旧节点中的位置
if(!keyMap) {
keyMap = {}
for(let i = oldStartIdx; i <= oldEndIdx; i++) {
const key = oldCh[i].key
if(key != undefined) {
keyMap[key] = i
}
}
}
//寻找当前项(newStartIdx)在keyMap中的位置
const idxInOld = keyMap[newStartVnode.key]
//判断,如果idxInOld是undefined,那么它就是全新的项,要插入节点
if(idxInOld == undefined) {
parentElm.insertBefore(CreateElement(newStartVnode), oldStartVnode.elm)
} else { // 不是undefined,那么它不是全新的项,要移动
//取出要移动的项
const elmToMove = oldCh[idxInOld]
patchVnode(elmToMove, newStartVnode)
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
//将处理过的项打上undefined标记,之后就会跳过他
oldCh[idxInOld] = undefined
}
//新前指针下移
newStartVnode = newCh[++newStartIdx]
}
}

if(newStartIdx <= newEndIdx) {
console.log('new中还有剩余');
for(let i = newStartIdx; i <= newEndIdx; i++) {

parentElm.insertBefore(CreateElement(newCh[i]), oldCh[oldStartIdx].elm)
}
} else if(oldStartIdx <= oldEndIdx) {
console.log('old中还有剩余');
for(let i = oldStartIdx; i <= oldEndIdx; i++) {
if(oldCh[i]) {
parentElm.removeChild(oldCh[i].elm)
}
}
}
}