理解virtual dom的實現細節-snabbdom

最近想了解一下React和Vue框架分別在virtual dom部分的實現,以及他們的不一樣之處。因而先翻開Vue的源碼去找virtual dom 的實現,看到開頭,它就提到了Vue的virtual dom更新算法是基於Snabbdom實現的。因而,又去克隆了Snabbdom的源碼,發現它的源碼並非很複雜而且星星🌟還不少,因此就仔細看了一遍了,這裏就將詳細學習一下它是如何實現virtual dom的。javascript

在Snabbdom的GitHub上就解釋了,它是一個實現virtual dom的庫,簡單化,模塊化,以及強大的特性和性能。html

A virtual DOM library with focus on simplicity, modularity, powerful features and performance.vue

這裏是Snabbdom的倉庫地址java

init

Snabbdom的簡單是基於它的模塊化,它對virtual dom的設計很是巧妙,在覈心邏輯中只會專一於vNode的更新算法計算,而把每一個節點具體要更新的部分,好比props,class,styles,datalist等放在獨立的模塊裏,經過在不一樣時機觸發不一樣module的鉤子函數去完成。經過這樣的方式解耦,不只可使代碼組織結構更加清晰,更可使得每一部分都專一於實現特定的功能,在設計模式中,這個也叫作單一職責原則。在實際場景使用時,能夠只引入須要用到的特定模塊。好比咱們只會更新節點的類名和樣式,而不關心屬性以及事件,那麼就只須要引用class和style的模塊就能夠了。例以下面這樣,node

// 這裏咱們只須要用到class和style模塊,因此就能夠只須要引用這2個模塊
var patch = snabbdom.init([
 require('snabbdom/modules/class').default,
 require('snabbdom/modules/style').default,
]);
複製代碼

它的核心方法就是這個init,咱們先來簡單看一下這個函數的實現,react

//這裏是module中的鉤子函數
const hooks = ['create', 'update', 'remove', 'destroy', 'pre', 'post'];
export function init(modules:Array<Partial<Module>>, domApi?:DOMAPI){
    let i:number, j:number, cbs = ({} as ModuleHooks);
    const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi;
    //cbs存儲了引入的modules中定義的鉤子函數,
    for(i = 0; i < hooks.length; ++i){
        cbs[hooks[i]] = [];
        for(j = 0; j < modules.length; ++j){
            const hook = modules[j][hooks[i]];
            if(hook !== undefined){
                cbs[hooks[i]].push(hook);
            }
        }
    }
    
    //還定義了一些其餘的內部方法,這些方法都是服務於patch
    function emptyNodeAt(){/.../};
    function createRmCb(){/.../};
    function createElm(){/.../};
    function addVnodes(){/.../};
    function invokeDestroyHook(){/.../};
    function removeVnodes(){/.../};
    function updateChildren(){/.../};
    function patchVnode(){/.../};
    
    //init返回了一個patch函數,這個函數接受2個參數,第一個是將被更新的vNode或者真實dom節點,第二個是用來更新的新的vNode
    return function patch(oldVnode: VNode | Element,vnode:VNode):VNode{
        //...
    }
}
複製代碼

init函數總體來看,它接受一個modules數組,返回一個新的函數patch。這不就是咱們熟悉的閉包函數嗎?在init中,它會將引入模塊的鉤子函數經過遍歷存儲在cbs變量裏,後面在執行更新算法時會相應的觸發這些鉤子函數。只須要初始化一次,後面virtual dom的更新都是經過patch來完成的。git

流程圖以下,github

patch

最爲複雜也最爲耗時的部分就是如何實現virtual dom的更新,更新算法的好壞直接影響整個框架的性能,好比React中的react-reconciler模塊,到vue中的vdom模塊,都是最大可能優化這一部分。在Snabbdom中virtual dom的更新邏輯大體以下,算法

//這個patch就是init返回的
function patch(oldVnode,vnode){
    //第一步:若是oldVnode是Element,則根據Element建立一個空的vnode,這個也是vnode tree的根節點
    if(!isVnode(oldVnode)){
        oldVnode = emptyAtNode(oldVnode);
    }
    //第二步:判斷oldVnode是否與vnode相同的元素,若是是,則更新元素便可。這裏判斷它們是否相同,是對比了它們的key相同且tagName相同且ID屬性相同且類相同
    if(sameVnode(oldVnode,vnode)){
        patchVnode(oldVnode,vnode);
    }else{
        //第三步:若是不相同,則直接用vnode建立新的element元素替換oldVnode,且刪除掉oldVnode。
        elm = oldVnode.elm;
        parent = api.parentNode(elm);
        createElm(vnode);
        if(parent !== null){
            api.insertBefore(parent,vnode.elm,api.nextSlibing(elm));
            removeVnodes(parent,[oldVnode], 0, 0);
        }
    }
}
複製代碼

