本文分爲入門和進階兩部分,建議有經驗的讀者直接閱讀進階部分。javascript
本文主要參考了vue和snabbdom這兩個開源庫,若讀者閱讀過它們的源碼能夠直接跳過本文 :)html
首先咱們須要知道Node.insertBefore
這個API的用法, 其函數簽名以下:vue
// 使用這個API須要三個元素: 新結點,引用結點以及這兩個結點的共同父結點
var insertedNode = parentNode.insertBefore(newNode, referenceNode);
複製代碼
newNode能夠是用document.createElement
等API新建的DOM結點, 也能夠是文檔中已經存在的DOM結點。java
referenceNode除了能夠是文檔中已經存在的DOM結點,也能夠爲null
值。當其爲null
時newNode將被插入到父結點全部子結點的末尾。node
由這個API咱們能夠輕鬆的作到同一父結點下的子結點位置交換, 舉個例子:app
<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結點中。dom
// 可使用nextElementSibling, 不過IE9如下須要本身用nextSibling模擬
parent.insertBefore($('h2').nextElementSibling, $('h2'));
複製代碼
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 }
。post
它們返回的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');
複製代碼
因爲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函數接收兩個參數: 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);
}
}
}
複製代碼
咱們能夠先根據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的組合複用模式。