【Vue原理】Diff - 源碼版 之 Diff 流程

寫文章不容易,點個讚唄兄弟


專注 Vue 源碼分享,文章分爲白話版和 源碼版,白話版助於理解工作原理,源碼版助於瞭解內部詳情,讓我們一起學習吧
研究基於 Vue版本 【2.5.17】

如果你覺得排版難看,請點擊 下面鏈接 或者 拉到 下面關注公衆號也可以吧

【Vue原理】Diff - 源碼版 之 Diff 流程

今天終於要開始探索 Vue 更新DOM 的重點了,就是 Diff

Diff 的內容不算多,但是如果要講得很詳細的話,就要說很多了,而且要配很多圖

這是 Diff 的最後一篇文章,最重要也是最詳細的一篇了

所以本篇內容很多,先提個內容概覽

1、分析 Diff 源碼比較步驟

2、個人思考爲什麼如此比較

3、寫個例子,一步步走個Diff 流程

文章很長,也非常詳細,如果你對這內容有興趣的話,也推薦邊閱讀源碼邊看,如果你對本內容暫時沒有了解,可以先看不涉及源碼的白話版 Diff - 白話版

下面開始我們的正文

公衆號

在之前一篇文章 Diff - 源碼版 之 從新建實例到開始diff ,我們已經探索了 Vue 是如何從新建實例到開始diff 的

你應該還有印象,其中Diff涉及的一個重要函數就是 createPatchFunciton

var patch = createPatchFunction();

Vue.prototype.__patch__ =  patch

那麼我們就來看下這個函數


createPatchFunction

function createPatchFunction() {  

    return function patch(

        oldVnode, vnode, parentElm, refElm    

    ) {      

        // 沒有舊節點,直接生成新節點

        if (!oldVnode) {
            createElm(vnode, parentElm, refElm);
        } 
        else {     
            // 且是一樣 Vnode

            if (sameVnode(oldVnode, vnode)) {                

                // 比較存在的根節點

                patchVnode(oldVnode, vnode);
            } 
            else {    

                // 替換存在的元素

                var oldElm = oldVnode.elm;                

                var _parentElm = oldElm.parentNode    

                // 創建新節點

                createElm(vnode, _parentElm, oldElm.nextSibling);   

                // 銷燬舊節點

                if (_parentElm) {
                    removeVnodes([oldVnode], 0, 0);
                }
            }
        }        

        return vnode.elm

    }
}

這個函數的作用就是

比較 新節點 和 舊節點 有什麼不同,然後完成更新

所以你看到接收一個 oldVnode 和 vnode

處理的流程分爲

1、沒有舊節點

2、舊節點 和 新節點 自身一樣(不包括其子節點)

3、舊節點 和 新節點自身不一樣

速度來看下這三個流程了

1 沒有舊節點

沒有舊節點,說明是頁面剛開始初始化的時候,此時,根本不需要比較了

直接全部都是新建,所以只調用 createElm

2 舊節點 和 新節點 自身一樣

通過 sameVnode 判斷節點是否一樣,這個函數在上篇文章中說過了

舊節點 和 新節點自身一樣時,直接調用 patchVnode 去處理這兩個節點

patchVnode 下面會講到這個函數

在講 patchVnode 之前,我們先思考這個函數的作用是什麼?

當兩個Vnode自身一樣的時候,我們需要做什麼?

首先,自身一樣,我們可以先簡單理解,是 Vnode 的兩個屬性 tag 和 key 一樣

那麼,我們是不知道其子節點是否一樣的,所以肯定需要比較子節點

所以,patchVnode 其中的一個作用,就是比較子節點

3 舊節點 和 新節點 自身不一樣

當兩個節點不一樣的時候,不難理解,直接創建新節點,刪除舊節點


patchVnode

在上一個函數 createPatchFunction 中,有出現一個函數 patchVnode

我們思考了這個函數的其中的一個作用是 比較兩個Vnode 的子節點

是不是我們想的呢,可以先來過一下源碼