patch邏輯能夠簡化爲下面:typescript

  1. 若是oldVnode是Element類型,則根據oldVnode建立一個空vnode,這個空vnode也是這個vnode tree的root節點
  2. 比較oldVnode與vnode,若是是同一個vnode(key值相同)或者是相同類型的元素(tagName相同且id相同且class相同),則直接調用patchVnode
  3. 不然,直接根據vnode建立一個新的element,且用新的element替換掉oldVnode的element,且刪除掉oldVnode

流程圖以下,

在進行第3步時,當oldVnode與vnode不相同,是直接拋棄了舊的節點,建立新的節點來替換,在用新vnode來建立節點時會檢查當前vnode有沒有children,若是有,則也會遍歷children建立出新的element。這意味oldVnode以及包含的全部子節點將被做爲一個總體被新的vnode替換。示意圖以下,

若是B與B'不相同,則B在被B'替換的過程當中,B的子節點D也就被B'的子節點D'和E'一塊兒替換掉了。

patchVnode

咱們再來看看第2步,若是oldVnode與vnode相同,則會複用以前已經建立好的dom,只是更新這個dom上的差別點,好比text,class,datalist,style等。這個是在函數patchVnode中實現的,下面爲它的大體邏輯,

function patchVnode(oldVnode,vnode){
    const elm = oldVnode.elm; //獲取oldVnode的dom對象
    vnode.elm = elm; //將vnode的elm直接指向elm,複用oldVnode的dom對象,由於它們類型相同 
    //若是oldVnode與vnode相等,則直接返回,根本不用更新了
    if(oldVnode === vnode){
        return;
    }
    //若是vnode是包含text,且不等於oldVnode.text,則直接更新elm的textContent爲vnode.text
    if(isDef(vnode.text) && vnode.text !== oldVnode.text){
       return api.setTextContext(elm,vnode.text);
    }
    let oldCh = oldVnode.children; //獲取oldVnode的子節點
    let ch = vnode.children; //獲取vnode的子節點
    
    //若是oldVnode沒有子節點,而vnode有子節點,則添加vnode的子節點
    if(isUndef(oldCh) && isDef(ch)){
        // 若是oldVnode有text值,則先將elm的textContent清空
        if(idDef(oldVnode.text)){
            api.setTextContext(elm,'');
        }
        addVnodes(elm,null,ch,0,ch.length-1);
    }
    //若是oldVnode有子節點,而vnode沒有子節點,則刪除oldVnode的子節點
    else if(isUndef(ch) && isDef(oldCh)){
        reoveVnodes(elm,oldCh,0,oldCh.length-1)
    }
    //若是它們都有子節點,而且子節點不相同,則更新它們的子節點
    else if(ch !== oldCh){
        updateChildren(elm,oldCh,ch);
    }
    //不然就是它們都有子節點,且子節點相同,若是oldVnode有text值,則將elm的textContent清空
    else if(ifDef(oldVnode.text)){
        api.setTextContext(elm,'');
    }
}
複製代碼

patchVnode邏輯能夠簡化爲下面:

  1. 直接將vnode的elm設置爲oldVnode的elm,以達到複用已有的dom對象,避免了建立新的dom對象的開銷
  2. 比較oldVnode === vnode,若是相等,則直接返回,不一樣更新,由於它們就是同一個對象
  3. 若是vnode有text值,則說明elm就只包含了純text文本,無其餘類型子節點,若是它的值與oldVnode的text不相同,則更新elm的textContent,並返回。
  4. 這一步開始,真正比較它們的children了,
    • 若是vnode有children,oldVnode沒有children,先清空elm的textContext,再將vnode的children添加進來
    • 若是vnode沒有children,oldVnode有children,則直接刪除oldVnode的children
    • 若是它們都有children,且不相同,則更新它們的children
    • 若是它們都有children,且相同,則清空elm的textContext

流程圖以下,

patchVnode更新時,vnode會先是經過觸發定義在data數據上的鉤子函數來更新本身節點上的信息,好比class或者styles等,而後再去更新children節點信息。

updateChildren

更新vnode.children信息是經過updateChildren函數來完成的。只有當oldVnode上存在children,且vnode上也存在children時,而且oldVnode.children !== vnode.children時,纔會去調用updateChildren。下面來梳理一下updateChildren的大體邏輯,

