Vue源碼分析之虛擬DOM詳解

為什麼需要虛擬dom?

虛擬DOM就是為瞭解決瀏覽器性能問題而被設計出來的。例如,若一次操作中有10次更新DOM的動作,虛擬DOM不會立即操作DOM,而是將這10次更新的diff內容保存到本地一個JS對象中,最終將這個JS對象一次性attch到DOM樹上,再進行後續操作,避免大量無謂的計算量。簡單來說,可以把Virtual DOM 理解為一個簡單的JS對象,並且最少包含標簽名( tag)、屬性(attrs)和子元素對象( children)三個屬性。

  • —– 元素節點: 元素節點更貼近於我們通常所看到的真實DOM節點,他有描述節點標簽名詞的tag屬性,描述節點屬性如class,attributes等的data屬性,有描述包含的子節點信息的children屬性等,由於元素節點所包含的情況相對而言比較復雜,源碼中沒有像前三種節點一樣直接寫死。
  • VNode的作用: 用js的計算性能來換取操作真實DOM所消耗的性能,
  • —– VNode在Vue的整個虛擬DOM過程起到瞭什麼作用呢。 其實VNode的作用是相當大的,我們在視圖渲染之前,把寫好的template模板先編譯成VNode並緩存下來,等到數據變化頁面需要重新渲染的時候,我們把數據發生變化後的生成的VNode與前一次緩存下來的VNode進行對比,找出差異。然後有差異的VNode對應的真實的DOM節點就是需要重新渲染的節點,最後根據有差異的創建出來的DOM節點再插入到視圖中,最終完成一次視圖更新。就是再數據變化前後生成真實的DOM對應的虛擬DOM節點

為什麼要有虛擬DOM:

—– 就是以JS的計算性能來換取操作真實DOM所消耗的性能,Vue是通過VNode類來實例化不同類型的虛擬DOM節點,並且學習瞭不同類型節點生成的屬性的不同,所謂不同類型的節點其本質還是一樣的,都屬VNode類的實例,隻是實例化的時候傳入的參數不同罷瞭。
有瞭數據變化前後的VNode,我們才能進行後續的DOM-Diff找出差異,最終做到隻更新有差異的視圖,從而達到盡可能少的操作真實DOM的目的,以節省性能

—– 而找出更新有差異的DOM節點,已達到最少操作真實DOM更新視圖的目的。而對比新舊兩份VNode並找出差異的過程就是所謂的DOM-Diff過程,DOM-Diff算法是整個虛擬DOM的核心所在。

Patch

在Vue中,把DOM-Diff過程就叫做patch過程,patch意思為補丁,一個思想:所謂舊的VNode(odlNode)就是數據變化之前屬於所對應的虛擬DOM節點,而新的NVode是數據變化之後將要渲染的視圖所對應的虛擬DOM節點,所以我們要以生成的新的VNode為基準,對比舊的oldVNode,如果新的VNode上有的節點而舊的oldVNode沒有,那麼就在舊的oldVNode上加上去,如果新的VNode上沒有的節點而舊的oldVNode上有,那麼就在舊的oldVnode上去掉。如果新舊Vnode節點都有,則以新的VNode為準,更新舊的oldVNode,從而讓新舊VNode相同。

整個patch:就是在創建節點:新的VNode有,舊的沒有。就在舊的oldVNode中創建

刪除節點:新的VNode中沒有,而舊的oldVNode有,就從舊的oldVNode中刪除

更新節點:新的舊的都有,就以新的VNode為準,更新舊的oldVNode

更新子節點

/* 
    對比兩個子節點數組肯定是要通過循環,外層循環newChildren,內層循環oldCHildren數組,每循環外層
    newChildren數組裡的每一個子節點,就去內層oldChildren數組裡找看有沒有與之相同的子節點
*/
for (let i = 0; i < newChildred.length; i++) {
    const newChild = newChildren[i]
    for (let j = 0; j < oldChildren.length; j++) {
        const oldChild = oldChildren[i]
        if (newChild === oldChild) {
            // ...
        }
    }
}

那麼以上這個過程將會存在一下四種情況

  1. 創建子節點,如果newChildren裡面的某個子節點在oldChildren裡找不到與之相同的子節點,那麼說明newChildren裡面的這個子節點是之前沒有的,是需要此次新增的節點,那就創建子節點
  2. 刪除子節點,如果把newChildren裡面的每一個子節點都循環完畢後,oldChildren還有未處理的子節點,那就說明未處理的子節點式需要被廢棄的,那就把這些節點刪除
  3. 移動子節點,如果newChildren裡面的某個子節點在oldChildren裡找到瞭與之相同的子節點,但是所處的位置不同,這說明此次變化的需要調整該子節點的位置,那以newChildren裡的子節點1的位置為基準,調整oldChildren裡該節點的位置,使之與在newChildren裡的位置相同
  4. 更新節點:如果newChildren裡面的某個子節點在oldCHildren裡找到瞭與之相同的子節點,並且所處的位置也相同,那麼就更新oldChildren裡該節點,使之與newChildren裡的該節點相同

