關於 Virtual Dom 的簡單瞭解(snabbdom,Vue, React)
阿新 • • 發佈:2019-02-19
- Virtual Dom 即根據最終狀態在記憶體中繪製出一棵 Virtual Dom Tree,使用 Diff 演算法與現存的 Dom Tree 對比並更新。
- Virtual Dom 並不能提升效能, 直接操作 Dom 理論上是最快的。
2. 深入淺出
1.) Virtual Node
/**
sel [string]: 選擇器, 比如 'div#id.class1.class2'
data [any]: 該節點屬性(包括style、class等)
children (Array[]Vnode): 子節點(也由此函式建立)
text [string]: 節點內部的 text
ele [HTMLElement]: Dom 元素
**/
function vnode(sel, data, children, text, elm) (
// 是否包含key,在list中元素變動時有些許效能影響
let key = data === undefined ? undefined : data.key;
return {sel: sel, data: data, children: children,
text: text, elm: elm, key: key};
}
並不是每次建立虛擬節點都需要那麼多引數,下面會根據傳入的引數來調整 vnode
的函式的產出:
/**
sel: 元素選擇器
b: 如果是陣列或包含sel屬性的object, 則為子節點; 如果是string, 則是文字節點; 否則就是data
c: 如果存在, 就肯定是子節點,同時b是data(型別判斷早於b)
**/
function h(sel, b, c) {
var data = {}, children, text, i;
if (c !== undefined) {
// ...
} else if (b !== undefined) {
// ...
}
if (is.array(children)) {
for (i = 0; i < children.length; ++i) {
// 如果該元素是字串或數字, 那麼就是個純文字節點
if (is.primitive(children[i]))
children[i] = vnode(undefined, undefined, undefined, children[i], undefined);
}
}
return vnode(sel, data, children, text, undefined);
}
通過以上方法,就可以創建出一整棵 Dom Tree 了, 如下:
var data = [
{id: 65, content: 'A'},
{id: 66, content: 'B'},
{id: 67, content: 'C'},
];
var tree = h('ul', data.map(node => {
return h('li', {key: node.id}, node.content)
}))
// 得到的虛擬dom結構如下:
// <ul>
// <li>A</li>
// <li>B</li>
// <li>C</li>
// </ul>
2.) 從Virtual Dom 到 Real Dom
這是從 Virtual Dom 對映到 Real Dom 的 main 函式:
// oldVNode: 可以是 HTMLElement(第一次呼叫) 也可以是上一次生成的虛擬Dom tree
// Vnode: 根據最新狀態形成的虛擬Dom Tree
function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node;
// 用於生命週期 inserted 階段,記錄下所有新插入的節點以備呼叫
const insertedVnodeQueue: VNodeQueue = [];
// 整個 Diff 過程模組可註冊的鉤子(跳過)
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
if (!isVnode(oldVnode)) {
// 將 HTMLElement 轉換成 VNode
oldVnode = emptyNodeAt(oldVnode);
}
// 如果兩個節點相似(節點的 sel 與 key 完全相等)則更新
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode, insertedVnodeQueue);
} else { // 否則直接替換
elm = oldVnode.elm as Node;
parent = api.parentNode(elm);
createElm(vnode, insertedVnodeQueue);
if (parent !== null) {
// 取代 oldNode 的位置
api.insertBefore(parent, vnode.elm as Node, api.nextSibling(elm));
removeVnodes(parent, [oldVnode], 0, 0);
}
}
// 整個 Diff 過程所有節點可註冊的節點插入後呼叫的鉤子(跳過)
for (i = 0; i < insertedVnodeQueue.length; ++i) {
(((insertedVnodeQueue[i].data as VNodeData).hook as Hooks).insert as any)(insertedVnodeQueue[i]);
}
// 整個 Diff 過程模組可註冊的鉤子(跳過)
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
return vnode;
};
可見,整個流程比較清晰,替換節點或者在節點相似時更新節點(註釋中提到,程式碼通過比較key以及選擇器來判斷是否相似,當沒有指定key時,那麼元素只能通過選擇器來判斷)。下面處理節點更新細節的 patchVnode
函式:
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);
}
const elm = vnode.elm = (oldVnode.elm as Node);
let oldCh = oldVnode.children;
let ch = vnode.children;
// 完全是同一個物件,不作處理
if (oldVnode === vnode) return;
// 更新時呼叫的生命週期鉤子,包括模組以及節點自身的
if (vnode.data !== undefined) {
// 這裡如果引入了class模組,那麼就會更新class屬性;引入style模組,則會更新元素的樣式
// 此節點的更新都再這個迴圈裡處理了
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);
}
// 開始處理子節點
// 如果不是純文字
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) {
api.setTextContent(elm, vnode.text as string);
}
// 生命週期鉤子,更新完成後呼叫
if (isDef(hook) && isDef(i = hook.postpatch)) {
i(oldVnode, vnode);
}
}
整個框架的模組也是重要的組成部分。使用者可以載入不同的模組讓框架選擇性的更新 Dom 中元素的屬性。
下面是某節點的所有子節點更新函式:
function updateChildren(parentElm: Node,
oldCh: Array<VNode>,
newCh: Array<VNode>,
insertedVnodeQueue: VNodeQueue) {
// 幾個變數:
// 1. 舊子節點陣列的 startIndex, endIndex, startNode, endNode
// 2. 新子節點陣列的 startIndex, endIndex, startNode, endNode
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]; // Vnode might have been moved 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(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
// 1,2,3,4,5 --> 2,3,4,5,1 (處理一些特殊情況?reverse也可以用到)
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 (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
// 1,2,3,4,5 --> 5,1,2,3,4 (處理一些特殊情況?reverse也可以用到)
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
api.insertBefore(parentElm, oldEndVnode.elm as Node, oldStartVnode.elm as Node);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
// 如果每個子元素都有 key(可識別),那麼亂序後大概率會進這裡(list)
if (oldKeyToIdx === undefined) {
// 建立一個關於舊子元素陣列的, key --> index 的對映(map)
// 沒有key則不存在於map中
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
}
// 新子元素,獲取該新元素在舊陣列中的位置(如果有key)
idxInOld = oldKeyToIdx[newStartVnode.key as string];
if (isUndef(idxInOld)) { // New element
// 不存在在舊子元素陣列中(如果沒有指定key也會進入這裡)
// 根據這個新元素建立dom元素插入
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
newStartVnode = newCh[++newStartIdx];
} else {
// 說明不是新元素,只是位置變了
elmToMove = oldCh[idxInOld]; // 與新元素對應的舊元素
if (elmToMove.sel !== newStartVnode.sel) {
// 雖然key相同,但是元素tag已經不同了
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);
}
}
}
整個子節點 Diff 的過程也比較簡單。這樣也可以解答使用過程中的一個疑惑了,“為什麼給list中的元素新增唯一的key可以提升效能”?
因為在比對列表元素的過程中,一旦有了key值就可以複用之前的元素本身,而免去更新多數元素的過程了。比如原本是 [A, B, C, D, E]
, 此時往B與C之間插入元素F。
如果沒有攜帶 key 值,整個過程就是將C更新為F,D更新為C,E更新為D,最後新增元素E;
如果攜帶了key值,則會進入最後一個 ifelse
,直接在B之後插入元素F,也不用更新其他任何元素。