function patchVnode(oldVnode, vnode) { 

    if (oldVnode === vnode) return

    var elm = vnode.elm = oldVnode.elm;    

    var oldCh = oldVnode.children;    

    var ch = vnode.children;   

    // 更新children

    if (!vnode.text) {   

        // 存在 oldCh 和 ch 時

        if (oldCh && ch) {            

            if (oldCh !== ch) 

                updateChildren(elm, oldCh, ch);

        }    

        // 存在 newCh 時,oldCh 只能是不存在,如果存在,就跳到上面的條件了

        else if (ch) {   

            if (oldVnode.text) elm.textContent = '';      

            for (var i = 0; i <= ch.length - 1; ++i) {

                createElm(
                  ch[i],elm, null
                );
            }

        } 

        else if (oldCh) {     

            for (var i = 0; i<= oldCh.length - 1; ++i) {
            
                oldCh[i].parentNode.removeChild(el);
            }

        } 

        else if (oldVnode.text) {
            elm.textContent = '';
        }
    } 

    else if (oldVnode.text !== vnode.text) {
        elm.textContent = vnode.text;
    }
}

我們現在就來分析這個函數

沒錯,正如我們所想,這個函數的確會去比較處理子節點

總的來說,這個函數的作用是

1、Vnode 是文本節點,則更新文本(文本節點不存在子節點)

2、Vnode 有子節點,則處理比較更新子節點

更進一步的總結就是,這個函數主要做了兩種判斷的處理

1、Vnode 是否是文本節點

2、Vnode 是否有子節點

下面我們來看看這些步驟的詳細分析

1 Vnode是文本節點

當 VNode 存在 text 這個屬性的時候,就證明了 Vnode 是文本節點

我們可以先來看看 文本類型的 Vnode 是什麼樣子

公衆號

所以當 Vnode 是文本節點的時候,需要做的就是,更新文本

同樣有兩種處理

1、當 新Vnode.text 存在,而且和 舊 VNode.text 不一樣時

直接更新這個 DOM 的 文本內容

elm.textContent = vnode.text;

注:textContent 是 真實DOM 的一個屬性, 保存的是 dom 的文本,所以直接更新這個屬性

2、新Vnode 的 text 爲空,直接把 文本DOM 賦值給空

elm.textContent = '';

2 Vnode存在子節點

當 Vnode 存在子節點的時候,因爲不知道 新舊節點的子節點是否一樣,所以需要比較,才能完成更新

這裏有三種處理

1、新舊節點 都有子節點,而且不一樣

2、只有新節點

3、只有舊節點

後面兩個節點,相信大家都能想通,但是我們還是說一下

1 只有新節點

只有新節點,不存在舊節點,那麼沒得比較了,所有節點都是全新的

所以直接全部新建就好了,新建是指創建出所有新DOM,並且添加進父節點的

2 只有舊節點

只有舊節點而沒有新節點,說明更新後的頁面,舊節點全部都不見了

那麼要做的,就是把所有的舊節點刪除

也就是直接把DOM 刪除

3 新舊節點 都有子節點,而且不一樣

咦惹,又出現了一個新函數,那就是 updateChildren

預告一下,這個函數非常的重要,是 Diff 的核心模塊,蘊含着 Diff 的思想

可能會有點繞,但是不用怕,相信在我的探索之下,可以稍微明白些

同樣的,我們先來思考下 updateChildren 的作用

記得條件,當新節點 和 舊節點 都存在,要怎麼去比較才能知道有什麼不一樣呢?

哦沒錯,使用遍歷,新子節點和舊子節點一個個比較

如果一樣,就不更新,如果不一樣,就更新

下面就來驗證下我們的想法,來探索一下 updateChildren 的源碼


updateChildren

這個函數非常的長,但是其實不難,就是分了幾種處理流程而已,但是一開始看可能有點懵

或者可以先跳過源碼,看下分析,或者便看分析邊看源碼

