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