什么是虛擬dom
虛擬DOM是一種使用js對象來描述真實DOM的技術(shù),通過這種技術(shù),一方面我們能精確知道哪些真實DOM改變了,從而盡量減少DOM操作的性能開銷。另外一方面由于真實DOM都通過js對象來描述了,所以我們可以嘗試使用數(shù)據(jù)來驅(qū)動DOM開發(fā),比如著名的react就是這樣做的。
為什么要減少操作DOM開銷呢?
首先這個和瀏覽器的架構(gòu)有關(guān)系,在webkit的瀏覽器架構(gòu)中DOM模塊和js的模塊是互相獨立且分割的,因此每次操作DOM的開銷要比單純的操作一次js開銷要大。
其次在整個前端項目中,瀏覽器的重繪和重排性能開銷最大,因此盡量減少瀏覽器的重繪和重排是前端項目在做性能優(yōu)化的時候的重點。
最后目前大型頁面/單頁應(yīng)用里動態(tài)創(chuàng)建/銷毀 DOM 很常見,而在之前的jquery時代,經(jīng)常會出現(xiàn)因為列表中的一項改變而重置整個列表的dom,因此減少操作DOM開銷在現(xiàn)在的前端開發(fā)中顯得十分必要。
為什么是snabbdom.js
由于虛擬dom有那么多的好處而且現(xiàn)代前端框架中react和vue均不同程度的使用了虛擬dom的技術(shù),因此通過一個簡單的 庫賴學(xué)習(xí)虛擬dom技術(shù)就十分必要了,至于為什么會選擇snabbdom.js這個庫呢?原因主要有兩個:
- 源碼簡短,總體代碼行數(shù)不超過500行。
- 著名的vue的虛擬dom實現(xiàn)也是參考了snabbdom.js的實現(xiàn)。
帶著問題看snabbdom.js
如果要我們自己去實現(xiàn)一個虛擬dom,大概過程應(yīng)該有以下三步:
- compile,如何把真實DOM編譯成vnode。
- diff,我們要如何知道oldVnode和newVnode之間有什么變化。
- patch, 如果把這些變化用打補(bǔ)丁的方式更新到真實dom上去。
通過這三個階段我們就可以觀察一波snabbdom.js的源碼。
snabbdom.js結(jié)構(gòu)總覽
src
├── helpers
│ └── attachto.ts # 定義了AttachData,VNodeDataWithAttach ,VNodeWithAttachData 等數(shù)據(jù)結(jié)構(gòu)
├── modules # 該文件夾中主要存放一些在更新dom差異的時候需要的操作
│ ├── attributes.ts # 在vnode更新的時候,更新dom中的attrs操作。
│ ├── class.ts # 在vnode更新的時候,更新dom中的class操作。
│ ├── dataset.ts # 在vnode更新的時候,更新dom中的dataset(自定義數(shù)據(jù)集)操作。
│ ├── eventlisteners.ts # 在vnode更新的時候,更新dom中的eventlisteners(自定義數(shù)據(jù)集)操作。
│ ├── hero.ts # 在vnode更新的時候,和動畫效果有關(guān)的支持
│ ├── module.ts # 定義的module結(jié)構(gòu)
│ ├── props.ts # 在vnode更新的時候,更新dom中的props操作。
│ └── style.js # 在vnode更新的時候,更新dom中的style操作。
├── h.ts # 幫助函數(shù)主要用來操作生成vnode的。
├── hooks.ts # 定義snabbdom在運行的過程中hooks的模型。
├── htmldomapi.ts # 對瀏覽器的dom的api進(jìn)行二次包裝,可以直接操作,html的dom的api。
├── is.ts # is函數(shù)主要是針對做一些數(shù)據(jù)類型判斷,分 primitive和array類型。
├── snabbdom.bundle.ts # snabbdom.ts、attributes、class、props、style 、eventListenersModule和h組成了這個ts文件。
├── snabbdom.ts # 主要文件,程序的主線邏輯都在這個文件里。
├── thunk.ts # thunk這個文件不知道干什么的,但是不影響理解主線邏輯。
├── tovnode.ts # 提供了toVNode的方法,把真實dom轉(zhuǎn)化為vnode。
└── vnode.ts # 定義了vnode的模型和轉(zhuǎn)化成為vnode的工具方法。
從上面的代碼結(jié)構(gòu),我們可以看到關(guān)于snabbdom.js中最主要的代碼幾個文件是h.ts,snabbdom.ts,tovnode.ts,vnode.ts。
vnode.ts
vnode 是對 DOM 節(jié)點的抽象,既然如此,我們很容易定義它的形式:
{
type:String, // String,DOM 節(jié)點的類型,如 'div'/'span'
data:Object, // Object,包括 props,style等等 DOM 節(jié)點的各種屬性
children : Array // Array,子節(jié)點(子 vnode)
}
所以讓我們來看下snabbdom.js中對vnode的實際定義又是怎么做的:
export interface VNode {
sel: string | undefined; // VNode的選擇器,nodeName+id+class的組合
data: VNodeData | undefined; // 存放VNodeData的地方,具體見下面的VNodeData定義
children: Array<VNode | string> | undefined; // vnode的子vnode的地方
elm: Node | undefined; // 存儲vnode對應(yīng)的真實的dom的地方
text: string | undefined; // vnode的text文本,和children只能二選一
key: Key | undefined; // vnode的key值,主要用于后續(xù)vnode的diff過程
}
export interface VNodeData {
props?: Props; // vnode上傳遞的其他屬性
attrs?: Attrs; // vnode上的其他dom屬性,可以通過setAttribute來設(shè)置或刪除的。
class?: Classes; // vnode上的class的屬性集合
style?: VNodeStyle; // vnode上的style屬性集合
dataset?: Dataset; // vnode掛載的數(shù)據(jù)集合
on?: On; // 監(jiān)聽的事件集合
hero?: Hero;
attachData?: AttachData; // 額外附加的數(shù)據(jù)
hook?: Hooks; // vnode的鉤子函數(shù)集合,主要用于在不同階段調(diào)用不通過的鉤子函數(shù)
key?: Key;
ns?: string; // for SVGs 命名空間,主要用于SVG
fn?: () => VNode; // for thunks
args?: Array<any>; // for thunks
[key: string]: any; // for any other 3rd party module
}
繼續(xù)往下看我們發(fā)現(xiàn)vnode.ts中不僅僅存在Vnode和VnodeData兩個數(shù)據(jù)模型,還有一個vnode的工具方法,用于生成vnode。
// 參數(shù)是sel,data,children,text,elm,返回值是一個VNode的對象
export function vnode(sel: string | undefined,
data: any | undefined,
children: Array<VNode | string> | undefined,
text: string | undefined,
elm: Element | Text | undefined): VNode {
let key = data === undefined ? undefined : data.key;
return {sel: sel, data: data, children: children, text: text, elm: elm, key: key};
}
好了,現(xiàn)在我們已經(jīng)有vnode和一個生成vnode的函數(shù),接下來我們看看,如何把真實的dom轉(zhuǎn)為vnode
tovnode.ts
真實的dom轉(zhuǎn)為vnode的方法存放在tovnode.ts的文件里,方法名是toVNode。
// 參數(shù)是要求一個真實的dom對象
export function toVNode(node: Node, domApi?: DOMAPI): VNode {
// 這邊定義了一個變量叫api,主要是一些用于dom操作的api接口。
const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi;
// 定義了text的變量
let text: string;
// 如果node是一個element類型的dom對象就進(jìn)行如下的操作
if (api.isElement(node)) {
// 獲得node的id,變成#id
const id = node.id ? '#' + node.id : '';
// 獲得class,變成.class1.class2這樣的形式
const cn = node.getAttribute('class');
const c = cn ? '.' + cn.split(' ').join('.') : '';
// sel 變成tagName+id+class的形式,比如<div id=id class=class></div>的sel的值就變成了div#id.class
const sel = api.tagName(node).toLowerCase() + id + c;
// 定義一系列后續(xù)需要使用的attrs,children等對象。
const attrs: any = {};
const children: Array<VNode> = [];
let name: string;
let i: number, n: number;
// 獲得元素里所有的attrs
const elmAttrs = node.attributes;
// 獲得元素中所有子節(jié)點,這邊不用children
const elmChildren = node.childNodes;
for (i = 0, n = elmAttrs.length; i < n; i++) {
name = elmAttrs[i].nodeName;
if (name !== 'id' && name !== 'class') {
// 把非id和class的屬性值放到attrs中
attrs[name] = elmAttrs[i].nodeValue;
}
}
for (i = 0, n = elmChildren.length; i < n; i++) {
// 通過遞歸的方式把子節(jié)點翻譯成vnode放入children數(shù)組中
children.push(toVNode(elmChildren[i], domApi));
}
// 生成完整的vnode并返回
return vnode(sel, {attrs}, children, undefined, node);
} else if (api.isText(node)) {
// 如果node是一個textContent類型的就返回文本的vnode
text = api.getTextContent(node) as string;
return vnode(undefined, undefined, undefined, text, node);
} else if (api.isComment(node)) {
// 如果node是一個comment類型的就返回sel是"!"的文本的vnode
text = api.getTextContent(node) as string;
return vnode('!', {}, [], text, node as any);
} else {
// 如果什么都不是就返回一個空的vnode
return vnode('', {}, [], undefined, node as any);
}
}
OK,現(xiàn)在我們已經(jīng)知道了如何把一個真實的dom節(jié)點轉(zhuǎn)化成為vnode,用js對象的方式生成vnode,可以看到有vnode的方法題目,但是單純使用vnode函數(shù)來創(chuàng)建vnode比較繁瑣,所以snabbdom就提供了相應(yīng)的幫助函數(shù)來方便我們創(chuàng)建vnode,在h.ts中。
h.ts
// 以下所有代碼到函數(shù)體為止都做了一件事情,對h這個函數(shù)進(jìn)行重載,看不懂的可以去理解下typescript
export function h(sel: string): VNode;
export function h(sel: string, data: VNodeData): VNode;
export function h(sel: string, children: VNodeChildren): VNode;
export function h(sel: string, data: VNodeData, children: VNodeChildren): VNode;
export function h(sel: any, b?: any, c?: any): VNode {
var data: VNodeData = {}, children: any, text: any, i: number;
// 以下一串if判斷主要用于規(guī)范參數(shù)的形式,統(tǒng)一轉(zhuǎn)化。
if (c !== undefined) {
data = b;
if (is.array(c)) { children = c; }
else if (is.primitive(c)) { text = c; }
else if (c && c.sel) { children = [c]; }
} else if (b !== undefined) {
if (is.array(b)) { children = b; }
else if (is.primitive(b)) { text = b; }
else if (b && b.sel) { children = [b]; }
else { data = b; }
}
// 對文本或者數(shù)字類型的子節(jié)點進(jìn)行轉(zhuǎn)化
if (children !== undefined) {
for (i = 0; i < children.length; ++i) {
if (is.primitive(children[i])) children[i] = vnode(undefined, undefined, undefined, children[i], undefined);
}
}
// 針對svg的node進(jìn)行特別的處理
if (
sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g' &&
(sel.length === 3 || sel[3] === '.' || sel[3] === '#')
) {
addNS(data, children, sel);
}
// 返回一個正常的vnode對象。
return vnode(sel, data, children, text, undefined);
};
以上,我們已經(jīng)有了各種方法來生成一個vnode,包括從普通js對象生成,從真實的dom來生成。但是我們怎么從vnode生成真實的dom呢?接下來讓我們來看看snabbdom.js中最重要的主代碼snabbdom.ts。
snabbdom.ts
利用vnode生成真實dom在snabbdom中主要是通過createElm方法來實現(xiàn),該方法放在snabbdom.ts中。
//根據(jù)VNode創(chuàng)建element
function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
let i: any, data = vnode.data;
if (data !== undefined) {
//如果VNodeData存在且hooks里有init函數(shù),則執(zhí)行init函數(shù),然后重新賦值VNodeData
if (isDef(i = data.hook) && isDef(i = i.init)) {
i(vnode);
data = vnode.data;
}
}
// 子虛擬dom,
let children = vnode.children, sel = vnode.sel;
// 當(dāng)sel == "!"的時候表示這個vnode就是一個comment
if (sel === '!') {
if (isUndef(vnode.text)) {
vnode.text = '';
}
vnode.elm = api.createComment(vnode.text as string);
} else if (sel !== undefined) {
// Parse selector 這么一段就是為了從sel中獲得tag值,id值,class值
const hashIdx = sel.indexOf('#');
const dotIdx = sel.indexOf('.', hashIdx);
const hash = hashIdx > 0 ? hashIdx : sel.length;
const dot = dotIdx > 0 ? dotIdx : sel.length;
const tag = hashIdx !== -1 || dotIdx !== -1 ? sel.slice(0, Math.min(hash, dot)) : sel;
const elm = vnode.elm = isDef(data) && isDef(i = (data as VNodeData).ns) ? api.createElementNS(i, tag)
: api.createElement(tag);
// 設(shè)置元素的id
if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot));
// 設(shè)置元素的class
if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/\./g, ' '));
// 調(diào)用create鉤子
for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode);
if (is.array(children)) {
for (i = 0; i < children.length; ++i) {
const ch = children[i];
if (ch != null) {
//深度遍歷
api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue));
}
}
} else if (is.primitive(vnode.text)) {
api.appendChild(elm, api.createTextNode(vnode.text));
}
i = (vnode.data as VNodeData).hook; // Reuse variable
if (isDef(i)) {
if (i.create) i.create(emptyNode, vnode);
//當(dāng)insert的hook存在,就在插入Vnode的隊列中加入該vnode
if (i.insert) insertedVnodeQueue.push(vnode);
}
} else {
// 其他的情況就當(dāng)vnode是一個簡單的TextNode
vnode.elm = api.createTextNode(vnode.text as string);
}
return vnode.elm;
}
OK到現(xiàn)在為止,我們已經(jīng)把complie的階段弄的差不多了,現(xiàn)在只剩下,怎么比較 oldVnode 與 newVnode 兩個 vnode,并實現(xiàn) DOM 樹更新?也就是我們前面提到的diff方法和patch過程,在snabbdom.ts,diff和patch都寫在一起,我們繼續(xù)往下看。
// patch過程
function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
let i: any, hook: any;
// 調(diào)用全局hook里定義的事件的地方。
if (isDef(i = vnode.data) && isDef(hook = i.hook) && isDef(i = hook.prepatch)) {
i(oldVnode, vnode);
}
// 因為 vnode 和 oldVnode 是相同的 vnode,所以我們可以復(fù)用 oldVnode.elm。
const elm = vnode.elm = (oldVnode.elm as Node);
let oldCh = oldVnode.children;
let ch = vnode.children;
if (oldVnode === vnode) return;
if (vnode.data !== undefined) {
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 是 undefined
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
// 核心邏輯(最復(fù)雜的地方):怎么比較新舊 children 并更新,對應(yīng)上面
// 的數(shù)組比較
if (oldCh !== ch) updateChildren(elm, oldCh as Array<VNode>, ch as Array<VNode>, insertedVnodeQueue);
// 添加新 children
} else if (isDef(ch)) {
// 首先刪除原來的 text
if (isDef(oldVnode.text)) api.setTextContent(elm, '');
// 然后添加新 dom(對 ch 中每個 vnode 遞歸創(chuàng)建 dom 并插入到 elm)
addVnodes(elm, null, ch as Array<VNode>, 0, (ch as Array<VNode>).length - 1, insertedVnodeQueue);
} else if (isDef(oldCh)) {
// 相反地,如果原來有 children 而現(xiàn)在沒有,那么我們要刪除 children。
removeVnodes(elm, oldCh as Array<VNode>, 0, (oldCh as Array<VNode>).length - 1);
} else if (isDef(oldVnode.text)) {
// 最后,如果 oldVnode 有 text,刪除。
api.setTextContent(elm, '');
}
// 否則 (vnode 有 text),只要 text 不等,更新 dom 的 text。
} else if (oldVnode.text !== vnode.text) {
api.setTextContent(elm, vnode.text as string);
}
if (isDef(hook) && isDef(i = hook.postpatch)) {
i(oldVnode, vnode);
}
}
// diff算法的重點
function updateChildren(parentElm: Node,
oldCh: Array<VNode>,
newCh: Array<VNode>,
insertedVnodeQueue: VNodeQueue) {
// parentElm:Node
// oldCh: Array<VNode>
// newCh: Array<VNode>
// insertdVnodeQuenen: VNodeQuenen
// 和patchVnode形成了精巧遞歸
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;
// 當(dāng)oldCh和newCh其中還有一個沒有比較完的話,就執(zhí)行下的函數(shù)
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
// 把獲得更新后的 (oldStartVnode/newEndVnode) 的 dom 右移,移動到
// oldEndVnode 對應(yīng)的 dom 的右邊。為什么這么右移?
// (1)oldStartVnode 和 newEndVnode 相同,顯然是 vnode 右移了。
// (2)若 while 循環(huán)剛開始,那移到 oldEndVnode.elm 右邊就是最右邊,是合理的;
// (3)若循環(huán)不是剛開始,因為比較過程是兩頭向中間,那么兩頭的 dom 的位置已經(jīng)是
// 合理的了,移動到 oldEndVnode.elm 右邊是正確的位置;
// (4)記住,oldVnode 和 vnode 是相同的才 patch,且 oldVnode 自己對應(yīng)的 dom
// 總是已經(jīng)存在的,vnode 的 dom 是不存在的,直接復(fù)用 oldVnode 對應(yīng)的 dom。
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
//更新新舊vnode的值,然后vnode左移
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
api.insertBefore(parentElm, oldEndVnode.elm as Node, oldStartVnode.elm as Node);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
}
idxInOld = oldKeyToIdx[newStartVnode.key as string];
// 新的children中的startVnode元素沒有在舊children中找到
if (isUndef(idxInOld)) { // New element
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
newStartVnode = newCh[++newStartIdx];
} else {
// 新的children中的startNode元素在舊children中找到元素
elmToMove = oldCh[idxInOld];
// 如果sel不相等則必須重新創(chuàng)建一個新的ele
if (elmToMove.sel !== newStartVnode.sel) {
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];
}
}
}
在上述的操作中,有一個重要的函數(shù)叫sameVnode,十分重要,對diff算法的影響十分巨大,通常情況下,找到兩棵任意的樹之間最小修改的時間復(fù)雜度是 O(n^3),這不可接受。幸好,我們可以對 Virtual DOM 樹有這樣的假設(shè):
如果 oldVnode 和 vnode 不同(如 type 從 div 變到 p,或者 key 改變),意味著整個 vnode 被替換(因為我們通常不會去跨層移動 vnode ),所以我們沒有必要去比較 vnode 的 子 vnode(children) 了。基于這個假設(shè),我們可以 按照層級分解 樹,這大大簡化了復(fù)雜度,大到接近 O(n) 的復(fù)雜度:

此外,對于 children (數(shù)組)的比較,因為同層是很可能有移動的,順序比較會無法最大化復(fù)用已有的 DOM。所以我們通過為每個 vnode 加上 key 來追蹤這種順序變動。

因為以上的兩個假設(shè),所以
sameVnode方法的源代碼如下:
// 只要這兩個虛擬元素的sel(選擇器)和key一樣就是same的
function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel;
}
至此,源代碼的主要邏輯已經(jīng)梳理完畢,想要看完整的snabbdom源代碼或者更有有關(guān)Virtual DOM可以查看參考文章。
參考文章
snabbdom源代碼
探索Virtual DOM的前世今生
Why Turbine doesn't use virtual DOM
如何看待 snabbdom 的作者開發(fā)的前端框架 Turbine 拋棄了虛擬DOM?