snabbdom 虚拟DOM 源码解析
高性能JavaScript中很重要的一条就是减少DOM操作。打开一个DOM
可以看到浏览器对DOM的设计非常复杂。少数还好,当我们要对上千甚至上万的DOM进行操作时就肯定会导致浏览器卡死。
在浏览器中打印一个真实DOM为
var vnode = {
sel: "div#container.two.classes", // string类型 代表虚拟节点
data: {"on": {"click": undefined}}, // Object类型 为虚拟节点信息,比如样式、css类、属性等
children: [], // Array类型 代表虚拟节点的子节点
text: undefined, // 字符串或者undefined类型 当且仅当节点是个文本节点时,text有值
elm: undefined, // Element类型 指向snabbdom创建的实际DOM节点的指针
key: undefined, // string | number类型 用于保存指向以前存在的DOM节点的指针,以避免不必要时重新创建它们。这对于列表重新排序之类的事情非常有用。键必须是字符串或数字,以便进行适当的查找,因为它作为键/值对存储在对象内部,其中.key是键,值是.elm属性创建的
}
实现一个虚拟DOM,我们一般有如下三个步骤需要完成:
1、 compile
把真实的DOM编译成vnode
2、 diff
比较oldVnode和newVnode之间的不同
3、 patch
通过第二步的比较知道不同点,然后把不同的虚拟DOM渲染到真实的DOM上去
snabbdom
实现
当前参考的是tag:
v0.5.4
版本https://github.com/snabbdom/snabbdom/tree/v0.5.4
dist/snabbdom.js
才300多行比较少,包含三部分:h函数
、 vnode对象
、 patch方法
。
h函数
/**
* @param sel 虚拟节点
* @param b 节点属性
* @param c 子节点
*/
module.exports = function h(sel, b, c) {
var data = {}, children, text, i;
if (c !== undefined) {
data = b;
if (is.array(c)) { children = c; }
else if (is.primitive(c)) { text = c; }
} else if (b !== undefined) {
if (is.array(b)) { children = b; }
else if (is.primitive(b)) { text = b; }
else { data = b; }
}
if (is.array(children)) {
for (i = 0; i < children.length; ++i) {
if (is.primitive(children[i])) children[i] = VNode(undefined, undefined, undefined, children[i]);
}
}
// svg 单独处理
if (sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g') {
addNS(data, children, sel);
}
return VNode(sel, data, children, text, undefined);
};
内联例子
<script src="./dist/h.js"></script>
<script>
var vnode = h('div#container.two.classes', { on: { click: console.log(11) } }, [
h('span', { style: { fontWeight: 'bold' } }, 'This is bold'),
' and this is just normal text',
h('a', { props: { href: '/foo' } }, 'I\'ll take you places!')
]);
console.log(vnode);
</script>
打印如下:
vnode对象
需要注意的是,如果 text
有值就代表这是一个文本节点。
module.exports = function(sel, data, children, text, elm) {
var key = data === undefined ? undefined : data.key;
return {sel: sel, data: data, children: children,
text: text, elm: elm, key: key};
};
同上
patch方法
patch
有两种用法:
patch(container, vnode) // container为容器DOM元素
patch(vnode, newVnode) // 两个vnode的比较
示例
<script src="./dist/h.js"></script>
<script src="./dist/snabbdom.js"></script>
<div id="container"></div>
<script>
var container = document.getElementById('container');
var patch = snabbdom.init([]);
var vnode = h('div#container.one.classes', { on: { click: function () { } } }, [
// h('span', { style: { fontWeight: 'bold' } }, 'This is bold'),
// ' and this is just normal text',
// h('a', { props: { href: '/foo' } }, 'I\'ll take you places!')
]);
patch(container, vnode);
var newVnode = h('div#container.two.classes', { on: { click: function () { } } }, [
// h('span', { style: { fontWeight: 'normal', fontStyle: 'italic' } }, 'This is now italic type'),
// ' and this is still just normal text',
// h('a', { props: { href: '/bar' } }, 'I\'ll take you places!')
]);
// patch(vnode, newVnode);
</script>
init
函数:
function init(modules, api) {
return function(oldVnode, vnode) {
// 省略一些对钩子、周期的处理...
// 省略一些sel为空的处理...
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode, insertedVnodeQueue);
} else {
elm = oldVnode.elm;
parent = api.parentNode(elm);
createElm(vnode, insertedVnodeQueue);
if (parent !== null) {
api.insertBefore(parent, vnode.elm, api.nextSibling(elm));
removeVnodes(parent, [oldVnode], 0, 0);
}
}
// 省略一些对钩子、周期的处理...
return vnode;
}
}
sameVnode
函数,比较两个 vnode
是否相等。比较的方式就是 vnode1.key === vnode2.key && vnode1.sel === vnode2.sel
,我们在 vue
、 react
中常说到的 key
可以加快 vnode
计算就来源于这里,可以减少DOM操作。
如果两个 vnode
相等,则进入 patchVnode
;如果不相等(如:从 div
变成了 span
或者 key
改变),意味着整个 vnode
被替换,因为我们通常不会去跨层移动 vnode
所以没有必要再去比较,直接调用创建DOM的函数 createElm
。
patchVnode
函数:
通过diff算法实现两颗虚拟DOM树的最小更新。
function patchVnode(oldVnode, vnode, insertedVnodeQueue) {
// 一些钩子
var i, hook;
if (isDef(i = vnode.data) && isDef(hook = i.hook) && isDef(i = hook.prepatch)) {
i(oldVnode, vnode);
}
// 因为上一层已经通过 sameVnode 函数比较两个 vnode 是相等的了,所以这里直接赋值
var elm = vnode.elm = oldVnode.elm, oldCh = oldVnode.children, ch = vnode.children;
if (oldVnode === vnode) return;
// 跟 init 函数中的一样,如果不是相同的 vnode 则节点没有对比的必要,直接创建DOM
if (!sameVnode(oldVnode, vnode)) {
var parentElm = api.parentNode(oldVnode.elm);
elm = createElm(vnode, insertedVnodeQueue);
api.insertBefore(parentElm, elm, oldVnode.elm);
removeVnodes(parentElm, [oldVnode], 0, 0);
return;
}
// update 钩子
if (isDef(vnode.data)) {
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
i = vnode.data.hook;
if (isDef(i) && isDef(i = i.update)) i(oldVnode, vnode);
}
// vnode.text 有值则为文本节点
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
// 1、比较 old children 和 new children 不相等则更新
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
} else if (isDef(ch)) {
// 2、如果只有 new children 则往下加DOM
if (isDef(oldVnode.text)) api.setTextContent(elm, '');
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
} else if (isDef(oldCh)) {
// 3、如果只有 old children 则移除 vnode
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
} else if (isDef(oldVnode.text)) {
// 4、vnode.text 有值则为文本节点
api.setTextContent(elm, '');
}
} else if (oldVnode.text !== vnode.text) {
api.setTextContent(elm, vnode.text);
}
if (isDef(hook) && isDef(i = hook.postpatch)) {
i(oldVnode, vnode);
}
}
updateChildren
函数:
这里是最核心逻辑,比较新旧children并更新。
/**
* 1、优先处理特殊场景,先对比两端。也就是
* 旧 vnode 头 vs 新 vnode 头
* 旧 vnode 尾 vs 新 vnode 尾
* 旧 vnode 头 vs 新 vnode 尾
* 旧 vnode 尾 vs 新 vnode 头
* 2、首尾不一样的情况,寻找 key 相同的节点,找不到则新建元素
* 3、如果找到 key,但是,元素选择器变化了,也新建元素
* 4、如果找到 key,并且元素选择没变, 则移动元素
* 5、两个列表对比完之后,清理多余的元素,新增添加的元素
*
* 不提供 key 的情况下,如果只是顺序改变的情况,例如第一个移动到末尾。这个时候,会导致其实更新了后面的所有元素
*/
function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue) {
var oldStartIdx = 0, newStartIdx = 0;
var oldEndIdx = oldCh.length - 1;
var oldStartVnode = oldCh[0];
var oldEndVnode = oldCh[oldEndIdx];
var newEndIdx = newCh.length - 1;
var newStartVnode = newCh[0];
var newEndVnode = newCh[newEndIdx];
var oldKeyToIdx, idxInOld, elmToMove, before;
// 遍历 oldCh 和 newCh
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 前6个if都是对 oldCh 和 newCh 的开始节点、末尾节点排列组合的方式进行比较,相同则递归
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx]; // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
api.insertBefore(parentElm, oldStartVnode.elm, api.nextSibling(oldEndVnode.elm));
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
api.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
// 如果 oldCh 和 newCh 都不相同:
// 如果 oldKeyToIdx 不存在,创建 old children 中 vnode 的 key 到 index 的映射,方便我们之后通过 key 去拿下标
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
idxInOld = oldKeyToIdx[newStartVnode.key];
if (isUndef(idxInOld)) { // New element
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm);
newStartVnode = newCh[++newStartIdx];
} else {
elmToMove = oldCh[idxInOld];
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
oldCh[idxInOld] = undefined;
api.insertBefore(parentElm, elmToMove.elm, oldStartVnode.elm);
newStartVnode = newCh[++newStartIdx];
}
}
}
// 循环结束后,还可能存在一些未处理的 vnode
if (oldStartIdx > oldEndIdx) {
before = isUndef(newCh[newEndIdx+1]) ? null : newCh[newEndIdx+1].elm;
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
} else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}
createElm
函数:
通过 createElm
创建真正的 dom
节点
function createElm(vnode, insertedVnodeQueue) {
var i, data = vnode.data;
// 调用元素的 init hook
if (isDef(data)) {
if (isDef(i = data.hook) && isDef(i = i.init)) {
i(vnode);
data = vnode.data;
}
}
var elm, children = vnode.children, sel = vnode.sel;
// 如果不是注释节点
// 如果存在选择器,会对这个选择器做解析,得到 tag、id 和 class,然后调用 createElement 或 createElementNS 来生成节点,并挂载到 vnode.elm
// 如果存在 children,遍历所有子节点并递归调用 createElm 创建 dom,通过 appendChild 挂载到当前的 elm 上
// 如果不存在 children 但存在 text,便使用 createTextNode 来创建文本
// 最后调用调用元素上的 create hook 和保存存在 insert hook 的 vnode,因为 insert hook 需要等 dom 真正挂载到 document 上才会调用,这里用个数组来保存可以避免真正需要调用时需要对 vnode 树做遍历
// 如果当前元素是注释节点,会调用 createComment 来创建一个注释节点,然后挂载到 vnode.elm
if (isDef(sel)) {
// Parse selector
var hashIdx = sel.indexOf('#');
var dotIdx = sel.indexOf('.', hashIdx);
var hash = hashIdx > 0 ? hashIdx : sel.length;
var dot = dotIdx > 0 ? dotIdx : sel.length;
var tag = hashIdx !== -1 || dotIdx !== -1 ? sel.slice(0, Math.min(hash, dot)) : sel;
elm = vnode.elm = isDef(data) && isDef(i = data.ns) ? api.createElementNS(i, tag)
: api.createElement(tag);
if (hash < dot) elm.id = sel.slice(hash + 1, dot);
if (dotIdx > 0) elm.className = sel.slice(dot + 1).replace(/\./g, ' ');
if (is.array(children)) {
for (i = 0; i < children.length; ++i) {
api.appendChild(elm, createElm(children[i], insertedVnodeQueue));
}
} else if (is.primitive(vnode.text)) {
api.appendChild(elm, api.createTextNode(vnode.text));
}
for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode);
i = vnode.data.hook; // Reuse variable
if (isDef(i)) {
if (i.create) i.create(emptyNode, vnode);
if (i.insert) insertedVnodeQueue.push(vnode);
}
} else {
elm = vnode.elm = api.createTextNode(vnode.text);
}
return vnode.elm;
}