snabbdom.js(二)

總共寫了四篇文章(都是本身的一些拙見,僅供參考,請多多指教,我這邊也會持續修正加更新)vue

  1. 介紹一下snabbdom基本用法
  2. 介紹一下snabbdom渲染原理
  3. 介紹一下snabddom的diff算法和對key值的認識
  4. 介紹一下對於兼容IE8的修改

這篇我將以本身的思路去解讀一下源碼(這裏的源碼我爲了兼容IE8有做修改);node

對虛擬dom的理解

經過js對象模擬出一個咱們須要渲染到頁面上的dom樹的結構,實現了一個修改js對象便可修改頁面dom的快捷途徑,避免了咱們‘手動’再去一次次操做dom-api的繁瑣,並且其提供了算法可使得用最少的dom操做進行修改。

從例子出發,尋找切入點

var snabbdom = SnabbdomModule;

var patch = snabbdom.init([ //導入相應的模塊
    DatasetModule,
    ClassModule,
    AttributesModule,
    PropsModule,
    StyleModule,
    EventlistenerModule
]);

var h = HModule.h;

var app = document.getElementById('app');

var newVnode = h('div#divId.red', {}, [h('p', {},'已改變')])

var vnode = h('div#divId.red', {}, [h('p',{},'2S後改變')])

vnode = patch(app, vnode);

setTimeout(function() {
    vnode=patch(vnode, newVnode);
}, 2000)

從上面的例子不難看出,咱們須要從三個重點函數 init patch h 切入,這三個函數分別的做用是:初始化模塊,對比渲染,構建vnode;算法

而文章開頭我說了實現虛擬dom的第一步就是 經過js對象模擬出一個咱們須要渲染到頁面上的dom樹的結構,因此'首當其衝'就是須要先了解h函數,如何將js對象封裝成vnode,vnode是咱們定義的虛擬節點,而後就是利用patch函數進行渲染segmentfault

構建vnode

h.js

var HModule = {};
(function(HModule) {
    var VNode = VNodeModule.VNode;
    var is = isModule;
    /**
     *
     * @param sel 選擇器
     * @param b    數據
     * @param childNode    子節點
     * @returns {{sel, data, children, text, elm, key}}
     */

    //調用vnode函數將數據封裝成虛擬dom的數據結構並返回,在調用以前會對數據進行一個處理:是否含有數據,是否含有子節點,子節點類型的判斷等
    HModule.h = function(sel, b, childNode) {
        var data = {},
            children, text, i;
        if (childNode !== undefined) { //若是childNode存在,則其爲子節點
            //則h的第二項b就是data
            data = b;
            if (is.array(childNode)) { //若是子節點是數組,則存在子element節點
                children = childNode;
            } else if (is.primitive(childNode)) { //不然子節點爲text節點
                text = childNode;
            }
        } else if (b !== undefined) { //若是隻有b存在,childNode不存在,則b有多是子節點也有多是數據
            //數組表明子element節點
            if (is.array(b)) {
                children = b;
            } else if (is.primitive(b)) { //表明子文本節點
                text = b;
            } else { //表明數據
                data = b;
            }
        }
        if (is.array(children)) {
            for (i = 0; i < children.length; ++i) {
                //若是子節點數組中,存在節點是原始類型,說明該節點是text節點,所以咱們將它渲染爲一個只包含text的VNode
                if (is.primitive(children[i])) children[i] = VNode(undefined, undefined, undefined, children[i]);
            }
        }
        //返回VNode
        return VNode(sel, data, children, text, undefined);

    }
})(HModule)

h函數的主要工做就是把傳入的參數封裝爲vnodeapi

接下來看一下,vnode的結構數組

vnode.js

var VNodeModule = {};

(function(VNodeModule) {
    VNodeModule.VNode = function(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
        };
    }
})(VNodeModule)
sel 對應的是選擇器,如'div','div#a','div#a.b.c'的形式

data 對應的是vnode綁定的數據,能夠有如下類型:attribute、props、eventlistner、
class、dataset、hook

children 子元素數組

text 文本,表明該節點中的文本內容

