Vue2.0的Diff算法

Vue2.0加入了Virtual Dom,Vue的Diff位于patch.js文件中,該算法來源于snabbdom,復(fù)雜度為O(n)

React的 Diff其實(shí)和Vue的 Diff 大同小異,只比較同層級節(jié)點(diǎn),不會跨層級比較,目的就是最小變動

Diff 的過程就是調(diào)用 patch 函數(shù),就像打補(bǔ)丁一樣修改真實(shí)DOM。

patch函數(shù)

// 只保留核心部分
function patch(oldVnode, vnode) {
    if (sameVnode(oldVnode, vnode)) {
        patchVnode(oldVnode, vnode)
    } else {
        const oEl = oldVnode.el
        let parentEle = api.parentNode(oEl)
        createEle(vnode)
        if (parentEle !== null) {
            api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl))
            api.removeChild(parentEle, oldVnode.el)
            oldVnode = null
        }
    }
    return vnode
}

(oldVnode, vnode) 也就是新舊兩個虛擬DOM節(jié)點(diǎn),一個完整的 vnode 都有什么屬性:

// body下的 <div id="v" class="classA"><div> 對應(yīng)的 oldVnode 就是
{
  el:  div  // 對真實(shí)節(jié)點(diǎn)的引用,document.querySelector('#id.classA')
  tagName: 'DIV',   // 節(jié)點(diǎn)的標(biāo)簽
  sel: 'div#v.classA'  // 節(jié)點(diǎn)的選擇器
  data: null,   // 一個存儲節(jié)點(diǎn)屬性的對象,對應(yīng)節(jié)點(diǎn)的 el[prop] 屬性,如onclick, style
  children: [],  // 存儲子節(jié)點(diǎn)的數(shù)組,每個子節(jié)點(diǎn)也是 vnode 結(jié)構(gòu)
  text: null,    // 如果是文本節(jié)點(diǎn),對應(yīng)文本節(jié)點(diǎn)的textContent,否則為null
}

需要注意的是,屬性 el 引用的是此 Virtual DOM對應(yīng)的真實(shí)DOM,patch() 的參數(shù) vnode 中的 el 最初為 null,因?yàn)?patch() 之前它還沒有對應(yīng)的真實(shí)DOM

  1. patch(oldVnode, vnode) 的第一部分:
    if (sameVnode(oldVnode, vnode)) {
        patchVnode(oldVnode, vnode)
    }

sameVnode() 的作用是看這兩個節(jié)點(diǎn)是否值得比較,代碼很簡單:

    function sameVnode(oldVnode, vnode){
        // 只保留核心部分
        return vnode.key === oldVnode.key && vnode.sel === oldVnode.sel
    }

兩個 vnodekeysel 相同才會去比較它們,比如 pspan、div.classAdiv.classB 都被認(rèn)為是不同結(jié)構(gòu)而不去比較它們。
不值得比較的節(jié)點(diǎn)會進(jìn)入 else 中,過程如下:

else {
    // 1. 獲取 oldVnode.el 的父節(jié)點(diǎn),parentEle 是真實(shí)DOM
    const oEl = oldVnode.el
    let parentEle = api.parentNode(oEl)
    // 2. 為 vnode 創(chuàng)建它的真實(shí)DOM,令 vnode.el = 真實(shí)DOM
    createEle(vnode)
    if (parentEle !== null) {
        // 3. parentEle 將新的DOM插入,移除舊的DOM
        api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl))
        api.removeChild(parentEle, oldVnode.el)
        oldVnode = null
    }
}

當(dāng)不值得比較時,新節(jié)點(diǎn)直接把老節(jié)點(diǎn)整個替換掉了
最后執(zhí)行:return vnode,返回的 vnode 與傳入時的 vnode 的區(qū)別就在于:vnode.el,之前 vnode.el=null,而現(xiàn)在 vnode.el=對應(yīng)的真實(shí)DOM

    var oldVnode = patch (oldVnode, vnode)

至此完成一個patch過程。

  1. patchVnode(oldVnode, vnode)
    兩個節(jié)點(diǎn)值得比較時,會調(diào)用 patchVnode() 函數(shù)
