vue的Virtual Dom實現snabbdom解密,vuesnabbdom

來源:互聯網
上載者:User

vue的Virtual Dom實現snabbdom解密,vuesnabbdom

vue在官方文檔中提到與react的渲染效能對比中,因為其使用了snabbdom而有更優異的效能。

JavaScript 開銷直接與求算必要 DOM 操作的機制相關。儘管 Vue 和 React 都使用了 Virtual Dom 實現這一點,但 Vue 的 Virtual Dom 實現(複刻自 snabbdom)是更加輕量化的,因此也就比 React 的實現更高效。

看到火到不行的國產前端架構vue也在用別人的 Virtual Dom開源方案,是不是很好奇snabbdom有何強大之處呢?不過正式解密snabbdom之前,先簡單介紹下Virtual Dom。

什麼是Virtual Dom

Virtual Dom可以看做一棵類比了DOM樹的JavaScript樹,其主要是通過vnode,實現一個無狀態的組件,當組件狀態發生更新時,然後觸發Virtual Dom資料的變化,然後通過Virtual Dom和真實DOM的比對,再對真實DOM更新。可以簡單認為Virtual Dom是真實DOM的緩衝。

為什麼用Virtual Dom

我們知道,當我們希望實現一個具有複雜狀態的介面時,如果我們在每個可能發生變化的組件上都綁定事件,綁定欄位資料,那麼很快由於狀態太多,我們需要維護的事件和欄位將會越來越多,代碼也會越來越複雜,於是,我們想我們可不可以將視圖和狀態分開來,只要視圖發生變化,對應狀態也發生變化,然後狀態變化,我們再重繪整個視圖就好了。

這樣的想法雖好,但是代價太高了,於是我們又想,能不能只更新狀態發生變化的視圖?於是Virtual Dom應運而生,狀態變化先反饋到Virtual Dom上,Virtual Dom在找到最小更新視圖,最後批次更新到真實DOM上,從而達到效能的提升。

除此之外,從移植性上看,Virtual Dom還對真實dom做了一次抽象,這意味著Virtual Dom對應的可以不是瀏覽器的DOM,而是不同裝置的組件,極大的方便了多平台的使用。如果是要實現前後端同構直出方案,使用Virtual Dom的架構實現起來是比較簡單的,因為在服務端的Virtual Dom跟瀏覽器DOM介面並沒有綁定關係。

基於Virtual DOM 的資料更新與UI同步機制:

初始渲染時,首先將資料渲染為 Virtual DOM,然後由 Virtual DOM 產生 DOM。

資料更新時,渲染得到新的 Virtual DOM,與上一次得到的 Virtual DOM 進行 diff,得到所有需要在 DOM 上進行的變更,然後在 patch 過程中應用到 DOM 上實現UI的同步更新。

Virtual DOM 作為資料結構,需要能準確地轉換為真實 DOM,並且方便進行對比。

介紹完Virtual DOM,我們應該對snabbdom的功用有個認識了,下面具體解剖下snabbdom這隻“小麻雀”。

snabbdom

vnode

DOM 通常被視為一棵樹,元素則是這棵樹上的節點(node),而 Virtual DOM 的基礎,就是 Virtual Node 了。

Snabbdom 的 Virtual Node 則是純資料對象,通過 vnode 模組來建立,對象屬性包括:

sel
data
children
text
elm
key

可以看到 Virtual Node 用於建立真實節點的資料包括:

元素類型
元素屬性
元素的子節點

源碼:

//VNode函數,用於將輸入轉化成VNode /** * * @param sel 選取器 * @param data 繫結資料 * @param children 子節點數組 * @param text 當前text節點內容 * @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的主要功能是處理參數:

h(sel,[data],[children],[text]) => vnode

從snabbdom的typescript的源碼可以看出,其實就是這幾種函數重載:

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

建立vnode後,接下來就是調用patch方法將Virtual Dom渲染成真實DOM了。patch是snabbdom的init函數返回的。
snabbdom.init傳入modules數組,module用來擴充snabbdom建立複雜dom的能力。

不多說了直接上patch的源碼:

return function patch(oldVnode, vnode) { var i, elm, parent; //記錄被插入的vnode隊列,用於批觸發insert var insertedVnodeQueue = []; //調用全域pre鉤子 for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i](); //如果oldvnode是dom節點,轉化為oldvnode if (isUndef(oldVnode.sel)) { oldVnode = emptyNodeAt(oldVnode); } //如果oldvnode與vnode相似,進行更新 if (sameVnode(oldVnode, vnode)) { patchVnode(oldVnode, vnode, insertedVnodeQueue); } else { //否則,將vnode插入,並將oldvnode從其父節點上直接刪除 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); } } //插入完後,調用被插入的vnode的insert鉤子 for (i = 0; i < insertedVnodeQueue.length; ++i) { insertedVnodeQueue[i].data.hook.insert(insertedVnodeQueue[i]); } //然後調用全域下的post鉤子 for (i = 0; i < cbs.post.length; ++i) cbs.post[i](); //返回vnode用作下次patch的oldvnode return vnode; };

先判斷新舊虛擬dom是否是相同層級vnode,是才執行patchVnode,否則建立新dom刪除舊dom,判斷是否相同vnode比較簡單:

function sameVnode(vnode1, vnode2) { //判斷key值和選取器 return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel;}

patch方法裡面實現了snabbdom 作為一個高效virtual dom庫的法寶—高效的diff演算法,可以用一張圖示意:

diff演算法的核心是比較只會在同層級進行, 不會跨層級比較。而不是逐層逐層搜尋遍曆的方式,時間複雜度將會達到 O(n^3)的層級,代價非常高,而只比較同層級的方式時間複雜度可以降低到O(n)。

patchVnode函數的主要作用是以打補丁的方式去更新dom樹。

function patchVnode(oldVnode, vnode, insertedVnodeQueue) { var i, hook; //在patch之前,先調用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的引用相同,說明沒發生任何變化直接返回,避免效能浪費 if (oldVnode === vnode) return; //如果oldvnode和vnode不同,說明vnode有更新 //如果vnode和oldvnode不相似則直接用vnode引用的DOM節點去替代oldvnode引用的舊節點 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)) { //首先調用全域的update鉤子,對vnode.elm本身屬性進行更新 for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode); //然後調用vnode.data裡面的update鉤子,再次對vnode.elm更新 i = vnode.data.hook; if (isDef(i) && isDef(i = i.update)) i(oldVnode, vnode); } //如果vnode不是text節點 if (isUndef(vnode.text)) { //如果vnode和oldVnode都有子節點 if (isDef(oldCh) && isDef(ch)) { //當Vnode和oldvnode的子節點不同時,調用updatechilren函數,diff子節點 if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue); } //如果vnode有子節點,oldvnode沒子節點 else if (isDef(ch)) { //oldvnode是text節點,則將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完,觸發postpatch鉤子 if (isDef(hook) && isDef(i = hook.postpatch)) { i(oldVnode, vnode); } }

patchVnode將新舊虛擬DOM分為幾種情況,執行替換textContent還是updateChildren。

updateChildren是實現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至少有一個已經遍曆完了,就會結束比較。

具體的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中的節點oldCh裡沒有,將新節點插入到oldStartVnode.el的前邊。

在結束時,分為兩種情況:

1.oldStartIdx > oldEndIdx,可以認為oldCh先遍曆完。當然也有可能newCh此時也正好完成了遍曆,統一都歸為此類。此時newStartIdx和newEndIdx之間的vnode是新增的,調用addVnodes,把他們全部插進before的後邊,before很多時候是為null的。addVnodes調用的是insertBefore操作dom節點,我們看看insertBefore的文檔:parentElement.insertBefore(newElement, referenceElement)如果referenceElement為null則newElement將被插入到子節點的末尾。如果newElement已經在DOM樹中,newElement首先會從DOM樹中移除。所以before為null,newElement將被插入到子節點的末尾。

2.newStartIdx > newEndIdx,可以認為newCh先遍曆完。此時oldStartIdx和oldEndIdx之間的vnode在新的子節點裡已經不存在了,調用removeVnodes將它們從dom裡刪除。

hook

shabbdom主要流程的代碼在上面就介紹完畢了,在上面的代碼中可能看不出來如果要建立比較複雜的dom,比如有attribute、props、eventlistener的dom怎麼辦?奧秘就在與shabbdom在各個主要的環節提供了鉤子。鉤子方法中可以執行擴充模組,attribute、props、eventlistener等可以通過擴充模組實現。

在源碼中可以看到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種類型的鉤子,觸發這些鉤子時,會調用對應的函數對節點的狀態變更首先我們來看看有哪些鉤子以及它們觸發的時間:

比如在patch的代碼中可以看到調用了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鉤子方法調用的時候,可以執行class模組的updateClass:從elm中刪除vnode中不存在的或者值為false的類。

將vnode中新的class添加到elm上去。

總結snabbdom

  • vnode是基礎資料結構
  • patch建立或更新DOM樹
  • diff演算法只比較同層級
  • 通過鉤子和擴充模組建立有attribute、props、eventlistener的複雜dom

參考:

snabbdom

以上就是本文的全部內容,希望對大家的學習有所協助,也希望大家多多支援幫客之家。

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.