我們一再強調更新節點要以新Vnode為基準,然後操作舊的oldVnode,使之最後舊的oldVNode與新的VNode相同。

更新的時候分為三個部分:

如果VNode和oldVNode均為靜態節點,

我們說瞭,靜態節點無論數據發生任何變化都與它無關,所以都為靜態節點的話則直接跳過,無需處理

如果VNode是文本節點

如果VNode是我文本節點即表示這個節點內隻包含純文本,那麼隻需要看oldVNode是否也是文本節點,如果是那就比較兩個文本是否不同,如果不用則把oldVNode裡的文本改成跟VNode的文本一樣,如果oldVNode不是文本節點,那麼不論它是什麼,直接調用setTextNode方法把他改成文本節點,並且文本內容跟VNode相同

如果VNode是元素節點,則又細分以下兩種情況

  1. 該節點包含子節點,那麼此時要看舊的節點是否包含子節點,如果舊的節點裡包含瞭子節點,那就需要遞歸對比更新子節點
  2. 如果舊的節點裡不包含子節點,那麼這個舊節點可能是空節點或者文本節點
  3. 如果舊的節點是空節點就把新的節點裡的子節點創建一份然後插入到舊的節點裡面,
  4. 如果舊的節點是文本節點,則把文本清空,然後把新的節點裡的子節點創建一份然後插入到舊的節點裡面
  5. 該節點不包含子節點,如果該節點不包含子節點,同時他又不是文本節點,那就說明該節點是個空節點,那就好辦瞭,不管舊的節點之前裡面有啥,直接清空即可
// 更新節點
function patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly) {
    // vnode 與 oldVnode 是否完全一樣,如果是,退出程序
    if (oldVnode === vnode) {
        return
    }
    const elm = vnode.elm = oldVnode.elm
    // vnode 與 oldVnode是否都是靜態節點,如果是退出程序
    if (isTrue(vnode.isStatic) && isTrue(vnode.isStatic) && vnode.key === oldVnode.key && (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))) {
        return
    }
    const oldCh = oldVnode.children
    const ch = vnode.children
    // vnode 有 text屬性,若沒有
    if (isUndef(vnode.text)) {
        if (isDef(oldCh) && isDef(ch)) {
            // 若都存在,判斷子節點是否相同,不同則更新子節點
            if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
        }
        // 若隻有vnode的子節點的存在
        else if (isDef(ch)) {
            /**
             * 判斷oldVnode是否有文本
             * 若沒有,則把Vnode的子節點添加到真實DOM中
             * 若有,則清空DOM中的文本,再把vnode的子節點添加到真實DOM中
             *  */
            if (isDef(oldVnode.text)) nodeOps.setTextContext(elm, '')
            addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
        }
        // 如果隻有oldnode的子節點存在
        else if (isDef(oldCh)) {
            // 清空DOM中的所有子節點
            removeVnodes(elm, oldCh, 0, oldCh.length - 1)
        }
        // 若vnode和oldnode都沒有子節點,但是oldnode中有文本
        else if (isDef(oldVnode.text)) {
            nodeOps.setTextContext(elm, '')
        }
        // 上面兩個判斷一句話概括就是,如果vnode中既沒有text,也沒有子節點,那麼對應的oldnode中有什麼清空什麼
    } else if (oldVnode.text !== vnode.text) {
        nodeOps.setTextContext(elm, vnode.text)
    }
}

上面的我們瞭解瞭Vue的patch也就是DOM-DIFF算法,並且知道瞭在patch過程之中基本會幹三件事,分別是創建節點,刪除節點和更新節點。 創建節點和刪除節點比較簡單,而更新節點因為要處理各種可能出現的情況邏輯就比較復雜一些。 更新過程中九點Vnode可能都包含子節點,對於子系欸但的對比更新會有額外的一些邏輯,那麼本篇文章就來學習Vue中是如何對比子節點的

更新子節點

