虛擬DOM(Virtual DOM
)是Vue和React框架實現數據動態更新視圖的關鍵技術,它利用JS運算速度優與DOM從而大大提升的視圖渲染性能。因爲虛擬DOM是用一個普通對象來表示視圖節點結果,因此能夠利用這個對象來渲染到不一樣平臺,生成對應的原生控件來實現跨平臺。node
咱們先來看下面一個例子:算法
var container = document.getElementById('app');
var vnode = h('div', { key: 'app' }, [h('h1', {}, 'I am old vnode')]);
var newVnode = h('div', { key: 'app' }, [
h('span', {}, 'after two second'),
h('h1', {}, 'I am new vnode')
]);
// 首次掛載
patch(container, vnode);
setTimeout(() => {
patch(vnode, newVnode);
}, 2000);
複製代碼
咱們建立一個虛擬節點vnode掛載到一個<div id="app"></div>
上,兩秒後和新的虛擬節點newVnode比對,更新視圖。其中h()
函數是建立虛擬節點,patch()
函數是對比兩個虛擬節點,實現更新DOM。app
由於虛擬節點就是一個JS對象,因此咱們能夠定義一個VNode類來表示節點。由於考慮到參數處理,能夠定義一個建立節點函數,在函數內對參數進行處理並用new VNode()
返回一個虛擬節點:框架
// VNode類
function VNode(tag, data, ch, text, elm) {
this.tag = tag || '';
this.data = data || {};
this.children = ch;
this.text = text;
this.key = data.key || '';
this.elm = elm || null;
}
// 建立vnode函數
function h(tag, data, ch) {
var text, children;
if (ch) {
// 考慮子節點是文本
if (typeof ch === 'string') {
text = ch;
} else {
children = ch;
}
}
if (children && children.length) {
for (var i = 0; i < children.length - 1; i++) {
if (typeof children[i] === 'string') {
// 把子節點所有轉爲vnode
children[i] = new VNode(undefined, undefined, undefined, children[i]);
}
}
}
return new VNode(tag, data, children, text);
}
複製代碼
由於咱們須要對DOM進行操做,預先定義一些操做函數:dom
var domApi = {
createElement: function(tag) {
return document.createElement(tag);
},
createTextNode: function(text) {
return document.createTextNode(text);
},
appendChild: function(node, child) {
node.appendChild(child);
},
removeChild: function(node, child) {
node.removeChild(child);
},
insertBefore: function(parent, node, before) {
parent.insertBefore(node, before);
},
setTextContent: function(node, text) {
node.textContent = text;
},
parentNode: function(node) {
return node.parentNode;
},
nextSibling: function(node) {
return node.nextSibling;
}
};
複製代碼
另外,建立一個判斷是否爲同一個虛擬節點的函數:函數
// 簡單判斷兩個vnode是不是同一個節點
function sameVnode(oldVnode, vnode) {
return oldVnode.tag === vnode.tag && oldVnode.key === vnode.key;
}
複製代碼
patch()
函數是對比兩個虛擬節點更新視圖的入口。因爲考慮到第一次掛載的時候oldVnode是DOM元素,須要一個DOM轉爲vnode的函數:post
// 把DOM元素轉爲簡單的vnode
function toVnode(elm) {
var tag = elm.tagName.toLowerCase();
return new VNode(tag, {}, [], undefined, elm);
}
複製代碼
若是兩個虛擬節點不是同一個節點,直接用vnode建立一個DOM元素,替換原來的DOM便可。替換是用新DOM利用insertBefore()
插入的老DOM的旁邊,而後移除老的DOM元素:性能
function patch(oldVnode, vnode) {
if (oldVnode.tag === undefined) {
oldVnode = toVnode(oldVnode);
}
if (sameVnode(oldVnode, vnode)) {
// 進行比對
patchVnode(oldVnode, vnode);
} else {
var elm = oldVnode.elm;
var parentElm = domApi.parentNode(elm);
// 建立DOM元素
createElm(vnode);
if (parentElm) {
// 插入新DOM
domApi.insertBefore(parentElm, vnode.elm, elm);
// 移除老的DOM
removeVnodes(parentElm, [oldVnode], 0, 0);
}
}
}
複製代碼
增長createElm()
函數把虛擬節點vnode轉爲真實的DOM,這裏要對虛擬節點的子節點children進行遞歸轉化:優化
function createElm(vnode) {
if (vnode.tag) {
var elm = (vnode.elm = domApi.createElement(vnode.tag));
if (vnode.children && vnode.children.length) {
// 遞歸轉化children
vnode.children.forEach(function(child) {
domApi.appendChild(elm, createElm(child));
});
} else {
domApi.appendChild(elm, domApi.createTextNode(vnode.text));
}
} else {
// 文本節點
vnode.elm = domApi.createTextNode(vnode.text);
}
return vnode.elm;
}
複製代碼
增長addVnodes()
函數批量插入虛擬節點到真實的DOM中:ui
function addVnodes(parent, before, vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
var vnode = vnodes[startIdx];
if (vnode) {
domApi.insertBefore(parent, createElm(vnode), before);
}
}
}
複製代碼
增長removeVnodes()
函數批量刪除DOM中的節點:
function removeVnodes(parent, vnodes, startIdx, endIdx) {
var vnode, elm;
for (var i = startIdx; i <= endIdx; ++i) {
vnode = vnodes[i];
elm = vnode.elm;
domApi.removeChild(parent, elm);
}
}
複製代碼
在patch(
)函數中若是是同一個節點,咱們要進行節點的diff。如下是Diff算法的大概流程:
新虛擬節點有文本屬性:移除老虛擬節點的子節點,插入新文本。
新虛擬節點無文本屬性:
新節點有children:若是老節點也有children,須要進行下一輪的比對。若是沒有children,則清空老節點的文本後插入新節點的children。
新節點無children:刪除老節點的children或者文本。
算法的流程圖以下:
實現節點對比函數patchVnode()
:
function patchVnode(oldVnode, vnode) {
if (oldVnode === vnode) return;
var elm = (vnode.elm = oldVnode.elm);
var oldCh = oldVnode.children;
var newCh = vnode.children;
if (!vnode.text) {
if (oldCh && newCh) {
updateChildren(elm, oldCh, newCh);
} else if (newCh) {
// 只有新的vnode有children
if (oldVnode.text) {
domApi.setTextContent(elm, '');
}
addVnodes(elm, newCh, 0, newCh.length - 1);
} else if (oldCh) {
// 只有老的vnode有children
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
} else {
domApi.setTextContent(elm, '');
}
} else {
// 新的vnode爲文本節點
if (oldCh) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
}
domApi.setTextContent(elm, vnode.text);
}
}
複製代碼
上面咱們提到當vnode和oldVnode都有子節點時,須要比對更新子節點。比對的大體流程爲循環新的子節點,找出在老子節點中相同的節點:
patchVnode()
函數進行更新。而後判斷是否須要移動子節點。以上算法的時間複雜度爲O(n^2)
,能夠用下面快速查找的方式優化到O(n)
複雜度。
在每輪循環中,先進行下面4種比較:
上面的第一個和最後一個都是針對未處理範圍的而言。在正確循環體內的子節點都是未處理的。若是上面4種快捷查找都沒找到,則遍歷查找老的子節點。
很明顯,上面的優化策略咱們須要4個索引進行跟蹤。在退出循環後,存在下面兩種狀況:
代碼實現updateChildren()
以下:
function updateChildren(parentElm, oldCh, newCh) {
if (oldCh === newCh) return;
var oldStartIdx = 0,
oldEndIdx = oldCh.length - 1,
oldStartVnode = oldCh[0],
oldEndVnode = oldCh[oldEndIdx],
newStartIdx = 0,
newEndIdx = newCh.length - 1,
newStartVnode = newCh[0],
newEndVnode = newCh[newEndIdx],
oldKeyToIdx,
idxInOld;
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVnode == null) {
oldStartVnode = oldCh[++oldStartIdx];
} 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)) {
parentNode(oldStartVnode, newStartVnode);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode);
// 把節點移動到爲處理的最後面
domApi.insertBefore(parentElm, oldStartVnode.elm, oldEndVnode.elm);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode);
// 把節點移動到未處理的最前面
domApi.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
// 老的vnode的key到idx的映射
// 這裏只求出爲爲處理節點的映射,一點小優化
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToIdx(oldCh, oldStartIdx, oldEndIdx);
}
idxInOld = oldKeyToIdx[newStartVnode.key];
if (idxInOld === undefined) {
// 子節點是新增的節點
domApi.insertBefore(parentElm, createElm(newStartVnode), oldStartVnode.elm);
} else {
var moveVnode = oldCh[idxInOld];
if (moveVnode.tag !== newStartVnode.tag) {
// key相同可是tag不一樣也爲新增
domApi.insertBefore(parentElm, createElm(newStartVnode), oldStartVnode.elm);
} else {
patchVnode(moveVnode, newStartVnode);
// 把節點移動到未處理的最前面
domApi.insertBefore(parentElm, moveVnode.elm, oldStartVnode.elm);
oldCh[idxInOld] = null;
}
}
newStartVnode = newCh[++newStartIdx];
}
}
if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
if (oldStartIdx > oldEndIdx) {
// 插入剩餘新增的子節點
var before = newCh[newEndIdx + 1] ? newCh[newEndIdx + 1].elm : null;
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx);
} else {
// 刪除廢棄的子節點
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}
}
複製代碼