function updateChildren(parentElm,oldCh,newCh){
    // 舊的children
    let oldStartIdx = 0;
    let oldEndIdx = oldCh.length-1;
    let oldStartVnode = oldCh[oldStartIdx];
    let oldEndVnode = oldCh(oldEndIdx);
    
    // 新的children
    let newStartIdx = 0;
    let newEndIdx = newCh.length-1;
    let newStartVnode = newCh(newStartIdx);
    let newEndVnode = newCh(newEndIdx);
    
    let before = null;
    
    // 循環比較
    while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx){
        if(oldStartVnode == null){
            // 當前節點可能被移動了
            oldStartVnode = oldCh[++oldStartIdx];
        }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); // 更新newStartVnode
            oldStartVnode = oldCh[++oldStartIdx]; // oldStartIdx 向右移動
            newStartVnode = newCh[++newStartIdx]; // newStartIdx 向右移動
        }else if(sameVnode(oldEndVnode,newEndVnode)){
            patchVnode(oldEndVnode,newEndVnode); // 更新newEndVnode
            oldEndVnode = oldCh[--oldEndIdx]; // oldEndIdx 向左移動
            newEndVnode = newCh[--newEndIdx]; // newEndIdx 向左移動
        }else if(sameVnode(oldStartVnode,newEndVnode)){
            patchVnode(oldStartVnode,newEndVnode); //更新newEndVnode
            let oldAfterVnode = api.nextSibling(oldEndVnode);
            // 將oldStartVnode移動到當前oldEndVnode後面
            api.insertBefore(parentElm, oldStartVnode.elm,oldAfterVnode);
            oldStartVnode = oldCh[++oldStartIdx]; // oldStartIdx 向右移動
            newEndVnode = newCh[--newOldVnode]; // newEndIdx 向左移動
        }else if(sameVnode(oldEndVnode,newStartVnode)){
            patchVnode(oldEndVnode,newStartVnode); // 更新newStartVnode
            //將oldEndVnode移動到oldStartVnode前面
            api.insertBefore(parentElm,oldEndVnode.elm,oldStartVnode.elm);
            oldEndVnode = oldCh[--oldEndIdx]; // oldEndVnode 向右移動
        	newStartVnode = newCh[++newStartIdx]; // newStartVnode 向左移動
        }else{
            //獲取當前舊的children的節點的key與其index的對應值,
            if(oldKeyIdx == undefined){
                oldKeyIdx = createKeyToOldIdx(oldCh,oldStartIdx,oldEndIdx);
            }
            //獲取當前newStartVnode的key是否存在舊的children數組裏
            idxInOld = oldKeyIdx[newStartVnode.key];
            if(isUndef(idxInOld)){
                //若是當前newStartVnode的key不存在舊的children數組裏,那麼這個newStartVnode就是新的,須要新建dom
                let newDom = createElm(newStartVnode);
                api.insertBefore(parentElm,newDom,oldStartVnode.elm);
                newStartVnode = newCh[++newStartIdx];
            }else{
                //不然,當前newStartVnode的key存在舊的children裏,說明它們以前是同一個Vnode,
                elmToMove = oldCh[idxInOld];
                if(elmToMove.sel !== newStartVnode.sel){
                    //節點類型變了,不是同一個類型的dom元素了,也是須要新建的
                    let newDom = createElm(newStartVnode);
                    api.insertBefore(parentElm,newDom,oldStartVnode.elm);
                }else{
                    // 不然,它們是同一個Vnode且dom元素也相同,則不須要新建,只須要更新便可
                    patchVnode(elmToMove,newStartVnode);
                    oldCh[idxInOld] = undefined; // 標誌舊的children當前位置的元素被移走了,
                    api.insertBefore(parentElm,elmToMove,oldStartVnode.elm);
                }
                newStartVnode = newCh[++newStartIdx];
            }
        }
    }
    
    // 若是循環以後,還有未處理的children,
    if(oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx){
        // 若是新的children還有部分未處理,則把多的部分增長進去
        if(oldStartIdx > oldEndIdx){
            before = newCh[newEndIdx+1] == null ? null : newCh[newEndIdx+1];
            addVnodes(parentElm,before,newCh,newStartIdx,newEndIdx);
        }else{
            //若是舊的children還有未處理,則把多的部分刪除掉
            removeVnodes(parentElm,oldCh,oldStartIdx,oldEndIdx);
        }
    }
}
複製代碼

updateChildren函數邏輯能夠簡化爲,

  1. 初始化循環變量
  2. 根據變量循環遍歷old children與new children,並逐個比較更新,當類型相同時,則調用patchVnode更新,當類型不一樣時,則直接新建new vnode的dom 元素,並插入到合適的位置
  3. 循環完了以後,增長新增的new vnode節點和移除舊的冗餘的old vnode