當新的Vnode與舊的oldVnode都是元素節點並且都包含子節點的時候,那麼這連個節點VNode實例上的chidlren屬性就是所包含的子節點數組,對比兩個子節點的通過循環,外層循環newChildren數組,內層循環oldChildren數組,每循環外層newChildren數組裡的一個子節點,,就去內層oldChiildren數組裡找看有沒有與之相同的子節點

. 創建子節點

創建子節點的位置應該是在所有未處理節點之前,而並非所有已處理節點之後。 因為如果把子節點插入到已處理後面,如果後續還要插入新節點,那麼新增子節點就亂瞭

. 移動子節點

所有未處理結點之前就是我們要移動的目的的位置

優化更新子節點:

前面我們介紹瞭當新的VNode與舊的oldVNode都是元素節點並且都包含瞭子節點的時候,vue對子節點是先外層循環newChildren數組,再內層循環oldChildren數組,每循環外層newChildren數組裡的一個子節點,就去內層oldChildren數組裡找看有沒有與之相同的子節點,最後根據不同的情況做出不同的操作。這種還存在可優化的地方,比如當包含子節點數量較多的時候,這樣循環算法的時間復雜度就會變得很大,不利於性能提升。

方法:

  1. 先把newChildren數組裡的所有未處理子節點的第一個子節點和oldChildren數組裡所有未處理子節點的第一個子節點做對,如果相同,那就直接進入更新節點的操作;
  2. 如果不同,再把newChildren數組裡所有未處理子節點的最後一個節點和oldChildren數組裡所有未處理子節點的最後一個子節點做比對,如果相同,那就直接進入更新節點的操作;
  3. 如果不同,再把newChildren數組裡所有未處理子節點的最後一個子節點和oldChildren數組裡所有未處理子節點的第一個子節點做比對,如果相同,那就直接進入更新節點的操作,更新完後再將oldChildren數組裡的該節點移動到newChildren數組裡節點相同的位置;如果不同,
  4. 再把newChildren數組裡所有未處理子節點的第一個子節點和oldChildren數組裡所有未處理子節點的最後一個子節點做比對,如果相同,那就直接進入更新節點的操作,更新後再將oldChildren數組裡的該節點移動到與newChildren數組裡節點相同的位置;
  5. 最後四種情況都試完如果還不同,那就按照之前循環的方式來查找節點。
    Vue為瞭避免雙重循環數據量大時間復雜度升高帶來的性能問題,而選擇瞭從子節點數組中的四個特殊位置互相對比,分別是:新前和舊前,新後和舊後,新後和舊前,新前和舊後

在前面幾篇文章中,介紹瞭Vue中的虛擬DOM以及虛擬DOM的patch(DOM-Diff)過程,而虛擬DOM存在的必要條件是的現有VNode,那麼VNode又是從哪裡來的。 把用戶寫的模板進行編譯,就會產生VNode

模板編譯:

什麼是模板編譯:把用戶template標簽裡面的寫的類似於原生HTML的內容進行編譯,把原生HTML的內容找出來,再把非原生的HTML找出來,經過一系列的邏輯處理生成渲染函數,也就是render函數的這一段過程稱之為模板編譯過程。 render函數會將模板內容生成VNode

整體渲染流程,所謂渲染流程,就是把用戶寫的類似於原生HTML的模板經過一系列的過程最終反映到視圖中稱之為整個渲染流程,這個流程在上文中已經說到瞭。

抽象語法樹AST:

  • 用戶在template標簽中寫的模板對Vue來說就是一堆字符串,那麼如何解析這一堆字符串並且從中提取出來元素的標簽,屬性,變量插值 等有效信息呢,這就需要借助一個叫做抽象語法樹的東西。
    抽象語法樹簡稱語法樹,是源代碼語法結構的一種抽象表示,他以樹狀的形式表現編程語言的語法結構,樹上的每個節點都表示源代碼中的一種結構,之所以說語法是抽象的,是因為這裡的語法並不會表示出真實語法中出現的每個析姐,比如,嵌套括號被隱含在樹的結構中,並沒有以節點的i形式呈現,

具體流程:

  • 將一堆字符串模板解析成抽象語法樹AST後,我們就可以對其進行各種操作處理瞭,處理完畢之後的AST來生成render函數,其具體三個流程可以分為以下三個階段

模板解析階段:將一堆模板字符串用正則表達式解析成抽象語法樹AST

優化階段:編譯AST,找出其中的靜態節點,並打上標記

代碼生成階段: 將AST轉換成渲染函數

有瞭模板編譯,才有瞭虛擬DOM,才有瞭後續的視圖更新

總結

到此這篇關於Vue源碼分析之虛擬DOM的文章就介紹到這瞭,更多相關Vue虛擬DOM內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: