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

寫文章不容易,點個讚唄兄弟 <br> <br> 專一 Vue 源碼分享,文章分爲白話版和 源碼版,白話版助於理解工做原理,源碼版助於瞭解內部詳情,讓咱們一塊兒學習吧 研究基於 Vue版本 【2.5.17】node

若是你以爲排版難看,請點擊 下面連接 或者 拉到 下面關注公衆號也能夠吧數組

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

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

Diff 的內容不算多,可是若是要講得很詳細的話,就要說不少了,並且要配不少圖函數

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

因此本篇內容不少,先提個內容概覽測試

一、分析 Diff 源碼比較步驟

二、我的思考爲何如此比較

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

文章很長,也很是詳細,若是你對這內容有興趣的話,也推薦邊閱讀源碼邊看,若是你對本內容暫時沒有了解,能夠先看不涉及源碼的白話版 Diff - 白話版 spa

下面開始咱們的正文prototype

公衆號

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

你應該還有印象,其中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 沒有舊節點

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

直接所有都是新建,因此只調用 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;
    }
}

咱們如今就來分析這個函數

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

總的來講,這個函數的做用是

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

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

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

一、Vnode 是不是文本節點

二、Vnode 是否有子節點

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

1 Vnode是文本節點

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

咱們能夠先來看看 文本類型的 Vnode 是什麼樣子

公衆號

因此當 Vnode 是文本節點的時候,須要作的就是,更新文本

一樣有兩種處理

一、當 新Vnode.text 存在,並且和 舊 VNode.text 不同時

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

elm.textContent = vnode.text;

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

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

elm.textContent = '';

2 Vnode存在子節點

當 Vnode 存在子節點的時候,由於不知道 新舊節點的子節點是否同樣,因此須要比較,才能完成更新

這裏有三種處理

一、新舊節點 都有子節點,並且不同

二、只有新節點

三、只有舊節點

後面兩個節點,相信你們都能想通,可是咱們仍是說一下

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);
        }
    }
}

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

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

如何 循環遍歷?

一、使用 while

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

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

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

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

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

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

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

公衆號

源碼處理的流程分爲兩個

一、比較新舊子節點

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

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

1 比較新舊子節點

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

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

而全部比較過程當中都是直接 插入刪除 真實頁面DOM

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

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

只有在實在不一樣的狀況下,纔會新建

比較更新計劃步驟

首先考慮,不移動DOM

其次考慮,移動DOM

最後考慮,新建 / 刪除 DOM

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

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

五種比較邏輯以下

一、舊頭 == 新頭

二、舊尾 == 新尾

三、舊頭 == 新尾

四、舊尾 == 新頭

五、單個查找

來分析下這五種比較邏輯

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 單個遍歷查找

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

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

流程大概是

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

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

三、不存在,則新建DOM

四、存在,繼續判斷是否 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 前面

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

一、比較新舊子節點

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

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

處理可能剩下的節點

在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 的思想

可是我強迫本身去思考一個問題,就是

爲何會這樣去比較?

如下純屬我的意淫想法,沒有權威認證,僅供參考

咱們全部的比較,都是爲了找到 新子節點 和 舊子節點 同樣的子節點

並且咱們的比較處理的宗旨是

一、能不移動,儘可能不移動

二、沒得辦法,只好移動

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

首先,一開始比較,確定是按照咱們的第一宗旨 不移動 ,找到能夠不移動的節點

而 頭頭,尾尾比較 符合咱們的第一宗旨,因此出如今最開始,嗯,這個能夠想通

而後就到咱們的第二宗旨 移動,按照 updateChildren 的作法有

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

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

明明我能夠用 單個查找 的方式,完成全部的移動操做啊?

我思考了好久,頭和尾的關係,以爲多是爲了不極端狀況的消耗??

怎麼說?

好比當咱們去掉頭尾比較,所有使用單個查找的方式

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

這樣實在是太消耗了,因此這裏加入了 頭尾比較 就是爲了排除 極端狀況形成的消耗操做

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

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

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

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

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

二、加入頭尾比較,用時 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 內容講完了,謝謝你們的觀看

公衆號


最後

鑑於本人能力有限,不免會有疏漏錯誤的地方,請你們多多包涵,若是有任何描述不當的地方,歡迎去後臺去聯繫本人,有重謝

公衆號

相關文章
相關標籤/搜索