流程圖以下,

updateChildren函數中,逐個更新children中節點時,當比較的兩個節點類型相同時,又會反過來調用patchVnode來更新節點,這樣,實際上存在了間接的遞歸調用。

life cycle hooks

在使用React或者Vue時,你會發現它們都分別定義了組件的生命週期方法,雖然名稱或觸發時機不徹底相同,可是基本的順序和目的是差很少的。Snabbdom也提供了相應的生命週期鉤子函數,不一樣的是它提供了2套,一套是針對virtual dom 的,好比一個Vnode的create,update,remove等;一套是針對modules的,經過在不一樣時機觸發不一樣module的鉤子函數去完成當前Vnode的更新操做。

modules的上的鉤子函數以下,

export interface Module {
  pre: PreHook;
  create: CreateHook;
  update: UpdateHook;
  destroy: DestroyHook;
  remove: RemoveHook;
  post: PostHook;
}
複製代碼

它的觸發時機圖以下,

在觸發modules的hooks函數時,不一樣的函數會接受不一樣的參數,下面爲modukes中鉤子函數接受參數狀況,

Name Triggered when Arguments to callback
pre patch函數開始處
create createElm函數中建立一個element時 vnode
update pathVnode函數中更新Vnode時, oldVnodenewVnode
destroy removeVnodes函數中移除Vnode時, vnode
remove removeVnodes函數中移除Vnode時, vnoderemoveCallback
post patch函數最後處,

大部分module中都沒有定義pre函數和post函數,主要是在createupdatedestoryremove中對當前Vnode進行操做。好比,在class module中在create函數內對Vnode上的操做以下,

// class modules 中在create鉤子函數中對當前Vnode操做
function updateClass(oldVnode: VNode, vnode: VNode): void {
  var cur: any, name: string, elm: Element = vnode.elm as Element,
      oldClass = (oldVnode.data as VNodeData).class,// 舊的class
      klass = (vnode.data as VNodeData).class; // 新的class

  if (!oldClass && !klass) return; // 都不存在class,直接返回
  if (oldClass === klass) return; // 相等,直接返回
  oldClass = oldClass || {};
  klass = klass || {};

    // 刪除那些存在oldVnode上而不存在vnode上的
  for (name in oldClass) {
    if (!klass[name]) {
      elm.classList.remove(name);
    }
  }
    // 遍歷當前vnode上的class,
  for (name in klass) {
    cur = klass[name];
      //若是不想等
    if (cur !== oldClass[name]) {
        // 若是值爲true,則添加class,不然移除class
      (elm.classList as any)[cur ? 'add' : 'remove'](name);
    }
  }
}
複製代碼

其餘module的其餘hook函數也都會對當前vnode更新,這裏就不一一列舉了。

咱們再來看看對Vnode上的鉤子函數以下,

export interface Hooks {
  init?: InitHook;
  create?: CreateHook;
  insert?: InsertHook;
  prepatch?: PrePatchHook;
  update?: UpdateHook;
  postpatch?: PostPatchHook;
  destroy?: DestroyHook;
  remove?: RemoveHook;
}
複製代碼

它的觸發時機以及接受參數狀況以下,

Name Triggered when Arguments to callback
init createElm時會先觸發init vnode
create createElm時,已經建好了element,已經對應的children都建立完畢,以後在觸發create emptyVnodevnode
insert vnode.elm已經更新到dom文檔上了,最後在patch函數結尾處觸發 vnode
prepatch patchVnode開始處就觸發了prepatch oldVnodevnode
update patchVnode中,vnode.elm=oldVnode.elm以後,更新children以前觸發 oldVnodevnode
postpatch patchVnode中結尾處,已經更新爲children後觸發, oldvnodevnode
destroy removeVnodes中觸發,此時尚未被移除 vnode
remove removeVnodes中,destroy以後觸發,此時尚未真正被移除,需調用removeCallback才真正將element移除 vnoderemoveCallback

Vnode上的鉤子函數就是咱們本身定義的了,定義在data.hooks中,例如,

h('div.row', {
  key: movie.rank,
  hook: {
    insert: (vnode) => { movie.elmHeight = vnode.elm.offsetHeight; }
  }
});
複製代碼

小結

在看了源碼以後,其實最爲複雜的地方就是updateChildren中更新子節點,這裏爲了不重複建立element,而作了不少的判斷和比較,以達到最大化的複用以前已經建立好的element。與React和Vue相似,它在比較中也添加了key來優化這一點。在更新Vnode對應的element時,它將不一樣數據分解到不一樣module中去更新,經過鉤子函數來觸發,這一點很是的優雅。

相關文章
相關標籤/搜索