往期
前言
本文分为入门和进阶两部分,建议有经验的读者直接阅读进阶部分。
本文主要参考了vue和snabbdom这两个开源库,若读者阅读过它们的源码可以直接跳过本文 :)
入门
关于Node.insertBefore
首先我们需要知道Node.insertBefore
这个API的用法, 其函数签名如下:
// 使用这个API需要三个元素: 新结点,引用结点以及这两个结点的共同父结点
var insertedNode = parentNode.insertBefore(newNode, referenceNode);
newNode可以是用document.createElement
等API新建的DOM结点, 也可以是文档中已经存在的DOM结点。
referenceNode除了可以是文档中已经存在的DOM结点,也可以为null
值。当其为null
时newNode将被插入到父结点所有子结点的末尾。
由这个API我们可以轻松的做到同一父结点下的子结点位置交换, 举个例子:
<section>
<h1>1</h1>
<h2>2</h2>
<h3>3</h3>
</section>
const $ = s => document.querySelector(s);
const parent = $('h2').parentNode;
// 交换三级标题与二级标题的位置
parent.insertBefore($('h3'), $('h2'));
如果你对DOM不太熟悉的话,使用下面的写法可能会对结果产生困惑
// `$('h2').nextSibling`并不等于`$('h3')`, 而是一个空的text结点
parent.insertBefore($('h2').nextSibling, $('h2'));
由于h2和h3之间存在换行和缩进(或者说空格), 又因为XML解析的时候所有空格都是需要被保留的,DOM的实现就把这些空格放在了text结点中。
// 可以使用nextElementSibling, 不过IE9以下需要自己用nextSibling模拟
parent.insertBefore($('h2').nextElementSibling, $('h2'));
关于vnode和h函数
vnode本质就是一个对象,如果你使用过vue.js,你可能对下面的写法很熟悉:
// 代表一个className为foo, 文字颜色为yellowgreen, innerText为text的div元素
h('div', {
class: 'foo',
style: {
color: 'yellowgreen',
},
}, 'text');
// 包含一个span元素的div元素
h('div', {}, [
h('span', 'span in div'),
]);
注: 为了行文的简洁,本文不讨论h
函数第一个参数中带选择器的写法(即div#container.two.classes
), 以及动态class的写法, 即class: { foo: true, bar: false }
。
它们返回的vnode形式大致是这样的:
{
children: null,
data: {
class: 'foo',
style: {
color: 'yellowgreen',
},
},
tag: 'div',
text: 'text',
}
{
children: [{
children: null,
data: {},
tag: 'span',
text: 'span in div',
}],
data: {},
tag: 'div',
text: null',
}
所以我们不难写出h函数, 将第三个参数和第二个参数分别分类讨论即可:
function isPrimitive(s) {
const t = typeof s;
return t === 'number' || t === 'string';
}
function h(tag, b, c) {
let data = {};
let children = null;
let text = null;
if (c !== undefined) {
data = b;
if (Array.isArray(c)) {
children = c;
} else if (isPrimitive(c)) {
text = c;
} else if (c && c.tag) {
children = [c];
}
} else if (b !== undefined) {
if (Array.isArray(b)) {
children = b;
} else if (isPrimitive(b)) {
text = b;
} else if (b && b.tag) {
children = [b];
} else {
data = b;
}
}
if (Array.isArray(children)) {
// 若children中存在不以h函数声明的子项
children.forEach((child, i) => {
if (isPrimitive(child)) {
children[i] = {
tag: null,
data: {},
children: null,
text: child,
};
}
});
}
return {
tag, data, children, text,
};
}
让我们来试试:
// Array.isArray(c)
const n1 = h('div', {}, ['text']);
// isPrimitive(c)
const n2 = h('div', {}, 'text');
// c && c.tag
const n3 = h('div', {}, h('span', 'text'));
// 当然不会有什么问题
console.assert(n1.children[0].text === 'text');
console.assert(n2.tag === 'div');
console.assert(n3.children[0].text === 'text');
关于如何将vnode转换为DOM结点
由于DOM结点本质上是多叉树上的一个结点,所以利用递归可以简单地将vnode转换成一个DOM结点:
function isPlainObject(obj) {
return ({}).toString.call(obj) === '[object Object]';
}
function createElm(vnode) {
const {
tag, data = {}, children, text,
} = vnode;
if (tag) {
const elm = document.createElement(tag);
// class
if (data.class) {
elm.className = data.class;
}
const {
style, props, on, attrs,
} = data;
// 将所有样式应用到元素
if (isPlainObject(style)) {
Object.keys(style).map(k => {
elm.style[k] = style[k];
});
}
// 将所有props应用到元素, 如href等
if (isPlainObject(props)) {
Object.keys(props).map(k => {
elm[k] = props[k];
});
}
// 将所有attributes应用到元素,如id等
if (isPlainObject(attrs)) {
Object.keys(attrs).map(k => {
elm[k] = attrs[k];
});
}
// 在元素上绑定所有事件, 如click等
if (isPlainObject(on)) {
Object.keys(on).map(k => {
if (typeof on[k] === 'function') {
elm.addEventListener(k, on[k]);
}
});
}
if (Array.isArray(children)) {
children.forEach((child) => {
// 递归创建子元素,并添加到父元素
elm.appendChild(createElm(child));
});
}
if (text) {
elm.appendChild(document.createTextNode(text));
}
vnode.elm = elm;
} else if (text) {
// 不使用h函数的text结点
vnode.elm = document.createTextNode(text);
}
return vnode.elm;
}
进阶
关于结点类型不同时的patch
patch函数接收两个参数: oldVnode和vnode, 当oldVnode为真实DOM结点时,将其转换为elm属性不为空的vnode, 然后判断oldVnode和vnode的tag属性值是否相等; 不相等则需要创建新的元素,即调用上文中的createElm
函数
function isRealElement(node) {
return node.nodeType !== undefined;
}
// 转换为elm属性不为空的vnode
function emptyNodeAt(elm) {
return {
tag: elm.tagName.toLowerCase(),
data: {},
children: [],
text: null,
elm,
};
}
function sameVnode(vnode1, vnode2) {
return vnode1.tag === vnode2.tag;
}
function patch(oldVnode, vnode) {
// 将真实DOM结点转为vnode
if (isRealElement(oldVnode)) {
oldVnode = emptyNodeAt(oldVnode);
}
if (sameVnode(oldVnode, vnode)) {
// 让我们暂时忽略这个函数, 先关注下面的逻辑 :)
patchVnode(oldVnode, vnode);
} else {
const { elm } = oldVnode;
const parent = elm.parentNode;
if (parent) {
const newNode = createElm(vnode);
const referenceNode = elm.nextSibling;
// 将新建的DOM结点插入到oldVnode所在结点的下个兄弟结点前
parent.insertBefore(newNode, referenceNode);
// 删除oldVnode所在结点
parent.removeChild(elm);
}
}
}
关于结点类型相同时的patch
我们可以先根据vnode的text属性来更新,如果vnode的text属性存在,我们便不需关心oldVnode,只需要设置元素的textContent
值即可。
当vnode的text属性不存在时, 则需要分类讨论:
oldVnode.children
和vnode.children
都存在且不等,这种情况是最复杂的,需要diff同一层的新旧结点(具体见updateChildren
函数的注释)oldVnode.children
不存在而vnode.children
存在, 这种情况比较简单,只需要构建vnode.children
的所以DOM结点并添加到oldVnode.elm
上即可。需要注意的是当oldVnode
的text属性存在时需要将oldVnode.elm
的textContext置空vnode.children
不存在而oldVnode.children
存在, 删除原有结点和监听的事件即可
patchVnode
函数就是完成上文分类讨论的函数,注意注释里的内容!
function patchVnode(oldVnode, vnode) {
// 为后文的updateStyle等函数,将vnode.elm置为oldVnode.elm
vnode.elm = oldVnode.elm;
const { elm } = oldVnode;
const oldCh = oldVnode.children;
const ch = vnode.children;
if (oldVnode === vnode) {
return;
}
// update props/attributes类似,为了行文简洁这里就省略了
updateStyle(oldVnode, vnode);
if (vnode.text == null) {
if (oldCh && ch && oldCh !== ch) {
updateChildren(elm, oldCh, ch);
} else if (ch) {
// 若oldVnode存在text结点则置空
if (oldVnode.text) {
elm.textContent = '';
}
// oldVnode.children不存在, 添加新结点
addVnodes(elm, ch);
} else if (oldCh) {
// vnode.children不存在, 删除原有结点和监听的事件
removeVnodes(elm, oldCh);
} else if (oldVnode.text) {
elm.textContent = '';
}
} else if (vnode.text !== oldVnode.text) {
// vnode的text属性值不为空且已改变, 即使原有结点存在诸多子元素也不需要逐个调用removeChild
elm.textContent = vnode.text;
// 删除监听的事件
removeVnodes(null, oldCh);
}
}
function updateStyle(oldVnode, vnode) {
const { elm } = vnode;
let { data: { style: oldStyle } } = oldVnode;
let { data: { style } } = vnode;
if (!oldStyle && !style) {
return;
}
if (oldStyle === style) {
return;
}
// 有可能为undefined
style = style || {};
oldStyle = oldStyle || {};
Object.keys(oldStyle).forEach((name) => {
if (!style[name]) {
elm.style[name] = '';
}
});
Object.keys(style).forEach((name) => {
if (style[name] !== oldStyle[name]) {
elm.style[name] = style[name];
}
});
}
function removeVnodes(parentNode, vnodes) {
vnodes.forEach(({ elm, data }) => {
if (!elm) {
return;
}
if (data) {
if (isPlainObject(data.on)) {
Object.keys(data.on).forEach((event) => {
elm.removeEventListener(event, data.on[event]);
});
}
if (parentNode) {
parentNode.removeChild(elm);
}
}
});
}
function addVnodes(parentNode, vnodes) {
vnodes.forEach((vnode) => {
if (vnode) {
parentNode.appendChild(createElm(vnode));
}
});
}
diff同一层的所有新旧vnode, 若tag属性值相等则递归更新,不相等则增加/删除元素, 注意注释里的内容!
function updateChildren(parentNode, oldVnodes, vnodes) {
let oldStartIdx = 0;
let oldEndIdx = oldVnodes.length - 1;
let oldStartVnode = oldVnodes[oldStartIdx];
let oldEndVnode = oldVnodes[oldEndIdx];
let startIdx = 0;
let endIdx = vnodes.length - 1;
let startVnode = vnodes[startIdx];
let endVnode = vnodes[endIdx];
while (oldStartIdx <= oldEndIdx && startIdx <= endIdx) {
// 记旧子结点中第一个为A, 最后一个为B
// 记新子结点中第一个为C, 最后一个为D
if (sameVnode(oldStartVnode, startVnode)) {
// AC类型相等
patchVnode(oldStartVnode, startVnode);
oldStartVnode = oldVnodes[oldStartIdx += 1];
startVnode = vnodes[startIdx += 1];
} else if (sameVnode(oldEndVnode, endVnode)) {
// BD类型相等
patchVnode(oldEndVnode, endVnode);
oldEndVnode = oldVnodes[oldEndIdx -= 1];
endVnode = vnodes[endIdx -= 1];
} else if (sameVnode(oldStartVnode, endVnode)) {
// AD类型相等
patchVnode(oldStartVnode, endVnode);
// oldStartVnode所在DOM元素插到所有子元素最末尾, 保证endVnode的位置正确
parentNode.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
oldStartVnode = oldVnodes[oldStartIdx += 1];
endVnode = vnodes[endIdx -= 1];
} else if (sameVnode(oldEndVnode, startVnode)) {
// BC类型相等
patchVnode(oldEndVnode, startVnode);
// oldEndVnode所在DOM元素插到所有子元素最前, 保证startVnode的位置正确
parentNode.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldVnodes[oldEndIdx -= 1];
startVnode = vnodes[startIdx += 1];
} else {
// ABCD类型都不相等
parentNode.insertBefore(createElm(startVnode), oldStartVnode.elm);
startVnode = vnodes[startIdx += 1];
}
}
// oldVnodes元素个数大于vnodes元素个数, 即添加了某些元素
if (oldStartIdx > oldEndIdx) {
// 下个兄弟结点/null
const referenceNode = vnodes[endIdx + 1] == null ? null : vnodes[endIdx + 1].elm;
for (let i = startIdx; i <= endIdx; i += 1) {
const vnode = vnodes[i];
if (vnode) {
parentNode.insertBefore(createElm(vnode), referenceNode);
}
}
} else if (startIdx > endIdx) {
// vnodes元素个数大于oldVnodes元素个数, 即删除了某些元素
removeVnodes(parentNode, oldVnodes.slice(oldStartIdx, oldEndIdx + 1));
}
}
我们可以试试:
<main id="container"></main>
<script>
const $ = s => document.querySelector(s);
const oldVnode = h('div', [
h('span', {
attrs: { id: 'abc' },
style: { fontWeight: 'bold' },
on: {
click: (evt) => {
console.warn(evt);
},
},
}, 'This is bold'),
'a',
h('a', {props: {href: '/foo'}}, 'I\'ll take you places!')
]);
const vnode = h('div', [
h('section', {
attrs: { id: 'abc' },
style: { fontWeight: 'bold', color: 'green' },
}, 'This is bold'),
'b',
h('span', {props: {href: '/foo'}}, 'I\'ll take you places!')
]);
patch($('#container'), oldVnode);
setTimeout(() => {
// 一秒后更新视图
patch(oldVnode, vnode);
}, 1000);
</script>
好了,以上就是本文关于Virtual DOM的全部内容了。下篇文章将暂时撇开框架源码,探讨一下Mixins到HOC/Render Props再到Hooks的组合复用模式。