浅析Virtual DOM库 - snabbdom的源码以及 仿写一个自己的vDOM库

本来打算看下 virtualDOM 的实现原理,但看到许多文章都只是在讲原理,很少有对 vDOM 库的源码的分析,今天打算尝试着从自己的角度出发,写一篇源码解析的文章

首先请出今天的主角——Vue2 的 vDOM 所基于的库,snabbdom,github 地址如下

GitHub: https://github.com/snabbdom/snabbdom


一、类型

首先我们来看下他的类型定义

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
31
32
33
34
35
36
37
38
39
40
41
42
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?: Array<any>; // for thunks
[key: string]: any; // for any other 3rd party module
}

// Recode的含义(相当于定义了key和value的类型)
// const user: Record<'name'|'email', string> = {
// name: '',
// email: ''
// }

type Props = Record<string, any>;

type Classes = Record<string, boolean>

type Attrs = Record<string, string | number | boolean>


interface Hooks {
pre?: PreHook;
init?: InitHook;
create?: CreateHook;
insert?: InsertHook;
prepatch?: PrePatchHook;
update?: UpdateHook;
postpatch?: PostPatchHook;
destroy?: DestroyHook;
remove?: RemoveHook;
post?: PostHook;
}

可以看到 snabbdom 定义的虚拟 dom 节点并不像许多Vue 里面所定义的一样, 他有一系列的符合我们认知的诸如 class,attrs 等属性,但同时他又给我们提供了 hook,让我们可以在更新节点是对他进行操作

二、方法

先看下官方给我们的示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var snabbdom = require('snabbdom')
var patch = snabbdom.init([ // Init patch function with chosen modules
require('snabbdom/modules/class').default, // makes it easy to toggle classes
require('snabbdom/modules/props').default, // for setting properties on DOM elements
require('snabbdom/modules/style').default, // handles styling on elements with support for animations
require('snabbdom/modules/eventlisteners').default, // attaches event listeners
]);
var h = require('snabbdom/h').default; // helper function for creating vnodes
var toVNode = require('snabbdom/tovnode').default;

var newVNode = h('div', {style: {color: '#000'}}, [
h('h1', 'Headline'),
h('p', 'A paragraph'),
]);

patch(toVNode(document.querySelector('.container')), newVNode)

很方便,定义一个节点以及一个更新时函数就可以正常使用了,下面我们来看下具体这些方法都做了什么

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
48
49
50
function h(sel: any, b?: any, c?: any): VNode {
var data: VNodeData = {}, children: any, text: any, i: number;
if (c !== undefined) {
data = b;
if (is.array(c)) { children = c; }
else if (is.primitive(c)) { text = c; }
else if (c && c.sel) { children = [c]; }
} else if (b !== undefined) {
if (is.array(b)) { children = b; }
else if (is.primitive(b)) { text = b; }
else if (b && b.sel) { children = [b]; }
else { data = b; }
}
if (children !== undefined) {
for (i = 0; i < children.length; ++i) {
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] === '#')
) {
addNS(data, children, sel);
}
return vnode(sel, data, children, text, undefined);
};

// addNs
function addNS(data: any, children: VNodes | undefined, sel: string | undefined): void {
data.ns = 'http://www.w3.org/2000/svg';
if (sel !== 'foreignObject' && children !== undefined) {
for (let i = 0; i < children.length; ++i) {
let childData = children[i].data;
if (childData !== undefined) {
addNS(childData, (children[i] as VNode).children as VNodes, children[i].sel);
}
}
}
}

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

可以看到所做的无非就是对你的输入做一些判断(可变参数),以及对一些拥有自己特殊的命名空间(svg)的元素的处理

init 函数实现

init 接受插件和可选的 domAPI 属性,返回一个函数用于更新 dom

1
init(modules: Array<Partial<Module>>, domApi?: DOMAPI)

第一个参数接受一系列插件用于更新 dom

1
2
3
4
5
6
7
8
9
// Partial 将所有类型标记为可选属性
interface Module {
pre: PreHook;
create: CreateHook;
update: UpdateHook;
destroy: DestroyHook;
remove: RemoveHook;
post: PostHook;
}

看一个插件的源码

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
import {VNode, VNodeData} from '../vnode';
import {Module} from './module';

export type Classes = Record<string, boolean>

function updateClass(oldVnode: VNode, vnode: VNode): void {
var cur: any, name: string, elm: Element = vnode.elm as Element,
oldClass = (oldVnode.data as VNodeData).class,
klass = (vnode.data as VNodeData).class;

if (!oldClass && !klass) return;
if (oldClass === klass) return;
oldClass = oldClass || {};
klass = klass || {};

for (name in oldClass) {
if (!klass[name]) {
elm.classList.remove(name);
}
}
for (name in klass) {
cur = klass[name];
if (cur !== oldClass[name]) {
(elm.classList as any)[cur ? 'add' : 'remove'](name);
}
}
}