function updateChildren(parentElm, oldCh, newCh) {

    var oldStartIdx = 0;    

    var oldEndIdx = oldCh.length - 1;    

    var oldStartVnode = oldCh[0];    

    var oldEndVnode = oldCh[oldEndIdx];    

    var newStartIdx = 0;    

    var newEndIdx = newCh.length - 1;    

    var newStartVnode = newCh[0];    

    var newEndVnode = newCh[newEndIdx];    

    var oldKeyToIdx, idxInOld, vnodeToMove, refElm;



    // 不斷地更新 OldIndex 和 OldVnode ,newIndex 和 newVnode

    while (

        oldStartIdx <= oldEndIdx && 

        newStartIdx <= newEndIdx

    ) {        

        if (!oldStartVnode) {

            oldStartVnode = oldCh[++oldStartIdx];
        }     

        else if (!oldEndVnode) {

            oldEndVnode = oldCh[--oldEndIdx];
        }   

        //  舊頭 和新頭 比較
        else if (sameVnode(oldStartVnode, newStartVnode)) {

            patchVnode(oldStartVnode, newStartVnode);

            oldStartVnode = oldCh[++oldStartIdx];
            newStartVnode = newCh[++newStartIdx];
        }    

        //  舊尾 和新尾 比較

        else if (sameVnode(oldEndVnode, newEndVnode)) {

            patchVnode(oldEndVnode, newEndVnode);

            oldEndVnode = oldCh[--oldEndIdx];
            newEndVnode = newCh[--newEndIdx];
        }                
               

        // 舊頭 和 新尾 比較

        else if (sameVnode(oldStartVnode, newEndVnode)) {

            patchVnode(oldStartVnode, newEndVnode);            

            // oldStartVnode 放到 oldEndVnode 後面,還要找到 oldEndValue 後面的節點

            parentElm.insertBefore(

                oldStartVnode.elm, 

                oldEndVnode.elm.nextSibling

            );
            
            oldStartVnode = oldCh[++oldStartIdx];
            newEndVnode = newCh[--newEndIdx];
        }   

        //  舊尾 和新頭 比較

        else if (sameVnode(oldEndVnode, newStartVnode)) {

            patchVnode(oldEndVnode, newStartVnode);            


            // oldEndVnode 放到 oldStartVnode 前面

            parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);

            oldEndVnode = oldCh[--oldEndIdx];
            newStartVnode = newCh[++newStartIdx];
        }        


        // 單個新子節點 在 舊子節點數組中 查找位置

        else {    

            // oldKeyToIdx 是一個 把 Vnode 的 key 和 index 轉換的 map

            if (!oldKeyToIdx) {
                oldKeyToIdx = createKeyToOldIdx(

                    oldCh, oldStartIdx, oldEndIdx

                );

            }     

            // 使用 newStartVnode 去 OldMap 中尋找 相同節點,默認key存在

            idxInOld = oldKeyToIdx[newStartVnode.key]        

            //  新孩子中,存在一個新節點,老節點中沒有,需要新建 

            if (!idxInOld) {  

                //  把  newStartVnode 插入 oldStartVnode 的前面

                createElm(

                    newStartVnode, 

                    parentElm, 

                    oldStartVnode.elm

                );

            }            

            else {                

                //  找到 oldCh 中 和 newStartVnode 一樣的節點

                vnodeToMove = oldCh[idxInOld];     
                if (sameVnode(vnodeToMove, newStartVnode)) {

                    patchVnode(vnodeToMove, newStartVnode);  
                
                    // 刪除這個 index

                    oldCh[idxInOld] = undefined;                    
                    // 把 vnodeToMove 移動到  oldStartVnode 前面

                    parentElm.insertBefore(

                        vnodeToMove.elm, 

                        oldStartVnode.elm

                    );

                }                

                // 只能創建一個新節點插入到 parentElm 的子節點中

                else {                    

                    // same key but different element. treat as new element

                    createElm(

                        newStartVnode, 

                        parentElm, 

                        oldStartVnode.elm

                    );

                }
            }            

            // 這個新子節點更新完畢,更新 newStartIdx,開始比較下一個

            newStartVnode = newCh[++newStartIdx];
        }
    }    



    // 處理剩下的節點

    if (oldStartIdx > oldEndIdx) {  

        var newEnd = newCh[newEndIdx + 1]

        refElm = newEnd ? newEnd.elm :null;        
        for (; newStartIdx <= newEndIdx; ++newStartIdx) {

            createElm(
               newCh[newStartIdx], parentElm, refElm
            );
        }
    }    

    // 說明新節點比對完了,老節點可能還有,需要刪除剩餘的老節點

    else if (newStartIdx > newEndIdx) {       

        for (; oldStartIdx<=oldEndIdx; ++oldStartIdx) {

            oldCh[oldStartIdx].parentNode.removeChild(el);
        }
    }
}