patchVnode (oldVnode, vnode) {
    // 只保留核心部分
    const el = vnode.el = oldVnode.el    // 引用真實(shí)DOM
    let i, oldCh = oldVnode.children, ch = vnode.children
    if (oldVnode === vnode) return
    if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) {
        api.setTextContent(el, vnode.text)
    }else {
        updateEle(el, vnode, oldVnode)
        if (oldCh && ch && oldCh !== ch) {
            updateChildren(el, oldCh, ch)
        }else if (ch){
            createEle(vnode) //create el's children dom
        }else if (oldCh){
            api.removeChildren(el)
        }
    }
}

const el = vnode.el = oldVnode.el 這是很重要的一步,讓 vnode.el 引用到當(dāng)前的真實(shí)DOM,當(dāng) el 變量修改時,vnode.el 會同步變化。
節(jié)點(diǎn)比較的5種情況

  • if (oldVnode === vnode) 它們的引用一致,可以認(rèn)為沒有變化,直接return
  • if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) 文本節(jié)點(diǎn)的比較,需要修改時,則會調(diào)用 oldVnode.textContent = vnode.text
  • if (oldCh && ch && oldCh !== ch) 兩個節(jié)點(diǎn)都有子節(jié)點(diǎn),且它們不一樣,則調(diào)用 updateChildren() 函數(shù)去比較子節(jié)點(diǎn),這是Diff的核心
  • else if (ch) 只有新的節(jié)點(diǎn)有子節(jié)點(diǎn),調(diào)用 createEle(vnode)vnode.el 已經(jīng)引用了老的DOM幾點(diǎn),createEle函數(shù)會在老的DOM節(jié)點(diǎn)上添加子節(jié)點(diǎn)。
  • else if (oldCh) 新節(jié)點(diǎn)沒有子節(jié)點(diǎn),老節(jié)點(diǎn)有子節(jié)點(diǎn),則直接刪除老節(jié)點(diǎn)。

updateChildren

function updateChildren(parentElm, oldCh, newCh) {
    let oldStartIdx = 0, newStartIdx = 0
    let oldEndIdx = oldCh.length - 1
    let oldStartVnode = oldCh[0]
    let oldEndVnode = oldCh[oldEndIdx]
    let newEndIdx = newCh.length - 1
    let newStartVnode = newCh[0]
    let newEndVnode = newCh[newEndIdx]
    let oldKeyToIdx
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (oldStartVnode == null) {   //對于vnode.key的比較,會把oldVnode = null
            oldStartVnode = oldCh[++oldStartIdx] 
        } else if (oldEndVnode == null) {
            oldEndVnode = oldCh[--oldEndIdx]
        } else if (newStartVnode == null) {
            newStartVnode = newCh[++newStartIdx]
        } else if (newEndVnode == null) {
            newEndVnode = newCh[--newEndIdx]
        } else if (sameVnode(oldStartVnode, newStartVnode)) {  // 比較方式一
            // 頭頭比較通過,patch不同之處,如class、style
            patchVnode(oldStartVnode, newStartVnode)
            // 向后移動指針
            oldStartVnode = oldCh[++oldStartIdx]
            newStartVnode = newCh[++newStartIdx]
        } else if (sameVnode(oldEndVnode, newEndVnode)) {  // 比較方式二
            // 尾尾比較通過,patch不同之處
            patchVnode(oldEndVnode, newEndVnode)
            // 向前移動指針
            oldEndVnode = oldCh[--oldEndIdx]
            newEndVnode = newCh[--newEndIdx]
        } else if (sameVnode(oldStartVnode, newEndVnode)) {  // 比較方式三
            // 舊頭與新尾比較通過,patch不同之處
            patchVnode(oldStartVnode, newEndVnode)
            // 移除這個舊節(jié)點(diǎn),再添加到數(shù)組尾部
            api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el))
            oldStartVnode = oldCh[++oldStartIdx]
            newEndVnode = newCh[--newEndIdx]
        } else if (sameVnode(oldEndVnode, newStartVnode)) {  // 比較方式四
            // 新頭與舊尾比較通過,patch不同之處
            patchVnode(oldEndVnode, newStartVnode)
            // 移除這個舊節(jié)點(diǎn),再添加到數(shù)組頭部
            api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el)
            oldEndVnode = oldCh[--oldEndIdx]
            newStartVnode = newCh[++newStartIdx]
        } else {
            // 四種比較方式都不滿足,如果設(shè)置了key,則使用 key 比較
            if (oldKeyToIdx === undefined) {
                // 由key生成index表,由此可見,應(yīng)該給節(jié)點(diǎn)數(shù)組設(shè)置唯一key
                oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
            }
            idxInOld = oldKeyToIdx[newStartVnode.key]
            if (!idxInOld) {
                // 用新節(jié)點(diǎn)的key在 舊節(jié)點(diǎn)數(shù)組中查找,沒有找到這個節(jié)點(diǎn),則插入新節(jié)點(diǎn)
                api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
                newStartVnode = newCh[++newStartIdx]
            } else {
                elmToMove = oldCh[idxInOld]
                if (elmToMove.sel !== newStartVnode.sel) {
                    // key雖相同,但節(jié)點(diǎn)不同,把這個舊節(jié)點(diǎn)替換成新節(jié)點(diǎn)
                    api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
                } else {
                    // 兩個節(jié)點(diǎn)相同,則patch不同之處
                    patchVnode(elmToMove, newStartVnode)
                    oldCh[idxInOld] = null
                    // 移除這個舊節(jié)點(diǎn),重新插入更新后的節(jié)點(diǎn)
                    api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el)
                }
                newStartVnode = newCh[++newStartIdx]
            }
        }
    }
    // 檢查新舊節(jié)點(diǎn)是否還有剩余
    if (oldStartIdx > oldEndIdx) {
        // 舊節(jié)點(diǎn)數(shù)組遍歷完了,則直接插入剩下的新節(jié)點(diǎn)
        before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
        addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
    } else if (newStartIdx > newEndIdx) {
        // 新節(jié)點(diǎn)遍歷完了,那么直接移除剩下的舊節(jié)點(diǎn)
        removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
    }
}

