理解virtual dom的實現細節-snabbdom

三隻小羊發表於2019-01-01

最近想了解一下React和Vue框架分別在virtual dom部分的實現,以及他們的不同之處。於是先翻開Vue的原始碼去找virtual dom 的實現,看到開頭,它就提到了Vue的virtual dom更新演算法是基於Snabbdom實現的。於是,又去克隆了Snabbdom的原始碼,發現它的原始碼並不是很複雜並且星星?還很多,所以就仔細看了一遍了,這裡就將詳細學習一下它是如何實現virtual dom的。

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

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

這裡是Snabbdom的倉庫地址

init

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

// 這裡我們只需要用到class和style模組,所以就可以只需要引用這2個模組
var patch = snabbdom.init([
 require('snabbdom/modules/class').default,
 require('snabbdom/modules/style').default,
]);
複製程式碼

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

//這裡是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來完成的。

流程圖如下,

理解virtual dom的實現細節-snabbdom

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邏輯可以簡化為下面:

  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

流程圖如下,

理解virtual dom的實現細節-snabbdom

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

理解virtual dom的實現細節-snabbdom

如果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

流程圖如下,

理解virtual dom的實現細節-snabbdom

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

流程圖如下,

理解virtual dom的實現細節-snabbdom

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;
}
複製程式碼

它的觸發時機圖如下,

理解virtual dom的實現細節-snabbdom

在觸發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中去更新,通過鉤子函式來觸發,這一點非常的優雅。

相關文章