首先要明確這個函數處理的是什麼

處理的是 新子節點 和 舊子節點,循環遍歷逐個比較

如何 循環遍歷?

1、使用 while

2、新舊節點數組都配置首尾兩個索引

新節點的兩個索引:newStartIdx , newEndIdx

舊節點的兩個索引:oldStartIdx,oldEndIdx

以兩邊向中間包圍的形式 來進行遍歷

頭部的子節點比較完畢,startIdx 就加1

尾部的子節點比較完畢,endIdex 就減1

只要其中一個數組遍歷完(startIdx<endIdx),則結束遍歷

公衆號

源碼處理的流程分爲兩個

1、比較新舊子節點

2、比較完畢,處理剩下的節點

我們來逐個說明這兩個流程

1 比較新舊子節點

注:這裏有兩個數組,一個是 新子Vnode數組,一箇舊子Vnode數組

在比較過程中,不會對兩個數組進行改變(比如不會插入,不會刪除其子項)

而所有比較過程中都是直接 插入刪除 真實頁面DOM

我們明確一點,比較的目的是什麼?

找到 新舊子節點中 的 相同的子節點,儘量以 移動 替代 新建 去更新DOM

只有在實在不同的情況下,纔會新建

比較更新計劃步驟

首先考慮,不移動DOM

其次考慮,移動DOM

最後考慮,新建 / 刪除 DOM

能不移動,儘量不移動。不行就移動,實在不行就新建

下面開始說源碼中的比較邏輯

五種比較邏輯如下

1、舊頭 == 新頭

2、舊尾 == 新尾

3、舊頭 == 新尾

4、舊尾 == 新頭

5、單個查找

來分析下這五種比較邏輯

1 舊頭 == 新頭

sameVnode(oldStartVnode, newStartVnode)

當兩個新舊的兩個頭一樣的時候,並不用做什麼處理

符合我們的步驟第一條,不移動DOM完成更新

但是看到一句,patchVnode

就是爲了繼續處理這兩個相同節點的子節點,或者更新文本

因爲我們不考慮多層DOM 結構,所以 新舊兩個頭一樣的話,這裏就算結束了

可以直接進行下一輪循環

newStartIdx ++ , oldStartIdx ++

公衆號

2 舊尾 == 新尾

sameVnode(oldEndVnode, newEndVnode)

和 頭頭 相同的處理是一樣的

尾尾相同,直接跳入下個循環

newEndIdx ++ , oldEndIdx ++

公衆號

3 舊頭 == 新尾

sameVnode(oldStartVnode, newEndVnode)

這步不符合 不移動DOM,所以只能 移動DOM 了

怎麼移動?

源碼是這樣的

parentElm.insertBefore(
    oldStartVnode.elm, 
    oldEndVnode.elm.nextSibling
);

以 新子節點的位置 來移動的,舊頭 在新子節點的 末尾

所以把 oldStartVnode 的 dom 放到 oldEndVnode 的後面

但是因爲沒有把dom 放到誰後面的方法,所以只能使用 insertBefore

即放在 oldEndVnode 後一個節點的前面

圖示是這樣的

公衆號

然後更新兩個索引

oldStartIdx++,newEndIdx--

4 舊尾 == 新頭

sameVnode(oldEndVnode, newStartVnode)

同樣不符合 不移動DOM,也只能 移動DOM 了