代碼很密集,用圖描述這個過程:

updateChildren.png

只有在 oldChnewCh 都存在的情況下才會執(zhí)行updateChildren(),可知updateChildren()進(jìn)行的是同層級下的Children的更新比較,也就是傳說中的Diff了。

一系列變量

  • 舊節(jié)點(diǎn)數(shù)組oldCh,新節(jié)點(diǎn)數(shù)組newCh
  • oldStartIdx、oldEndIdx 分別指向oldCh頭部和尾部,對應(yīng)元素oldStartVnode、oldEndVnode
  • newStartIdx、newEndIdx 分別指向newCh頭部和尾部,對應(yīng)元素newStartVnode、newEndVnode
  • oldKeyToIdx 是一個Map,其key就是v-bind:key的值,value對應(yīng)的就是當(dāng)前vnode

過程可以概況為:
頭頭比較、尾尾比較,頭尾比較,尾頭比較,如果 4 種比較都不匹配,但設(shè)置了key,則會用key進(jìn)行比較。

具體的Diff分析

Vue2在比較過程中會直接操作DOM,相比之下,React把DiffDOM操作分為兩個階段,DOM操作在提交階段。
Diff的遍歷過程中,只要是對DOM的操作都會調(diào)用 api.insertBefore(),api.insertBefore()只是原生insertBefore()的簡單封裝。

官方文檔:parentElement.insertBefore(newElement, referenceElement),如果referenceElementnull,則newElement將被插入到子節(jié)點(diǎn)的末尾;如果newElement已經(jīng)存在于DOM樹中,則首先會從DOM樹中移除。

  1. 對于頭頭比較sameVnode(oldStartVnode, newStartVnode)和尾尾比較sameVnode(oldEndVnode, newEndVnode) ,為true時是不需要對移動DOM的,只是更新節(jié)點(diǎn)(patchVnode)。
  2. 設(shè)置 key (v-bind:key="xxx") 與不設(shè)置 key 的區(qū)別:
    • 不設(shè)key(undefined),newCholdCh只會進(jìn)行頭尾兩端的相互比較;而且 sameVnode() 在判斷兩個節(jié)點(diǎn)是否值得比較時,無法比較key是否相同。
      在特定情況下,比如v-for的列表是10個多選框,選中哪一個則刪除哪一個,那么會發(fā)現(xiàn):刪除第一個checkbox,新的第一個checkbox會處于選中狀態(tài)!
    • 設(shè)置key,如果頭尾兩端的比較都不匹配,還會從用 key 生成的Map對象 oldKeyToIdx 中查找匹配的節(jié)點(diǎn),最大化的復(fù)用節(jié)點(diǎn),提高性能。
    • 不建議直接用 index 作為 key,其實(shí)就相當(dāng)于不加key。當(dāng)刪除/插入元素時,因?yàn)楹罄m(xù)元素的key="index"都會發(fā)生變化,導(dǎo)致它們重新渲染,無法復(fù)用,浪費(fèi)性能。
      key="index"只適用于比較穩(wěn)定的狀態(tài)。
  3. 當(dāng)sameVnode(oldStartVnode, newEndVnode) 值得比較時,說明 oldStartVnode.el 跑到 oldEndVnode.el 的后邊了。
