vue的Virtual Dom實現(xiàn)snabbdom解密
vue在官方文檔中提到與react的渲染性能對比中,因為其使用了snabbdom而有更優(yōu)異的性能。
JavaScript 開銷直接與求算必要 DOM 操作的機制相關。盡管 Vue 和 React 都使用了 Virtual Dom 實現(xiàn)這一點,但 Vue 的 Virtual Dom 實現(xiàn)(復刻自 snabbdom)是更加輕量化的,因此也就比 React 的實現(xiàn)更高效。
看到火到不行的國產(chǎn)前端框架vue也在用別人的 Virtual Dom開源方案,是不是很好奇snabbdom有何強大之處呢?不過正式解密snabbdom之前,先簡單介紹下Virtual Dom。
什么是Virtual Dom
Virtual Dom可以看做一棵模擬了DOM樹的JavaScript樹,其主要是通過vnode,實現(xiàn)一個無狀態(tài)的組件,當組件狀態(tài)發(fā)生更新時,然后觸發(fā)Virtual Dom數(shù)據(jù)的變化,然后通過Virtual Dom和真實DOM的比對,再對真實DOM更新??梢院唵握J為Virtual Dom是真實DOM的緩存。
為什么用Virtual Dom
我們知道,當我們希望實現(xiàn)一個具有復雜狀態(tài)的界面時,如果我們在每個可能發(fā)生變化的組件上都綁定事件,綁定字段數(shù)據(jù),那么很快由于狀態(tài)太多,我們需要維護的事件和字段將會越來越多,代碼也會越來越復雜,于是,我們想我們可不可以將視圖和狀態(tài)分開來,只要視圖發(fā)生變化,對應狀態(tài)也發(fā)生變化,然后狀態(tài)變化,我們再重繪整個視圖就好了。
這樣的想法雖好,但是代價太高了,于是我們又想,能不能只更新狀態(tài)發(fā)生變化的視圖?于是Virtual Dom應運而生,狀態(tài)變化先反饋到Virtual Dom上,Virtual Dom在找到最小更新視圖,最后批量更新到真實DOM上,從而達到性能的提升。
除此之外,從移植性上看,Virtual Dom還對真實dom做了一次抽象,這意味著Virtual Dom對應的可以不是瀏覽器的DOM,而是不同設備的組件,極大的方便了多平臺的使用。如果是要實現(xiàn)前后端同構(gòu)直出方案,使用Virtual Dom的框架實現(xiàn)起來是比較簡單的,因為在服務端的Virtual Dom跟瀏覽器DOM接口并沒有綁定關系。
基于Virtual DOM 的數(shù)據(jù)更新與UI同步機制:
初始渲染時,首先將數(shù)據(jù)渲染為 Virtual DOM,然后由 Virtual DOM 生成 DOM。
數(shù)據(jù)更新時,渲染得到新的 Virtual DOM,與上一次得到的 Virtual DOM 進行 diff,得到所有需要在 DOM 上進行的變更,然后在 patch 過程中應用到 DOM 上實現(xiàn)UI的同步更新。
Virtual DOM 作為數(shù)據(jù)結(jié)構(gòu),需要能準確地轉(zhuǎn)換為真實 DOM,并且方便進行對比。
介紹完Virtual DOM,我們應該對snabbdom的功用有個認識了,下面具體解剖下snabbdom這只“小麻雀”。
snabbdom
vnode
DOM 通常被視為一棵樹,元素則是這棵樹上的節(jié)點(node),而 Virtual DOM 的基礎,就是 Virtual Node 了。
Snabbdom 的 Virtual Node 則是純數(shù)據(jù)對象,通過 vnode 模塊來創(chuàng)建,對象屬性包括:
sel
data
children
text
elm
key
可以看到 Virtual Node 用于創(chuàng)建真實節(jié)點的數(shù)據(jù)包括:
元素類型
元素屬性
元素的子節(jié)點
源碼:
//VNode函數(shù),用于將輸入轉(zhuǎn)化成VNode /** * * @param sel 選擇器 * @param data 綁定的數(shù)據(jù) * @param children 子節(jié)點數(shù)組 * @param text 當前text節(jié)點內(nèi)容 * @param elm 對真實dom element的引用 * @returns {{sel: *, data: *, children: *, text: *, elm: *, key: undefined}} */ function vnode(sel, data, children, text, elm) { var key = data === undefined ? undefined : data.key; return { sel: sel, data: data, children: children, text: text, elm: elm, key: key }; }
snabbdom并沒有直接暴露vnode對象給我們用,而是使用h包裝器,h的主要功能是處理參數(shù):
h(sel,[data],[children],[text]) => vnode
從snabbdom的typescript的源碼可以看出,其實就是這幾種函數(shù)重載:
export function h(sel: string): VNode; export function h(sel: string, data: VNodeData): VNode; export function h(sel: string, text: string): VNode; export function h(sel: string, children: Array<VNode | undefined | null>): VNode; export function h(sel: string, data: VNodeData, text: string): VNode; export function h(sel: string, data: VNodeData, children: Array<VNode | undefined | null>): VNode;
patch
創(chuàng)建vnode后,接下來就是調(diào)用patch方法將Virtual Dom渲染成真實DOM了。patch是snabbdom的init函數(shù)返回的。
snabbdom.init傳入modules數(shù)組,module用來擴展snabbdom創(chuàng)建復雜dom的能力。
不多說了直接上patch的源碼:
return function patch(oldVnode, vnode) { var i, elm, parent; //記錄被插入的vnode隊列,用于批觸發(fā)insert var insertedVnodeQueue = []; //調(diào)用全局pre鉤子 for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i](); //如果oldvnode是dom節(jié)點,轉(zhuǎn)化為oldvnode if (isUndef(oldVnode.sel)) { oldVnode = emptyNodeAt(oldVnode); } //如果oldvnode與vnode相似,進行更新 if (sameVnode(oldVnode, vnode)) { patchVnode(oldVnode, vnode, insertedVnodeQueue); } else { //否則,將vnode插入,并將oldvnode從其父節(jié)點上直接刪除 elm = oldVnode.elm; parent = api.parentNode(elm); createElm(vnode, insertedVnodeQueue); if (parent !== null) { api.insertBefore(parent, vnode.elm, api.nextSibling(elm)); removeVnodes(parent, [oldVnode], 0, 0); } } //插入完后,調(diào)用被插入的vnode的insert鉤子 for (i = 0; i < insertedVnodeQueue.length; ++i) { insertedVnodeQueue[i].data.hook.insert(insertedVnodeQueue[i]); } //然后調(diào)用全局下的post鉤子 for (i = 0; i < cbs.post.length; ++i) cbs.post[i](); //返回vnode用作下次patch的oldvnode return vnode; };
先判斷新舊虛擬dom是否是相同層級vnode,是才執(zhí)行patchVnode,否則創(chuàng)建新dom刪除舊dom,判斷是否相同vnode比較簡單:
function sameVnode(vnode1, vnode2) { //判斷key值和選擇器 return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel; }
patch方法里面實現(xiàn)了snabbdom 作為一個高效virtual dom庫的法寶—高效的diff算法,可以用一張圖示意:
diff算法的核心是比較只會在同層級進行, 不會跨層級比較。而不是逐層逐層搜索遍歷的方式,時間復雜度將會達到 O(n^3)的級別,代價非常高,而只比較同層級的方式時間復雜度可以降低到O(n)。
patchVnode函數(shù)的主要作用是以打補丁的方式去更新dom樹。
function patchVnode(oldVnode, vnode, insertedVnodeQueue) { var i, hook; //在patch之前,先調(diào)用vnode.data的prepatch鉤子 if (isDef(i = vnode.data) && isDef(hook = i.hook) && isDef(i = hook.prepatch)) { i(oldVnode, vnode); } var elm = vnode.elm = oldVnode.elm, oldCh = oldVnode.children, ch = vnode.children; //如果oldvnode和vnode的引用相同,說明沒發(fā)生任何變化直接返回,避免性能浪費 if (oldVnode === vnode) return; //如果oldvnode和vnode不同,說明vnode有更新 //如果vnode和oldvnode不相似則直接用vnode引用的DOM節(jié)點去替代oldvnode引用的舊節(jié)點 if (!sameVnode(oldVnode, vnode)) { var parentElm = api.parentNode(oldVnode.elm); elm = createElm(vnode, insertedVnodeQueue); api.insertBefore(parentElm, elm, oldVnode.elm); removeVnodes(parentElm, [oldVnode], 0, 0); return; } //如果vnode和oldvnode相似,那么我們要對oldvnode本身進行更新 if (isDef(vnode.data)) { //首先調(diào)用全局的update鉤子,對vnode.elm本身屬性進行更新 for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode); //然后調(diào)用vnode.data里面的update鉤子,再次對vnode.elm更新 i = vnode.data.hook; if (isDef(i) && isDef(i = i.update)) i(oldVnode, vnode); } //如果vnode不是text節(jié)點 if (isUndef(vnode.text)) { //如果vnode和oldVnode都有子節(jié)點 if (isDef(oldCh) && isDef(ch)) { //當Vnode和oldvnode的子節(jié)點不同時,調(diào)用updatechilren函數(shù),diff子節(jié)點 if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue); } //如果vnode有子節(jié)點,oldvnode沒子節(jié)點 else if (isDef(ch)) { //oldvnode是text節(jié)點,則將elm的text清除 if (isDef(oldVnode.text)) api.setTextContent(elm, ''); //并添加vnode的children addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue); } //如果oldvnode有children,而vnode沒children,則移除elm的children else if (isDef(oldCh)) { removeVnodes(elm, oldCh, 0, oldCh.length - 1); } //如果vnode和oldvnode都沒chidlren,且vnode沒text,則刪除oldvnode的text else if (isDef(oldVnode.text)) { api.setTextContent(elm, ''); } } //如果oldvnode的text和vnode的text不同,則更新為vnode的text else if (oldVnode.text !== vnode.text) { api.setTextContent(elm, vnode.text); } //patch完,觸發(fā)postpatch鉤子 if (isDef(hook) && isDef(i = hook.postpatch)) { i(oldVnode, vnode); } }
patchVnode將新舊虛擬DOM分為幾種情況,執(zhí)行替換textContent還是updateChildren。
updateChildren是實現(xiàn)diff算法的主要地方:
function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue) { var oldStartIdx = 0, newStartIdx = 0; var oldEndIdx = oldCh.length - 1; var oldStartVnode = oldCh[0]; var oldEndVnode = oldCh[oldEndIdx]; var newEndIdx = newCh.length - 1; var newStartVnode = newCh[0]; var newEndVnode = newCh[newEndIdx]; var oldKeyToIdx; var idxInOld; var elmToMove; var before; 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)) { patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue); api.insertBefore(parentElm, oldStartVnode.elm, api.nextSibling(oldEndVnode.elm)); oldStartVnode = oldCh[++oldStartIdx]; newEndVnode = newCh[--newEndIdx]; } else if (sameVnode(oldEndVnode, newStartVnode)) { patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue); api.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm); oldEndVnode = oldCh[--oldEndIdx]; newStartVnode = newCh[++newStartIdx]; } else { if (oldKeyToIdx === undefined) { oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx); } idxInOld = oldKeyToIdx[newStartVnode.key]; if (isUndef(idxInOld)) { api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm); newStartVnode = newCh[++newStartIdx]; } else { elmToMove = oldCh[idxInOld]; if (elmToMove.sel !== newStartVnode.sel) { api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm); } else { patchVnode(elmToMove, newStartVnode, insertedVnodeQueue); oldCh[idxInOld] = undefined; api.insertBefore(parentElm, elmToMove.elm, oldStartVnode.elm); } newStartVnode = newCh[++newStartIdx]; } } } if (oldStartIdx > oldEndIdx) { before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm; addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue); } else if (newStartIdx > newEndIdx) { removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx); } }
updateChildren的代碼比較有難度,借助幾張圖比較好理解些:
過程可以概括為:oldCh和newCh各有兩個頭尾的變量StartIdx和EndIdx,它們的2個變量相互比較,一共有4種比較方式。如果4種比較都沒匹配,如果設置了key,就會用key進行比較,在比較的過程中,變量會往中間靠,一旦StartIdx>EndIdx表明oldCh和newCh至少有一個已經(jīng)遍歷完了,就會結(jié)束比較。
具體的diff分析:
對于與sameVnode(oldStartVnode, newStartVnode)和sameVnode(oldEndVnode,newEndVnode)為true的情況,不需要對dom進行移動。
有3種需要dom操作的情況:
1.當oldStartVnode,newEndVnode相同層級時,說明oldStartVnode.el跑到oldEndVnode.el的后邊了。
2.當oldEndVnode,newStartVnode相同層級時,說明oldEndVnode.el跑到了newStartVnode.el的前邊。
3.newCh中的節(jié)點oldCh里沒有,將新節(jié)點插入到oldStartVnode.el的前邊。
在結(jié)束時,分為兩種情況:
1.oldStartIdx > oldEndIdx,可以認為oldCh先遍歷完。當然也有可能newCh此時也正好完成了遍歷,統(tǒng)一都歸為此類。此時newStartIdx和newEndIdx之間的vnode是新增的,調(diào)用addVnodes,把他們?nèi)坎暹Mbefore的后邊,before很多時候是為null的。addVnodes調(diào)用的是insertBefore操作dom節(jié)點,我們看看insertBefore的文檔:parentElement.insertBefore(newElement, referenceElement)如果referenceElement為null則newElement將被插入到子節(jié)點的末尾。如果newElement已經(jīng)在DOM樹中,newElement首先會從DOM樹中移除。所以before為null,newElement將被插入到子節(jié)點的末尾。
2.newStartIdx > newEndIdx,可以認為newCh先遍歷完。此時oldStartIdx和oldEndIdx之間的vnode在新的子節(jié)點里已經(jīng)不存在了,調(diào)用removeVnodes將它們從dom里刪除。
hook
shabbdom主要流程的代碼在上面就介紹完畢了,在上面的代碼中可能看不出來如果要創(chuàng)建比較復雜的dom,比如有attribute、props、eventlistener的dom怎么辦?奧秘就在與shabbdom在各個主要的環(huán)節(jié)提供了鉤子。鉤子方法中可以執(zhí)行擴展模塊,attribute、props、eventlistener等可以通過擴展模塊實現(xiàn)。
在源碼中可以看到hook是在snabbdom初始化的時候注冊的。
var hooks = ['create', 'update', 'remove', 'destroy', 'pre', 'post']; var h_1 = require("./h"); exports.h = h_1.h; var thunk_1 = require("./thunk"); exports.thunk = thunk_1.thunk; function init(modules, domApi) { var i, j, cbs = {}; var api = domApi !== undefined ? domApi : htmldomapi_1.default; for (i = 0; i < hooks.length; ++i) { cbs[hooks[i]] = []; for (j = 0; j < modules.length; ++j) { var hook = modules[j][hooks[i]]; if (hook !== undefined) { cbs[hooks[i]].push(hook); } } }
snabbdom在全局下有6種類型的鉤子,觸發(fā)這些鉤子時,會調(diào)用對應的函數(shù)對節(jié)點的狀態(tài)進行更改首先我們來看看有哪些鉤子以及它們觸發(fā)的時間:
比如在patch的代碼中可以看到調(diào)用了pre鉤子
return function patch(oldVnode, vnode) { var i, elm, parent; var insertedVnodeQueue = []; for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i](); if (!isVnode(oldVnode)) { oldVnode = emptyNodeAt(oldVnode); }
我們找一個比較簡單的class模塊來看下其源碼:
function updateClass(oldVnode, vnode) { var cur, name, elm = vnode.elm, oldClass = oldVnode.data.class, klass = vnode.data.class; if (!oldClass && !klass) return; if (oldClass === klass) return; oldClass = oldClass || {}; klass = klass || {}; for (name in oldClass) { if (!klass[name]) { elm.classList.remove(name); } } for (name in klass) { cur = klass[name]; if (cur !== oldClass[name]) { elm.classList[cur ? 'add' : 'remove'](name); } } } exports.classModule = { create: updateClass, update: updateClass }; Object.defineProperty(exports, "__esModule", { value: true }); exports.default = exports.classModule; },{}]},{},[1])(1) });
可以看出create和update鉤子方法調(diào)用的時候,可以執(zhí)行class模塊的updateClass:從elm中刪除vnode中不存在的或者值為false的類。
將vnode中新的class添加到elm上去。
總結(jié)snabbdom
- vnode是基礎數(shù)據(jù)結(jié)構(gòu)
- patch創(chuàng)建或更新DOM樹
- diff算法只比較同層級
- 通過鉤子和擴展模塊創(chuàng)建有attribute、props、eventlistener的復雜dom
參考:
以上就是本文的全部內(nèi)容,希望對大家的學習有所幫助,也希望大家多多支持腳本之家。