怎麼移動?
parentElm.insertBefore(
    oldEndVnode.elm, 
    oldStartVnode.elm
);

把 oldEndVnode DOM 直接放到 當前 oldStartVnode.elm 的前面

圖示是這樣的

公衆號

然後更新兩個索引

oldEndIdx--,newStartIdx++

5 單個遍歷查找

當前面四種比較邏輯都不行的時候,這是最後一種處理方法

拿 新子節點的子項,直接去 舊子節點數組中遍歷,找一樣的節點出來

流程大概是

1、生成舊子節點數組以 vnode.key 爲key 的 map 表

2、拿到新子節點數組中 一個子項,判斷它的key是否在上面的map 中

3、不存在,則新建DOM

4、存在,繼續判斷是否 sameVnode

下面就詳細說一下

1 生成map 表

這個map 表的作用,就主要是判斷存在什麼舊子節點

比如你的舊子節點數組是

[{    
    tag:"div",  key:1
},{  

    tag:"strong", key:2
},{  

    tag:"span",  key:4
}]

經過 createKeyToOldIdx 生成一個 map 表 oldKeyToIdx

{ vnodeKey: 數組Index }

屬性名是 vnode.key,屬性值是 該 vnode 在children 的位置

是這樣(具體源碼看上篇文章 Diff - 源碼版 之 相關輔助函數)

oldKeyToIdx = {
    1:0,
    2:1,
    4:2
}
2 判斷 新子節點是否存在舊子節點數組中

拿到新子節點中的 子項Vnode,然後拿到它的 key

去匹配map 表,判斷是否有相同節點

oldKeyToIdx[newStartVnode.key]
3 不存在舊子節點數組中

直接創建DOM,並插入oldStartVnode 前面

createElm(newStartVnode, parentElm, oldStartVnode.elm);

公衆號

4 存在舊子節點數組中

找到這個舊子節點,然後判斷和新子節點是否 sameVnode

如果相同,直接移動到 oldStartVnode 前面

如果不同,直接創建插入 oldStartVnode 前面

我們上面說了比較子節點的處理的流程分爲兩個

1、比較新舊子節點

2、比較完畢,處理剩下的節點

比較新舊子節點上面已經說完了,下面就到了另一個流程,比較剩餘的節點,詳情看下面

處理可能剩下的節點

在updateChildren 中,比較完新舊兩個數組之後,可能某個數組會剩下部分節點沒有被處理過,所以這裏需要統一處理

1 新子節點遍歷完了
newStartIdx > newEndIdx

新子節點遍歷完畢,舊子節點可能還有剩

所以我們要對可能剩下的舊節點進行 批量刪除!

就是遍歷剩下的節點,逐個刪除DOM

for (; oldStartIdx <= oldEndIdx; ++oldStartIdx) {
    oldCh[oldStartIdx]

    .parentNode

    .removeChild(el);
}

公衆號

2舊子節點遍歷完了
oldStartIdx > oldEndIdx

舊子節點遍歷完畢,新子節點可能有剩

所以要對剩餘的新子節點處理

很明顯,剩餘的新子節點不存在 舊子節點中,所以全部新建

for (; newStartIdx <= newEndIdx; ++newStartIdx) {
   createElm(
      newCh[newStartIdx], 

      parentElm, 

      refElm

   );
}

但是新建有一個問題,就是插在哪裏?

所以其中的 refElm 就成了疑點,看下源碼

var newEnd = newCh[newEndIdx + 1]

refElm = newEnd ? newEnd.elm :null;

refElm 獲取的是 newEndIdx 後一位的節點

當前沒有處理的節點是 newEndIdx

也就是說 newEndIdx+1 的節點如果存在的話,肯定被處理過了

如果 newEndIdx 沒有移動過,一直是最後一位,那麼就不存在 newCh[newEndIdx + 1]

那麼 refElm 就是空,那麼剩餘的新節點 就全部添加進 父節點孩子的末尾,相當於

for (; newStartIdx <= newEndIdx; ++newStartIdx) {     
    parentElm.appendChild(

        newCh[newStartIdx]

    );

}

如果 newEndIdx 移動過,那麼就逐個添加在 refElm 的前面,相當於

for (; newStartIdx <= newEndIdx; ++newStartIdx) {
    parentElm.insertBefore(

        newCh[newStartIdx] ,

        refElm 

    );

}

如圖

公衆號


思考爲什麼這麼比較

我們已經講完了所有 Diff 的內容,大家也應該能領悟到 Diff 的思想

但是我強迫自己去思考一個問題,就是

爲什麼會這樣去比較?

以下純屬個人意淫想法,沒有權威認證,僅供參考

我們所有的比較,都是爲了找到 新子節點 和 舊子節點 一樣的子節點

而且我們的比較處理的宗旨是

1、能不移動,儘量不移動

2、沒得辦法,只好移動

3、實在不行,新建或刪除

首先,一開始比較,肯定是按照我們的第一宗旨 不移動 ,找到可以不移動的節點

而 頭頭,尾尾比較 符合我們的第一宗旨,所以出現在最開始,嗯,這個可以想通

然後就到我們的第二宗旨 移動,按照 updateChildren 的做法有

舊頭新尾比較,舊尾新頭比較,單個查找比較

我開始疑惑了,咦?頭尾比較爲了移動我知道,但是爲什麼要出現這種比較?

明明我可以用 單個查找 的方式,完成所有的移動操作啊?

我思考了很久,頭和尾的關係,覺得可能是爲了避免極端情況的消耗??

怎麼說?

比如當我們去掉頭尾比較,全部使用單個查找的方式

如果出現頭 和 尾 節點一樣的時候,一個節點需要遍歷 從頭找到尾 才能找到相同節點

這樣實在是太消耗了,所以這裏加入了 頭尾比較 就是爲了排除 極端情況造成的消耗操作

當然,這只是我個人的想法,僅供參考,雖然這麼說,我也的確做了個例子測試

子節點中加入了出現兩個頭尾比較情況的子項 b div

oldCh = ['header','span','div','b']
newCh = ['sub','b','div','strong']

使用 Vue 去更新,比較更新速度,然後更新十次,計算平均值

1、全用 單個查找,用時 0.91ms

2、加入頭尾比較,用時 0.853ms

的確是快一些喔


走流程

我相信經過這麼長的一篇文章,大家的腦海中還沒有把所有的知識點集合起來,可能對整個流程還有點模糊

沒事,我們現在就來舉一個例子,一步步走流程,完成更新

以下的節點,綠色表示未處理,灰色表示已經處理,淡綠色表示正在處理,紅色表示新插入,如下

公衆號

現在Vue 需要更新,存在下面兩組新舊子節點,需要進行比較,來判斷需要更新哪些節點

公衆號

1頭頭比較,節點一樣,不需移動,只用更新索引

公衆號

更新索引,newStartIdx++ , oldStartIdx++

開始下輪處理

一系列判斷之後,【舊頭 2】 和 【 新尾 2】相同,直接移動到 oldEndVnode 後面

公衆號

更新索引,newEndIdx-- ,oldStartIdx ++

開始下輪處理

3一系列判斷之後,【舊頭 2】 和 【 新尾 2】相同,直接移動到 oldStartVnode 前面

公衆號

更新索引,oldEndIdx-- ,newStartIdx++

開始下輪比較

4只剩一個節點,走到最後一個判斷,單個查找

找不到一樣的,直接創建插入到 oldStartVnode 前面

公衆號

更新索引,newStartIdx++

此時 newStartIdx> newEndIdx ,結束循環

5 批量刪除可能剩下的老節點

此時看 舊 Vnode 數組中, oldStartIdx 和 oldEndIdx 都指向同一個節點,所以只用刪除 oldVnode-4 這個節點

ok,完成所有比較流程

耶,Diff 內容講完了,謝謝大家的觀看

公衆號


最後

鑑於本人能力有限,難免會有疏漏錯誤的地方,請大家多多包涵,如果有任何描述不當的地方,歡迎後臺聯繫本人,有重謝

公衆號

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章