假設(shè)startIdx遍歷到1.png
  1. 當(dāng) sameVnode(oldEndVnode, newStartVnode) 值得比較,oldEndVnode.el 跑到了 oldStartVnode.el 的前邊。
    準(zhǔn)確的說應(yīng)該是 oldEndVnode.el 需要移動到 oldStartVnode.el的前邊。
    而實(shí)際操作是,先插入,后移除。
2038.png
  1. 4種頭尾方式比較完后,就會去檢查舊節(jié)點(diǎn)的Map表。根據(jù)新節(jié)點(diǎn)的key,到表中查找是否有這樣一個節(jié)點(diǎn)。
    • 找不到,說明這個新節(jié)點(diǎn)不存在與舊節(jié)點(diǎn)樹中,將新節(jié)點(diǎn)插入到 oldStartVnode.el 的前邊。
createEle.png
  • 找到了,也要去檢查兩個節(jié)點(diǎn)是否還是同一節(jié)點(diǎn)elmToMove.sel !== newStartVnode.sel
    如果是同一節(jié)點(diǎn),則更新節(jié)點(diǎn),移動到新的位置;否則,替換這個舊節(jié)點(diǎn)。
  1. while循環(huán)結(jié)束后(新、舊有一個發(fā)生了交叉),也分為兩種情況:
    • oldStartIdx > oldEndIdx 表示舊節(jié)點(diǎn)先遍歷完。當(dāng)然有可能 newCh 也剛好遍歷完,但都?xì)w為此類。
      那么,直接插入剩下的新節(jié)點(diǎn)即可。
before.png
  • newStartIdx > newEndIdx 表示新節(jié)點(diǎn)遍歷結(jié)束,那么就移除剩下的舊節(jié)點(diǎn)。
remove.png

小結(jié)

  • 盡量不要跨層級的修改DOM
  • 設(shè)置 Key 可以最大化的利用節(jié)點(diǎn)
  • 不要盲目相信 Diff 的效率,在必要時可以手工優(yōu)化
舉個例子.png

原有的 oldCh 的順序是 A 、B、C、D、E、F、G,更新后成 ch 的順序 F、D、A、H、E、C、B、G

初始狀態(tài).png

為了更好理解后續(xù)的 STEP,先看下相關(guān)符合標(biāo)記的說明:

圖解說明.png

STEP-1
對比 A-F --> G-GsameVnode(oldEndVnode, newEndVnode)值得比較,DOM順序不變

  • G 進(jìn)行patchVnode(),更新oldG.el
  • 移動指針:--oldEndIdx,--newEndIdx
step-1.png

STEP-2
對比 A-F --> F-B --> A-B --> F-FsameVnode(oldEndVnode, newStartVnode)值得比較

  • F 進(jìn)行patchVnode(),更新oldF.el
  • 找到 oldStartVnode 在DOM中的位置A,在其前面插入更新后的F
  • 移動指針:--oldEndIdx++newStartIdx
step-2.png

STEP-3
對比 A-D --> E-B --> A-B --> E-D,sameVnode(old, new)都不匹配,則取newStartVnodekey,即D.key, 在 oldKeyToIdx 中查找,并成功找到對應(yīng)的D

  • D 賦值給 elmToMove,圖示中為vnodeToMove
  • D 進(jìn)行patchVnode(),更新oldD.el
  • oldCh 中對應(yīng)Dvnode置空:oldCh[idxInOld] = null
  • 找到 oldStartVnode 在DOM中的位置A,在其前面插入更新后的D
  • 移動指針:++newStartIdx
step-3.png

STEP-4
對比 A-A,sameVnode(oldStartVnode, newStartVnode)匹配成功,進(jìn)行比較,DOM順序不變

  • A 進(jìn)行patchVnode(),更新oldA.el
  • 移動指針:++oldStartIdx++newStartIdx
step-4.png

STEP-5
對比 B-H --> E-B --> B-B,sameVnode(oldStartVnode, newEndVnode)值得比較

  • B 進(jìn)行patchVnode(),更新oldB.el
  • 找到 oldEndVnode - E 的下一個兄弟節(jié)點(diǎn) G,在其前面(G前面、E后面)插入更新后的B
  • 移動指針:++oldStartIdx,--newEndIdx
step-5.png

STEP-6
對比 C-H --> E-C --> C-CsameVnode(oldStartVnode, newEndVnode)值得比較(同STEP-5

  • C 進(jìn)行patchVnode(),更新oldC.el
  • 找到 oldEndVnode - E 的下一個兄弟節(jié)點(diǎn) B,在其前面(B前面、E后面)插入更新后的C
  • 移動指針:++oldStartIdx,--newEndIdx
step-6.png

STEP-7
因?yàn)?STEP-3 中的 oldCh[idxInOld] = null,當(dāng)前獲取的 oldStartVnode = null

  • 移動指針:++oldStartIdx
step-7.png

STEP-8
對比 E-H --> E-E,sameVnode(oldEndVnode, newEndVnode)值得比較,DOM順序不變

  • E 進(jìn)行patchVnode(),更新oldE.el
  • 移動指針:--oldEndIdx--newEndIdx
step-8.png

last
此時的 oldStartIdx > oldEndIdx,有一方發(fā)生了交叉,則退出循環(huán)。

        if (oldStartIdx > oldEndIdx) {
            before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
            addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
        }

舊VirtualDOM 首先發(fā)生交叉,所以把新VirtualDOM中剩余的節(jié)點(diǎn)直接插入到oldCh

  • 找到 newEndIdx + 1 位置E --> before
  • 剩余的待處理部分:newStartIdx -- newEndIdx 之間的 vnode 視為新增的部分 -- [ H ]
  • 插入到 before 位置E 的前面
last.png

注意點(diǎn)

  • Diff過程中,oldChnewCh 的位置并不會發(fā)生變化,真正進(jìn)行操作的是傳入的parentElm -- 父vnode的elm
  • 插入操作使用的 insertBefore(newElement, referenceElement),在指定節(jié)點(diǎn)前插入。
  • patch() 中其實(shí)還有一個數(shù)組insertedVnodeQueue,涉及到組件的patch過程:組件的 $mount 函數(shù)之后之后并不會立即觸發(fā)組件實(shí)例的mounted鉤子,而是把當(dāng)前實(shí)例pushinsertedVnodeQueue中,然后在patch的倒數(shù)第二行執(zhí)行invokeInsertHook -- 觸發(fā)所有組件實(shí)例的insert鉤子,而組件的insert鉤子函數(shù)中才會觸發(fā)組件實(shí)例的mounted鉤子。比如,在patch的過程中,patch了多個組件vnode,它們都進(jìn)行了$mount即生成DOM,但沒有立即觸發(fā)mounted,而是等整個patch完成,再逐一觸發(fā)。
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 轉(zhuǎn)載請注明出處本文轉(zhuǎn)載至我的blog 目錄 前言 virtual dom 分析diff 總結(jié) 前言 vue2.0加...
    yangjingzhuo閱讀 9,575評論 2 24
  • 目錄 前言 virtual dom 分析diff 總結(jié) 前言 vue2.0加入了virtual dom,有向rea...
    指尖跳動閱讀 1,655評論 0 3
  • 詳解vue的diff算法 1. 當(dāng)數(shù)據(jù)發(fā)生變化時,vue是怎么更新節(jié)點(diǎn)的? 要知道渲染真實(shí)DOM的開銷是很大的,比...
    Lilio閱讀 336評論 0 1
  • 也看過其他講vue diff過程的文章,但是感覺都只是講了其中的一部分(對比方式),沒有對其中細(xì)節(jié)的部分做詳細(xì)的講...
    小魚兒_61f5閱讀 2,800評論 5 3
  • 工作中遇到了這樣的需求:在excel的A列數(shù)據(jù)中篩選出B列數(shù)據(jù)中沒有的那一部分,A列數(shù)據(jù)10w,B列數(shù)據(jù)18w。我...
    小羊喵喵閱讀 90,876評論 0 1

友情鏈接更多精彩內(nèi)容