export const classModule = {create: updateClass, update: updateClass} as Module;
export default classModule;

插件是在 patch 函数运行时的提供的各个 hook 对 dom 进行实际操作的动作
那么插件是怎么装载进 patch 的呢?我们再来看一下 init 函数具体操作了什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const hooks: (keyof Module)[] = ['create', 'update', 'remove', 'destroy', 'pre', 'post'];
function init(modules: Array<Partial<Module>>, domApi?: DOMAPI) {
let i: number, j: number, cbs = ({} as ModuleHooks);

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 Array<any>).push(hook);
}
}
}
// ...
return function patch() {
// ...
}
}

就是用闭包把这些方法存起来,在运行时再一一调用

再看 patch 函数(由 init 方法返回的用于更新 dom 的函数)

  • 如果判断是不同的 VNode 则根据新的 VNode 创建 DOM 替换旧的 VNode 节点
  • 如果判断是同一个 vNode 则会运行 patchNode 的方法(对原有的 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
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](); // 执行钩子: pre

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

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

createElm(vnode, insertedVnodeQueue);

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

for (i = 0; i < insertedVnodeQueue.length; ++i) {
(((insertedVnodeQueue[i].data as VNodeData).hook as Hooks).insert as any)(insertedVnodeQueue[i]);
}
for (i = 0; i < cbs.post.length; ++i) cbs.post[i](); // 执行钩子: post
return vnode;
};

之后我们再看下 pathVnode 进行了什么操作

  • 主要执行的是更新操作是由 update 这个 hook 来提供的,之后再对子节点进行更新或者增删等操作
  • update 及上面 init 函数初始化时所传入的处理函数,在这一步对实际元素进行了处理
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
function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
let i: any, hook: any;
if (isDef(i = vnode.data) && isDef(hook = i.hook) && isDef(i = hook.prepatch)) {
i(oldVnode, vnode); // 执行钩子: prepatch(定义在VNode上)
}
const elm = vnode.elm = (oldVnode.elm as Node);
let oldCh = oldVnode.children;
let ch = vnode.children;
if (oldVnode === vnode) return;
if (vnode.data !== undefined) {
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode); // 执行钩子: update
i = vnode.data.hook;
if (isDef(i) && isDef(i = i.update)) i(oldVnode, vnode);
}

//
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh as Array<VNode>, ch as Array<VNode>, insertedVnodeQueue);
} else if (isDef(ch)) {
if (isDef(oldVnode.text)) api.setTextContent(elm, '');
addVnodes(elm, null, ch as Array<VNode>, 0, (ch as Array<VNode>).length - 1, insertedVnodeQueue);
} else if (isDef(oldCh)) {
removeVnodes(elm, oldCh as Array<VNode>, 0, (oldCh as Array<VNode>).length - 1);
} else if (isDef(oldVnode.text)) {
api.setTextContent(elm, '');
}
} else if (oldVnode.text !== vnode.text) {
if (isDef(oldCh)) {
removeVnodes(elm, oldCh as Array<VNode>, 0, (oldCh as Array<VNode>).length - 1);
}
api.setTextContent(elm, vnode.text as string);
}
if (isDef(hook) && isDef(i = hook.postpatch)) { // 执行钩子: postpatch(定义在VNode上)
i(oldVnode, vnode);
}
}

我们最后来看下 snabbdom 是怎么处理子元素的更新的,可以总结为:

  • 如果有某一个 vNode 不存在,则变更 VNode 的指向位置,缩小处理范围
  • 如果新旧 VNode 的两个子节点都存在且是同一个节点,就会递归调用 patchVnode 的方法
  • 如果当前操作的新 VNode 子节点等于旧 VNode 子节点,则代表子节点位置被移动了,会进行插入的操作
  • 如果以上情况都不符合,则会判断新 VNode 的子节点是否存在于旧 VNode 的未操作子节点中。如果不存在,则判定为新的节点,会新建一个 DOM 执行插入操作;若存在,sel 相同,则执行更新操作后插入,若 sel 不同则直接新建子节点
  • 退出上述循环后,若新的 VNode 或者旧的 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
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
function updateChildren(parentElm: Node,
oldCh: Array<VNode>,
newCh: Array<VNode>,
insertedVnodeQueue: VNodeQueue) {
let oldStartIdx = 0, newStartIdx = 0;
let oldEndIdx = oldCh.length - 1;
let oldStartVnode = oldCh[0];
let oldEndVnode = oldCh[oldEndIdx];
let newEndIdx = newCh.length - 1;
let newStartVnode = newCh[0];
let newEndVnode = newCh[newEndIdx];
let oldKeyToIdx: any;
let idxInOld: number;
let elmToMove: VNode;
let before: any;

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVnode == null) {
oldStartVnode = oldCh[++oldStartIdx]; left
} else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx];
} else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx]; // 以上四个都是对空元素的处理
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
api.insertBefore(parentElm, oldStartVnode.elm as Node, api.nextSibling(oldEndVnode.elm as Node));
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx]; // 以上两个则是对元素移动情况的处理
} else {
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
}
idxInOld = oldKeyToIdx[newStartVnode.key as string];
if (isUndef(idxInOld)) { // 判断新的vNode是否存在旧的vNode的中,执行新增或者移动的操作
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
newStartVnode = newCh[++newStartIdx];
} else {
elmToMove = oldCh[idxInOld];
if (elmToMove.sel !== newStartVnode.sel) {
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
} else {
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
oldCh[idxInOld] = undefined as any;
api.insertBefore(parentElm, (elmToMove.elm as Node), oldStartVnode.elm as Node);
}
newStartVnode = newCh[++newStartIdx];
}
}

// ...

if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
if (oldStartIdx > oldEndIdx) {
before = newCh[newEndIdx+1] == null ? null : newCh[newEndIdx+1].elm;
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
} else {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}
}

以上就是 snabbdom 在对节点进行更新时的主要操作,可以归纳为

  • 对元素本身的更新行为是由 init 时传入的函数来进行操作,此处理只关心元素本身属性的更新
  • 元素的位置是以及是否进行新增或者更新的操作是有 snabbdom 来进行处理的,此处理只针对元素的位置和增删并不关心元素本身的更新

三、仿写

了解 snabbdom 的行为后,我们可以进行简单(不考虑特殊情况,只简单实现功能)的仿写来练练手以及加深理解

1. 首先定义一下 vNode 的类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const NODE_KEY = Symbol('vNode')

type Style = {
[key: string]: string
}

export type vNodeModal = {
tag: string
class?: string
id?: string
style?: Style
[NODE_KEY]: string
elem?: Element
children?: Array<vNodeModal | string>
}

这里我用 symbol 来做唯一的标示方便准确判断是否是 vNode 以及 vNode 是否相同

1
2
3
4
export const isVNode = (elem: vNodeModal | Element) => Boolean(elem && elem[NODE_KEY])

export const isSameNode = (node: vNodeModal, otcNode: vNodeModal) => node[NODE_KEY] === otcNode[NODE_KEY]

2. 定义构造函数

我把 tag 定义为的必填属性,key 为的私有属性,由我来帮它创建

1
2
3
4
5
6
const constructVNode = function(data: Partial<vNodeModal> & { tag: string }) {
return {
...data,
[NODE_KEY]: uuid()
}
}

3. 定义更新函数

我把的更新处理函数称为 plugin,好理解一些,所以 plugin 和这个简单的 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

const init = function (plugins = []) {
if (!plugins || !plugins.length) return null

// 把hook存起来
hooks.forEach(function(hook) {
plugins.forEach(function(plugin) {
if (plugin[hook]) {
handler[hook] ? handler[hook].push(plugin[hook]) : handler[hook] = [plugin[hook]]
}
})
})

return function(ctrlNode: Element | vNodeModal, newVNode: vNodeModal) {
let oldVNode = ctrlNode
if (!isVNode(ctrlNode)) oldVNode = transformToVNode(ctrlNode as Element)

if (handler.pre) {
handler.pre.map((preHandle) => { preHandle(oldVNode, newVNode) })
}

updateNode(oldVNode as vNodeModal, newVNode)

if (handler.finish) {
handler.finish.map((finishHandle) => { finishHandle(oldVNode, newVNode) })
}

return newVNode
}
}

接下来是更新处理判断的函数

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
// 简单判断不是同一个vNode节点或者tag变更了就直接全部更新
const updateNode = function(oldVNode: vNodeModal, newVNode: vNodeModal) {
if (!isSameVNode(oldVNode as vNodeModal, newVNode) || isTagChange(oldVNode, newVNode)) {
const newElement = createDOMByVNode(newVNode)
oldVNode.elem.replaceWith(newElement)
} else {
updateVNodeByModal(oldVNode, newVNode)
}
}

// 根据VNode去更新dom
const updateVNodeByModal = function(oldVNode: vNodeModal, newVNode: vNodeModal) {
if (handler.update.length) {
handler.update.forEach((updateHandle) => { updateHandle(oldVNode, newVNode) })
}

// 更新完元素本身后对子元素进行处理
const oCh = oldVNode.children || []
const nCh = newVNode.children || []

if (oCh.length && !nCh.length) {
removeAllChild(oldVNode.elem)
} else if (!oCh.length && nCh.length) {
inertNode(newVNode.elem, nCh)
} else if (oCh.length && nCh.length) {
diff(oldVNode, newVNode)

for(let i = 0; i < nCh.length; i++) {
if (isVNode(nCh[i])) {
const idx = oCh.findIndex((oChild) => isSameVNode(nCh[i], oChild))
if (idx > - 1) updateNode(oCh[idx] as vNodeModal, nCh[i] as vNodeModal)
}
}
}
}

// 对子元素的diff
const diff = function(oldVNode: vNodeModal, newVNode: vNodeModal) {
// 具体处理
const oCh = oldVNode.children
const nCh = newVNode.children
const nLen = nCh.length

let lastIdx = 0

const getIndex = function(checkArray: Array<vNodeModal | string>, item: vNodeModal | string) {
if (isVNode(item)) {
return checkArray.findIndex(o => isSameVNode(o as vNodeModal, item as vNodeModal))
} else {
return checkArray.findIndex(o => o === item)
}
}

// 参考react的diff策略,但字符串不考虑
for (let i = 0; i < nLen; i++) {
const oldIdx = getIndex(oCh, nCh[i])
if (oldIdx > -1) {
if (oldIdx < lastIdx) {
if (typeof oCh[oldIdx] === 'string') {
oldVNode.elem.childNodes[oldIdx].remove()
}
getElement(oCh[i]).after(getElement(oCh[oldIdx]))
}
lastIdx = Math.max(oldIdx, lastIdx)
} else {
const newElem = createDOMByVNode(nCh[i])
if (i === 0) (oldVNode as vNodeModal).elem.parentElement.prepend(newElem)
else {
if (typeof nCh[i] === 'string') (oldVNode as vNodeModal).elem.childNodes[i].after(newElem)
else getElement(nCh[i]).after(newElem)
}
}
}

for (let i = 0; i < oldVNode.children.length; i++) {
const idx = getIndex(nCh, oCh[i])
if (idx < 0) {
if (typeof oCh[i] === 'string') {
oldVNode.elem.childNodes[i].remove()
} else {
(oCh[i] as vNodeModal).elem.remove()
}
}
}
}

4. 编写插件

再来写一个用于更新 class 的插件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const getClassList = (className: string) => className ? className.split('.') : []

const updateClassName = function (oldVNode: vNodeModal, newVNode: vNodeModal) {
const elem = newVNode.elem
if (!elem) return
const oldClassList = getClassList(oldVNode.class)
const newClassList = getClassList(newVNode.class)
if (!newClassList.length) return
oldClassList.forEach((className) => {
if (!newClassList.includes(className)) {
elem.classList.remove(className)
} else {
newClassList.splice(newClassList.indexOf(className), 1)
}
})
newClassList.forEach((className) => elem.classList.add(className))
}

const updateClassPlugin = {
update: updateClassName
}

5. 使用

使用的时候这么写

{ constructVNode, vNodeModal } from './tools/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
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
import init from './tools/init'
import transFromClass from './tools/plugins/class'

import './style.css'

const inp1 = document.querySelector('#first')

const newV = constructVNode({
tag: 'div',
class: 'haha.mama',
id: 'no',
children: [
'lalala',
constructVNode({
tag: 'input',
class: 'asdad',
id: '123'
})
]
})

// 插入子元素
const patch = init([transFromClass])

let newModal = patch(inp1, newV)

// 交换子元素位置
setTimeout(() => {
const changPosModal = {
...newModal,
children: [newModal.children[1], newV.children[0]]
}

newModal = patch(newModal, changPosModal)
}, 500)

// 修改子元素属性
setTimeout(() => {
const newChildren0 = {
...newModal.children[0] as vNodeModal,
class: 'newChildren0'
}

const changClassModal = {
...newModal,
children: [newChildren0, newModal.children[1] + 'juejin']
}


newModal = patch(newModal, changClassModal)
}, 1000)

// 删除子元素
setTimeout(() => {
const deleteChildrenModal = {
...newModal,
children: []
}

newModal = patch(newModal, deleteChildrenModal)
}, 1500)

最后看看结果:

  • 原 HTML 结构
  • 定义我们的颜色,方便看
  • 运行,看结果

这样,就实现了一个非常简单 vDOM 的处理(缺失对边界的处理,特殊元素处理等)

snabbdom 做的最主要的事情就是使 dom 的结构变得更加清晰容易掌控,在我们更新 dom 元素时,帮助我们进行了一系列操作优化处理,封装了实际操作逻辑。以及提供了一系列插件可供我们使用。


这是本人的第一次写这样的文章,写得有不好的地方欢迎大家批评指证!😄