elm 裏面存儲着對應的真實dom element的引用

key vnode標識符,主要是用在須要循環渲染的dom元素在進行diff運算時的優化算法,例如ul>li,tobody>tr>td等
text和children是不會同時存在的,存在text表明子節點僅爲文本節點
如:h('p',123) ---> <p>123</p>;

存在children表明其子節點存在其餘元素節點(也能夠包含文本節點),須要將這些節點放入數組中 如:h('p',[h('h1',123),'222']) ---> <p><h1>123</h1>222</p>數據結構

打印一下例子中調用h函數後的結構:
vnode:
圖片描述
newVnode:
圖片描述app

關於elm這個值後面再說dom

初始化模塊和對比渲染

利用vnode生成咱們的虛擬dom樹後,就須要開始進行渲染了;只因此說是對比渲染,是由於它渲染的機制不是直接把咱們的設置好的vnode所有渲染,而是會進行一次新舊vnode的對比,進行差別渲染;函數

snabbdom.js

init函數

function init(modules, api) {
   ... 
}

它有兩個參數,第一個是須要加載的模塊數組,第二個是操做dom的api,通常咱們只須要傳入第一個參數便可

1.模塊的初始化

先拿個模塊舉例:

var ClassModule = {};
function updateClass(oldVnode, vnode){}
ClassModule.create = updateClass;
ClassModule.update = updateClass;
var hooks = ['create', 'update', 'remove', 'destroy', 'pre', 'post']; //全局鉤子:modules自帶的鉤子函數

function init(modules, api) {
    var i, j, cbs = {};
    ...
    for (i = 0; i < hooks.length; ++i) {
            cbs[hooks[i]] = [];
            for (j = 0; j < modules.length; ++j) {
                if (modules[j][hooks[i]] !== undefined) cbs[hooks[i]].push(modules[j][hooks[i]]);
        }
    }
    ...
}

上面就是模塊初始化的核心,事先在模塊中定義好鉤子函數(即模塊對於vnode的操做),而後在init函數中依次將這些模塊的鉤子函數加載進來,放在一個對象中保存,等待調用;

ps:init函數裏面還會定義一些功能函數,等用到的時候再說,而後下一個須要分析的就是init被調用後會return一個函數---patch函數(這個函數是本身定義的一個變量名);

2.調用patch函數進行對比渲染
    在沒看源碼以前,我一直覺得snabbdom的對比渲染是會把新舊vnode對比結果產生一個差別對象,而後在利用這個差別對象再進行渲染,後面看了後發現snabbdom這邊是在對比的同時就直接利用dom的API在舊的dom上進行修改,而這些操做(渲染)就是定義在咱們前面加載的模塊中。

這裏須要說一下snabbdom的對比策略是針對同層級的節點進行對比
圖片描述
其實這裏就有一個小知識點,bfs---廣度優先遍歷

廣度優先遍歷從某個頂點出發,首先訪問這個頂點,而後找出這個結點的全部未被訪問的鄰接點,訪問完後再訪問這些結點中第一個鄰接點的全部結點,重複此方法,直到全部結點都被訪問完爲止。

網上介紹的文章不少,我這邊就不過多介紹了;

舉個例子

var tree = {
    val: 'div',
    ch: [{
        val: 'p',
        ch: [{
            val: 'text1'
        }]
    }, {
        val: 'p',
        ch: [{
            val: 'span',
            ch: [{
                val: 'tetx2'
            }]
        }]
    }]

}

function bfs(tree) {
    var queue = [];
    var res = []

    if (!tree) return
    queue.push(tree);
    while (queue.length) {
        var node = queue.shift();
        if (node.ch) {
            for (var i = 0; i < node.ch.length; i++) {
                queue.push(node.ch[i]);
            }
        }
        if (node.val) {
            res.push(node.val);
        }
    }
    return res;
}

console.log(bfs(tree)) //["div", "p", "p", "text1", "span", "tetx2"]

思路:先把根節點放入一個數組queue中,而後將其取出來,判斷其是否有子節點,若是有,將其子節點依次放入queue數組中;而後依次再從這個數組中取值,重複上述步驟,直到這個數組queue沒有數據;


這裏snabbdom會比較每個節點它的sel是否類似,若是類似對其子節點再進行比較,不然直接刪除這個節點,添加新節點,其子節點也不會繼續進行比較

patch函數

return function(oldVnode, vnode) {
            var i, elm, parent;
            //記錄被插入的vnode隊列,用於批量觸發insert
            var insertedVnodeQueue = [];
            //調用全局pre鉤子
            for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
            //若是oldvnode是真實的dom節點,則轉化爲一個空vnode,通常這是初始化渲染的時候會用到
            if (isUndef(oldVnode.sel)) {
                oldVnode = emptyNodeAt(oldVnode);
            }
            //若是oldvnode與vnode類似,進行更新;類似是比較其key值與sel值
            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;
        };

流程圖:
圖片描述

  • 當oldvnode的sel爲空的時候,這裏出現的場景基本上就是咱們第一次調用patch去初始化渲染頁面
  • 比較類似的方式爲vnode的sel,key兩個屬性是否相等,不定義key值也不要緊,由於不定義則爲undefined,而undefined===undefined,只須要sel相等便可類似
  • 因爲比較策略是同層級比較,因此當父節點不相類似時,子節點也不會再去比較
  • 最後會將vnode返回,也就是咱們此刻須要渲染到頁面上的vnode,它將會做爲下一次渲染時的oldvnode

這基本上就是一個對比的大致過程,值得研究的東西還在後面,涉及到了其核心的diff算法,下篇文章再提。

再介紹一下上面用到的一些功能函數:

isUndef
爲is.js中的函數,用來判斷數據是否爲undefined

emptyNodeAt

function emptyNodeAt(elm) {
    var id = elm.id ? '#' + elm.id : '';
    var c = elm.className ? '.' + elm.className.split(' ').join('.') : '';
    return VNode(api.tagName(elm).toLowerCase() + id + c, {}, [], undefined, elm);
}

用來將一個真實的無子節點的DOM節點轉化成vnode形式,
如:<div id='a' class='b c'></div>
將轉換爲{sel:'div#a.b.c',data:{},children:[],text:undefined,elm:<div id='a' class='b c'>}

sameVnode

function sameVnode(vnode1, vnode2) {
    return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel;
}

用來比較兩個vnode是否類似。
若是新舊vnode的key和sel都相同,說明兩個vnode類似,咱們就能夠保留舊的vnode節點,再具體去比較其差別性,在舊的vnode上進行'打補丁',不然直接替換節點。這裏須要說的是若是不定義key值,則這個值就爲undefined,undefined===undefined //true,因此平時在用vue的時候,在沒有用v-for渲染的組件的條件下,是不須要定義key值的,不會影響其比較。

createElm
建立vnode對應的真實dom,並將其賦值給vnode.elm,後續對於dom的修改都是在這個值上進行

//將vnode建立爲真實dom
    function createElm(vnode, insertedVnodeQueue) {
        var i, data = vnode.data;
        if (isDef(data)) {
            //當節點上存在hook並且hook中有beforeCreate鉤子時,先調用beforeCreate回調,對剛建立的vnode進行處理
            if (isDef(i = data.hook) && isDef(i = i.beforeCreate)) {
                i(vnode);
                //獲取beforeCreate鉤子修改後的數據
                data = vnode.data;
            }
        }
        var elm, children = vnode.children,
            sel = vnode.sel;
        if (isDef(sel)) {
            //解析sel參數,例如div#divId.divClass  ==>id="divId"  class="divClass"
            var hashIdx = sel.indexOf('#');
            //先id後class
            var dotIdx = sel.indexOf('.', hashIdx);
            var hash = hashIdx > 0 ? hashIdx : sel.length;
            var dot = dotIdx > 0 ? dotIdx : sel.length;
            var tag = hashIdx !== -1 || dotIdx !== -1 ? sel.slice(0, Math.min(hash, dot)) : sel;
            //建立一個DOM節點引用,並對其屬性實例化
            elm = vnode.elm = isDef(data) && isDef(i = data.ns) ? api.createElementNS(i, tag) : api.createElement(tag);
            //獲取id名 #a --> a
            if (hash < dot) elm.id = sel.slice(hash + 1, dot);
            //獲取類名,並格式化  .a.b --> a b
            if (dotIdx > 0) elm.className = sel.slice(dot + 1).replace(/\./g, ' ');
            //若是存在子元素Vnode節點,則遞歸將子元素節點插入到當前Vnode節點中,並將已插入的子元素節點在insertedVnodeQueue中做記錄
            if (is.array(children)) {
                for (i = 0; i < children.length; ++i) {
                    api.appendChild(elm, createElm(children[i], insertedVnodeQueue));
                }
            } else if (is.primitive(vnode.text)) { //若是存在子文本節點,則直接將其插入到當前Vnode節點
                api.appendChild(elm, api.createTextNode(vnode.text));
            }
            //當建立完畢後,觸發全局create鉤子回調
            for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode);
            i = vnode.data.hook; // Reuse variable
            if (isDef(i)) { //觸發自身的create鉤子回調
                if (i.create) i.create(emptyNode, vnode);
                //若是有insert鉤子,則推動insertedVnodeQueue中做記錄,從而實現批量插入觸發insert回調
                if (i.insert) insertedVnodeQueue.push(vnode);
            }
        }
        //若是沒聲明選擇器,則說明這個是一個text節點
        else {
            elm = vnode.elm = api.createTextNode(vnode.text);
        }
        return vnode.elm;
    }

patchVnode

若是兩個vnode類似,則會對具體的vnode進行‘打補丁’的操做

function patchVnode(oldVnode, vnode, insertedVnodeQueue) {
        var i, hook;
        //在patch以前,先調用vnode.data的beforePatch鉤子
        if (isDef(i = vnode.data) && isDef(hook = i.hook) && isDef(i = hook.beforePatch)) {
            i(oldVnode, vnode);
        }
        var elm = vnode.elm = oldVnode.elm,
            oldCh = oldVnode.children,
            ch = vnode.children;
        //若是oldnode和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);
        }
        /*
         分狀況討論節點的更新: new表明新Vnode old表明舊Vnode
         ps:若是自身存在文本節點,則不存在子節點 即:有text則不會存在ch,反之亦然

         1 new不爲文本節點
             1.1 new不爲文本節點,new還存在子節點    
               1.1.1 new不爲文本節點,new還存在子節點,old有子節點
               1.1.2 new不爲文本節點,new還存在子節點,old沒有子節點
                  1.1.2.1 new不爲文本節點,new還存在子節點,old沒有子節點,old爲文本節點

            1.2 new不爲文本節點,new不存在子節點
              1.2.1 new不爲文本節點,new不存在子節點,old存在子節點
              1.2.2 new不爲文本節點,new不存在子節點,old爲文本節點

         2.new爲文本節點
             2.1 new爲文本節點,而且old與new的文本節點不相等
             ps:這裏只須要討論這一種狀況,由於若是old存在子節點,那麼文本節點text爲undefined,則與new的text不相等
             直接node.textContent便可清楚old存在的子節點。若old存在子節點,且相等則無需修改
        */
        //1
        if (isUndef(vnode.text)) {
            //1.1.1
            if (isDef(oldCh) && isDef(ch)) {
                //當Vnode和oldvnode的子節點不一樣時,調用updatechilren函數,diff子節點
                if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
            }
            //1.1.2
            else if (isDef(ch)) {
                //oldvnode是text節點,則將elm的text清除
                //1.1.2.1
                if (isDef(oldVnode.text)) api.setTextContent(elm, '');
                //並添加vnode的children
                addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
            }
            //若是oldvnode有children,而vnode沒children,則移除elm的children
            //1.2.1
            else if (isDef(oldCh)) {
                removeVnodes(elm, oldCh, 0, oldCh.length - 1);
            }
            //1.2.2
            //若是vnode和oldvnode都沒chidlren,且vnode沒text,則刪除oldvnode的text
            else if (isDef(oldVnode.text)) {
                api.setTextContent(elm, '');
            }
        }
        //若是oldvnode的text和vnode的text不一樣,則更新爲vnode的text,
        //2.1
        else if (oldVnode.text !== vnode.text) {
            api.setTextContent(elm, vnode.text);
        }
        //patch完,觸發postpatch鉤子
        if (isDef(hook) && isDef(i = hook.postpatch)) {
            i(oldVnode, vnode);
        }
    }

removeVnodes

/*
        這個函數主要功能是批量刪除DOM節點,須要配合invokeDestoryHook和createRmCb
        主要步驟以下:
        調用invokeDestoryHook以觸發destory回調
        調用createRmCb來開始對remove回調進行計數
        刪除DOM節點
     *
     *
     * @param parentElm 父節點
     * @param vnodes  刪除節點數組
     * @param startIdx  刪除起始座標
     * @param endIdx  刪除結束座標
     */
    function removeVnodes(parentElm, vnodes, startIdx, endIdx) {
        for (; startIdx <= endIdx; ++startIdx) {
            var i, listeners, rm, ch = vnodes[startIdx]; //ch表明子節點
            if (isDef(ch)) {
                if (isDef(ch.sel)) {
                    //調用destroy鉤子
                    invokeDestroyHook(ch);
                    //對全局remove鉤子進行計數
                    listeners = cbs.remove.length + 1;
                    rm = createRmCb(ch.elm, listeners);
                    //調用全局remove回調函數,並每次減小一個remove鉤子計數
                    for (i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm);
                    //調用內部vnode.data.hook中的remove鉤子(只有一個)
                    if (isDef(i = ch.data) && isDef(i = i.hook) && isDef(i = i.remove)) {
                        i(ch, rm);
                    } else {
                        //若是沒有內部remove鉤子,須要調用rm,確保可以remove節點
                        rm();
                    }
                } else { // Text node
                    api.removeChild(parentElm, ch.elm);
                }
            }
        }
    }

invokeDestroyHook

/*
    這個函數用於手動觸發destory鉤子回調,主要步驟以下:
    先調用vnode上的destory
    再調用全局下的destory
    遞歸調用子vnode的destory
    */
    function invokeDestroyHook(vnode) {
        var i, j, data = vnode.data;
        if (isDef(data)) {
            if (isDef(i = data.hook) && isDef(i = i.destroy)) i(vnode); //調用自身的destroy鉤子
            for (i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode); //調用全局destroy鉤子
            if (isDef(i = vnode.children)) {
                for (j = 0; j < vnode.children.length; ++j) {
                    invokeDestroyHook(vnode.children[j]);
                }
            }
        }
    }

addVnodes

//將vnode轉換後的dom節點插入到dom樹的指定位置中去
function addVnodes(parentElm, before, vnodes, startIdx, endIdx, insertedVnodeQueue) {
    for (; startIdx <= endIdx; ++startIdx) {
        api.insertBefore(parentElm, createElm(vnodes[startIdx], insertedVnodeQueue), before);
    }
}

createRmCb

/*
remove一個vnode時,會觸發remove鉤子做攔截器,只有在全部remove鉤子
回調函數都觸發完纔會將節點從父節點刪除,而這個函數提供的就是對remove鉤子回調操做的計數功能
*/
function createRmCb(childElm, listeners) {
    return function() {
        if (--listeners === 0) {
            var parent = api.parentNode(childElm);
            api.removeChild(parent, childElm);
        }
    };
}

還有一個最核心的函數updateChildren,這個留到下篇文章再說;

咱們這邊簡單的總結一下:對比渲染的流程大致分爲1.經過sameVnode來判斷兩個vnode是否值得進行比較2.若是不值得,直接刪除舊的vnode,渲染新的vnode3.若是值得,調用模塊鉤子函數,對其節點的屬性進行替換,例如style,event等;再判斷節點子節點是否爲文本節點,若是爲文本節點則進行更替,若是還存在其餘子節點則調用updateChildren,對子節點進行更新,更新流程將會回到第一步,重複;

相關文章
相關標籤/搜索