Vue 虛擬dom & diff算法

diff算法

vdom因為是純粹的JS對象,所以操作它會很高效,但是vdom的變更最終會轉換成DOM操作,為了實現高效的DOM操作,一套高效的虛擬DOM diff算法顯得很有必要。

Vuediff算法僅在同級的vnode間做diff,遞歸地進行同級vnodediff,最終實現整個DOM樹的更新。

oldStart+oldEndnewStart+newEnd這樣2對指針,分別對應oldVdomnewVdom的起點和終點。起止點之前的節點是待處理的節點,Vue不斷對vnode進行處理同時移動指針直到其中任意一對起點和終點相遇。處理過的節點Vue會在oldVdomnewVdom中同時將它標記為已處理。

Vue通過diff算法來比較新老vdom的差異,計算出需要操作dom的最少次數,實際中并以下措施來提升diff的性能。

  • 1、優先處理特殊場景:處理頭尾同類型節點。
  • 2、原地復用:盡可能復用DOM,盡可能不發生DOM的移動。Vue在判斷更新前后指針是否指向同一個節點,其實不要求它們真實引用同一個DOM節點,實際上它僅判斷指向的是否是同類節點(比如2個不同的div,在DOM上它們是不一樣的,但是它們屬于同類節點),如果是同類節點,那么Vue會直接復用DOM,這樣的好處是不需要移動DOM
整體視圖
  • 處理頭尾同類型節點,即oldStartnewStart指向同類節點的情況 / oldEndnewEnd指向同類節點的情況,直接移動指針。

  • 處理頭尾、尾頭同類型節點,即oldStartnewEnd,以及oldEndnewStart指向同類節點的情況,互換位置。

  • 處理新增的節點:在oldVdom中找不到節點,說明它是新增的,那么就創建一個新的節點,插入DOM樹,插到什么位置?插到oldStart指向的節點前面,然后將newStart后移1位標記為已處理。oldStart不需要移動,因為oldVdom中沒有這個節點。

  • 處理更新的節點:newStart來到的位置,在oldVdom中能找到它而且不在指針位置(查找oldVdomoldStartoldEnd區間內的節點),說明它的位置移動了,那么需要在DOM樹中移動它,移到哪里?移到oldStart指向的節點前面,與此同時將節點標記為已處理(因為最后oldVdom中此時還是存在這個節點的,之后指針會游到該節點的位置,如果被標記過已經處理了,則是需要出現在新DOM中的節點,不要刪除它,而是之前就以前移動過它了),跟前面幾種情況有點不同,newVdom中該節點在指針下,可以移動newStart進行標記,而在oldVdom中該節點不在指針處,所以采用設置為undefined的方式來標記

  • 處理刪除的節點:如果newStart跨過了newEnd,它們相遇啦!而這個時候,oldStartoldEnd還沒有相遇,說明這2個指針之間的節點是此次更新中被刪掉的節點。而如果有之前被標記過的節點,則不做處理,只刪除此時沒有標記過的節點。

在應用中也可能會遇到oldVdom的起止點相遇了,但是newVdom的起止點沒有相遇的情況,這個時候需要對newVdom中的未處理節點進行處理,這類節點屬于更新中被加入的節點,需要將他們插入到DOM樹中。

第一部分是一個循環,循環內部是一個分支邏輯,每次循環只會進入其中的一個分支,每次循環會處理一個節點,處理之后將節點標記為已處理(oldVdomnewVdom都要進行標記,如果節點只出現在其中某一個vdom中,則另一個vdom中不需要進行標記),標記的方法有2種,當節點正好在vdom的指針處,移動指針將它排除到未處理列表之外即可,否則就要采用其他方法,Vue的做法是將節點設置為undefined

循環結束之后,可能newVdom或者oldVdom中還有未處理的節點,如果是newVdom中有未處理節點,則這些節點是新增節點,做新增處理。如果是oldVdom中有這類節點,則這些是需要刪除的節點,相應在DOM樹中刪除之

整個過程是逐步找到更新前后vdom的差異,然后將差異反應到DOM樹上(也就是patch),特別要提一下Vuepatch是即時的,并不是打包所有修改最后一起操作DOMReact則是將更新放入隊列后集中處理)

虛擬dom

1、dom很慢

當創建一個元素比如div,有以下幾項內容需要實現: HTML elementElementGlobalEventHandler。簡單的說,就是插入一個dom元素的時候,這個元素上本身或者繼承很多屬性如 widthheightoffsetHeightstyletitle,另外還需要注冊這個元素的諸多方法,比如onfucosonclick等等。 這還只是一個元素,如果元素比較多的時候,還涉及到嵌套,那么元素的屬性和方法等等就會很多,效率很低。

尤其是在js操作DOM的過程中,不僅有dom本身的繁重,js的操作也需要浪費時間,我們認為jsDOM之間有一座橋,如果你頻繁的在橋兩邊走動,顯然效率是很低的。

虛擬dom就是解決這個問題的! 雖然它解決不了dom自身的繁重(虛擬dom只實現了真實dom的重要的屬性和事件,但是最終渲染在頁面的真實dom依然是繁重的),但是虛擬dom可以對js操作dom這一部分內容進行優化。

2、設計

隔離dom并不僅僅是因為dom慢,而也是為了把界面和業務完全隔離,操作數據的只關心數據,操作界面的只關心界面。

即我提供一個Component,然后你只管給我數據,界面的事情完全不用你操心,我保證會把界面變成你想要的樣子。所以說著力點就在于View層。只要你給的數據是[1, 2, 3],我保證顯示的是[1, 2, 3]。沒有什么刪除一個Element,添加一個Element這樣的事情。NO。你要我顯示什么就給我一個完整的列表。

3、實現

首先,vdom并沒有完全實現dom,即vdom和真正地dom是不一樣的,vdom最主要的還是保留了Element之間的層次關系和一些基本屬性。因為真實dom實在是太復雜,一個空的Element都復雜得能讓你崩潰,并且幾乎所有內容我根本不關心好嗎。所以vdom里每一個Element實際上只有幾個屬性,即最重要的,最為有用的,并且沒有那么多亂七八糟的引用,比如一些注冊的屬性和函數啊,這些都是默認的,創建vdom進行diff的過程中大家都一致,是不需要進行比對的。所以哪怕是直接把vdom刪了,根據新傳進來的數據重新創建一個新的vdom出來都非常非常非常快。。

所以,引入了vdom之后,你給我一個數據,我根據這個數據生成一個全新的vdom,然后跟我上一次生成的vdom去 diff,得到一個Patch,然后把這個Patch打到瀏覽器的dom上去。完事。并且這里的patch顯然不是完整的vdom,而是新的vdom和上一次的vdom經過diff后的差異化的部分

假設在任意時候有,vdom1 == dom1 (組織結構相同, 顯然vdom和真實dom是不可能完全相等的)。當有新數據來的時候,我生成vdom2,然后去和vdom1diff,得到一個Patch(差異化的結果)。然后將這個Patch去應用到dom1上,得到dom2。如果一切正常,那么有vdom2 == dom2(同樣是結構上的相等)。此時vdom2就會與下一次vdom3進行比較。

1.用JavaScript對象來表示DOM樹的結構; 然后用這個樹構建一個真正的DOM樹,插入到文檔中。
2.當狀態變更的時候,重新構造一個新的對象樹,然后用這個新的樹和舊的樹作對比,記錄兩個樹的差異。
3.把2所記錄的差異應用在步驟一所構建的真正的DOM樹上,視圖就更新了。

差異化實現

差異類型

1.替換原來的節點,如把上面的div換成了section。
2.移動、刪除、新增子節點, 例如上面div的子節點,把p和ul順序互換。
3.修改了節點的屬性。
4.對于文本節點,文本內容可能會改變。

所以,我們可以定義下面的幾種類型:

var REPLACE = 0;
var REORDER = 1;
var PROPS = 2;
var TEXT = 3;

對于節點替換,很簡單,判斷新舊節點的tagName是不是一樣的,如果不一樣的說明需要替換掉。 如div換成了section,就記錄下:

patches[0] = [{
  type: REPALCE,
  node: newNode // el('section', props, children)
}]

除此之外,還新增了屬性id為container,就記錄下:

pathches[0] = [
    {
        type: REPLACE,
        node: newNode 
    }, 
    { 
        type: PROPS,
        props: {
            id: 'container'
        }
    }
]

如果是文本節點發生了變化,那么就記錄下:

pathches[2] = [
    {
    type:  TEXT,
    content: 'virtual DOM2'
    }
]

列表對比算法

a b c d e f g h i => a b c h d f g i j

現在對節點進行了刪除、插入、移動的操作。新增j節點,刪除e節點,移動h節點,現在知道了新舊的順序,求最小的插入、刪除操作(移動可以看成是刪除和插入操作的結合),優化操作次數,我們能夠獲取到某個父節點的子節點的操作,就可以記錄下來:

patches[0] = [{
  type: REORDER,
  moves: [{remove or insert}, {remove or insert}, ...]
}]

把差異引用到真正的DOM樹上

因為步驟一所構建的 JavaScript 對象樹(Vdom)和render出來真正的DOM樹的信息、結構是一樣的。所以我們可以對那棵DOM樹也進行深度優先的遍歷,遍歷的時候從步驟二生成的patches對象中找出當前遍歷的節點差異,然后進行 DOM 操作。

function patch (node, patches) {
  var walker = {index: 0}
  dfsWalk(node, walker, patches)
}

function dfsWalk (node, walker, patches) {
    var currentPatches = patches[walker.index] // 從patches拿出當前節點的差異

    var len = node.childNodes ? node.childNodes.length: 0
    for (var i = 0; i < len; i++) { // 深度遍歷子節點
        var child = node.childNodes[i]
        walker.index++
        dfsWalk(child, walker, patches)
    }

    if (currentPatches) {
        applyPatches(node, currentPatches) // 對當前節點進行DOM操作
    }
}

function applyPatches (node, currentPatches) {
    currentPatches.forEach(function (currentPatch) {
        switch (currentPatch.type) {
            case REPLACE:
                node.parentNode.replaceChild(currentPatch.node.render(), node)
                break
            case REORDER:
                reorderChildren(node, currentPatch.moves)
                break
            case PROPS:
                setProps(node, currentPatch.props)
                break
            case TEXT:
                node.textContent = currentPatch.content
                break
            default:
                throw new Error('Unknown patch type ' + currentPatch.type)
        }
    })
}

參考1 參考2

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容