Vue源碼終筆-VNode更新與diff算法初探


  寫完這個就差不多了,准備干新項目了。

  確實挺不擅長寫東西,感覺都是羅列代碼寫點注釋的感覺,這篇就簡單闡述一下數據變動時DOM是如何更新的,主要講解下其中的diff算法。

 

  先來個正常的html模板:

    <body>
        <div id='app'>
            <div v-for="item in items">{{item}}</div>
            <div @click='click'>click me!</div>
        </div>
    </body>
    <script src='./vue.js'></script>
    <script>
        new Vue({
            el: '#app',
            data: {
                items: [1]
            },
            methods: {
                click: function() {
                    this.items.push(2);
                }
            }
        })

  頁面上有一個通過v-for渲染的div,還有一個按鈕,點擊按鈕時會讓div數量+1。

  

  首先需要提到的是,每一次渲染DOM,都會保存一份當前虛擬DOM的副本掛載到_vnode屬性上,如圖:

  點擊前,整個VNode結構為:根節點及3個子節點,子節點均包含2個div標簽和一個空白文本節點,div包含對應的文本節點。

  點擊后,由於vue劫持了部分數組方法,所以會進入自定義的push方法中,將彈入的新元素進行廣播,過程就不看了。

  完成數組添加后,會生成一個新的render函數與新的VNode,diff算法就是比較新舊VNode的差異,通過最小的變化操作渲染新的DOM。

  講VNode的diff算法之前,有一個小點先講一下:如何判斷當前VNode可復用?

  銷毀一個DOM節點並創建一個新的再插入是消耗非常大的,無論是DOM對象本身的復雜性還是操作引起的重繪重排,所以虛擬DOM的目標是盡可能復用現有DOM進行更新。

  其中涉及的概念就是新的VNode能否在舊的基礎上修改並復用呢?有一個函數就是做這個判斷的:

    function sameVnode(a, b) {
        return (
            // key來源於v-for或者自定的:key屬性
            a.key === b.key &&
            a.tag === b.tag &&
            a.isComment === b.isComment &&
            isDef(a.data) === isDef(b.data) &&
            sameInputType(a, b)
        )
    }

  該判斷有5重標准:

  (1)key:key屬性如果沒有設置默認是undefined,當且僅當v-for的列表渲染中會給節點加一個唯一的key,形式如圖:,key不一樣的節點不進行復用,官方文檔也有說明設置key屬性可以強制重新生成一個新DOM。

  (2)tag:復用的節點必須保證標簽名一致,畢竟沒有更改tag名的API

  (3)isComment:注釋與普通的DOM不是一個次元,所以需要判斷

  (4)isDef(*.data):這個涉及屬性的更新,如果一個節點沒有任何屬性,即data為undefined,與一個有data屬性的節點進行更新不如直接渲染一個新的

  (5)sameInputType:這個主要是input標簽type屬性異同判斷,不同的type相當於不同的tag

  如果均滿足,可以判定該節點可復用。

 

  前面說了,每一個更改數據源,會生成一個新的VNode,來與舊的VNode進行比較,節點間的比較無非是判斷是否可復用,再進行屬性置換。

  而diff算法主要是針對子節點的更新,即兩個數組之間的異同比較與更新。

  一個數組的變化無非3個狀態:增、刪、改,但是其中增刪會涉及數組索引與對應元素的變動,總體來講還是比較復雜的。

  源碼中有一個函數專門處理子節點比較,整體如下:

    function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
        // var... 
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { // 舊VNode不存在 if (isUndef(oldStartVnode)) { // ... } else if (isUndef(oldEndVnode)) { // ... } else if (sameVnode(oldStartVnode, newStartVnode)) { // ... } else if (sameVnode(oldEndVnode, newEndVnode)) { // ... } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right // ... } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left // ... } else { // ... } } if (oldStartIdx > oldEndIdx) { // ... } else if (newStartIdx > newEndIdx) { // ... } }

  第一次看還是比較懵逼的,主路線while循環中有7重判斷,分別對應7種情況。

  分解本例中的情況,不貼代碼,嘗試畫個圖:

  

  對比新舊VNode,可以看出新的VNode在索引0的后面插入了一個新的tag

  接下來通過updateChildren函數進行比較,有很多的變量,這里還需要一個圖:

  在函數中有8個變量,其中4個舊VNode,4個新VNode,分別是一一對應的,解釋一半就行了:

    var oldStartIdx = 0;
    var 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];

  (1)oldStartIdx => 從前往后的舊VNode數組索引,初始化時為0 => 簡稱為前索引

  (2)oldStartVnode => 對應索引的舊VNode元素 => 簡稱為前元素

  (3)oldEndIdx => 從后往前的舊VNode數組索引,初始化為children的數組長度 => 簡稱為后索引

  (4)oldEndVnode => 對應索引的舊Vnode元素 => 簡稱為后元素

  后面的闡述全部用簡稱,不然太難講了,並且新VNode的數組簡稱newCh,舊VNode的數組簡稱oldCh

  另外4個變量只是將old更換為new,並對應新VNode的索引與元素。

  

  接下來是一個大while循環,終止條件是前索引大於后索引(newCh或oldCh):

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (isUndef(oldStartVnode)) {
            // ...
        } else if (isUndef(oldEndVnode)) {
            // ...
        } else if (sameVnode(oldStartVnode, newStartVnode)) {
            // ...
        } else if (sameVnode(oldEndVnode, newEndVnode)) {
            // ...
        } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
            // ...
        } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
            // ...
        } else {
            // ...
        }
    }

   由於有幾種情況我模擬不出來,只能大概過一下。

1、isUndef(oldStartVnode)、isUndef(oldEndVnode)

  前兩種是oldCh前元素oldCh后元素不存在,我能模擬的情況是當oldCh中沒有元素時,會出現這種情況。

  這時只是單純加前索引加1或者后索引減1,而oldCh長度此時為0,會立即跳出while循環,進入下一步。

2、sameVnode(a,b)

  下面的的4種情況都是判斷節點是否可復用,然后進行更新。其中對比的情況有4對:

  oldCh前元素 => newCh前元素

  oldCh后元素 => newCh后元素

  oldCh前元素 => newCh后元素

  oldCh后元素 => newCh前元素 

  取第一種情況來說,如果比較通過,說明oldCh前元素可以被復用,隨即調用patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)來對DOM進行更新,由於tag是不變的,可以直接對DOM進行各種API調用,比如說事件更改,只要remove舊事件,add新事件就行,這里只是DOM對象的屬性更改,不會影響到DOM的增刪。

  當patch完畢后,會將oldCh前索引newCh的前索引加1,並更新對應的元素,然后進入下一輪循環。

  畫一輪圖解釋:

  

  此時第一個子節點已經更新完畢,然后重新開始對比,如果oldCh與newCh的索引1處也可復用,會再次更新並加1,直到前索引大於后索引時,說明所有可能的比較都進行完畢。

  這里的4種比較沒有必要重復過一遍,如果是前索引就加1,后索引就減1。

3、else{...}

  最后一種情況是需要強制更新元素時才會有的情況,比如:

    <body>
        <div id='app'>
            <div v-if="!vIfIter" key='o'>old Ele1</div>
            <div v-if="vIfIter" key='n'>new Ele</div>
            <div @click='click'>click me!</div>
        </div>
    </body>
    <script src='./vue.js'></script>
    <script>
        new Vue({
            el: '#app',
            data: {
                vIfIter: false
            },
            methods: {
                click: function() {
                    this.vIfIter = true;
                }
            }
        })
    </script>

  此時,由於設置了單獨的key值,所以div被標記為不可復用,跳過了所有判斷進入了else階段:

    // 這里將舊VNode中剩余的元素key值作為對象輸出
    if (isUndef(oldKeyToIdx)) {
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
    }
    // 判斷新VNode中是否存在可復用的元素
    idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : null;
    // 不存在就創建一個新的插入DOM中
    if (isUndef(idxInOld)) {
        // New element
    }
    // 存在 
    else {
        elmToMove = oldCh[idxInOld];
        if (sameVnode(elmToMove, newStartVnode)) {
            // 更新VNode
            patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
            // 把舊的VNode置空 此處會觸發到while循環的前兩個判斷
            oldCh[idxInOld] = undefined;
            // 移動更新后的VNode
            canMove && nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm);
            newStartVnode = newCh[++newStartIdx];
        }
        // 同樣的key值不同的tag 創建新DOM插入
        else {
            // same key but different element. treat as new element
        }
    }

  簡單來講還是可復用就復用,不可復用創建新DOM插入。

 

  最后來看看while循環跳出來的語句,其實很簡單:

    // VNode數量增加了
    if (oldStartIdx > oldEndIdx) {
        // 如果VNode是中間插入就會存在refElm
        // 否則refElm為null 調用insertBefore會將DOM插入父元素尾部
        refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
        addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
    }
    // 減少了 
    else if (newStartIdx > newEndIdx) {
        // 移除多出來的DOM節點
        removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
    }

 

  至此,所有的分析完了,上面的案例有興趣可以自己跑跑。

  

  不容易啊,寫完了。。。已經入行5個月,由於沒有什么好項目練手,只能看源碼提升基本功,接下來可能很長時間不寫博客了。(反正也沒人看,啊哈哈哈哈~)

  (定個小目標,Codewars刷到3kyu,加油!)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM