深入淺出虛擬 DOM 和 Diff 算法,及 Vue2 與 Vue3 中的區(qū)別

因為 Diff 算法,計算的就是虛擬 DOM 的差異,所以先鋪墊一點點虛擬 DOM,了解一下其結(jié)構(gòu),再來一層層揭開 Diff 算法的面紗,深入淺出,助你徹底弄懂 Diff 算法原理

認識虛擬 DOM

虛擬 DOM 簡單說就是 用JS對象來模擬 DOM 結(jié)構(gòu)

那它是怎么用 JS 對象模擬 DOM 結(jié)構(gòu)的呢?看個例子

<template>
    <div id="app" class="container">
        <h1>沐華</h1>
    </div>
</template>

上面的模板轉(zhuǎn)在虛擬 DOM 就是下面這樣的

{
  'div',
  props:{ id:'app', class:'container' },
  children: [
    { tag: 'h1', children:'沐華' }
  ]
}

這樣的 DOM 結(jié)構(gòu)就稱之為 虛擬 DOM (Virtual Node),簡稱 vnode。

它的表達方式就是把每一個標簽都轉(zhuǎn)為一個對象,這個對象可以有三個屬性:tag、propschildren

  • tag:必選。就是標簽。也可以是組件,或者函數(shù)
  • props:非必選。就是這個標簽上的屬性和方法
  • children:非必選。就是這個標簽的內(nèi)容或者子節(jié)點,如果是文本節(jié)點就是字符串,如果有子節(jié)點就是數(shù)組。換句話說 如果判斷 children 是字符串的話,就表示一定是文本節(jié)點,這個節(jié)點肯定沒有子元素

為什么要使用虛擬 DOM 呢? 看個圖

如圖可以看出原生 DOM 有非常多的屬性和事件,就算是創(chuàng)建一個空div也要付出不小的代價。而使用虛擬 DOM 來提升性能的點在于 DOM 發(fā)生變化的時候,通過 diff 算法和數(shù)據(jù)改變前的 DOM 對比,計算出需要更改的 DOM,然后只對變化的 DOM 進行操作,而不是更新整個視圖

在 Vue 中是怎么把 DOM 轉(zhuǎn)成上面這樣的虛擬 DOM 的呢,有興趣的可以關(guān)注我另一篇文章詳細了解一下 Vue 中的模板編譯過程和原理

在 Vue 里虛擬 DOM 的數(shù)據(jù)更新機制采用的是異步更新隊列,就是把變更后的數(shù)據(jù)變裝入一個數(shù)據(jù)更新的異步隊列,就是 patch,用它來做新老 vnode 對比

認識 Diff 算法

Diff 算法,在 Vue 里面就是叫做 patch ,它的核心就是參考 Snabbdom,通過新舊虛擬 DOM 對比(即 patch 過程),找出最小變化的地方轉(zhuǎn)為進行 DOM 操作

擴展
在 Vue1 里是沒有 patch 的,每個依賴都有單獨的 Watcher 負責更新,當項目規(guī)模變大的時候性能就跟不上了,所以在 Vue2 里為了提升性能,改為每個組件只有一個 Watcher,那我們需要更新的時候,怎么才能精確找到組件里發(fā)生變化的位置呢?所以 patch 它來了

那么它是在什么時候執(zhí)行的呢?

在頁面首次渲染的時候會調(diào)用一次 patch 并創(chuàng)建新的 vnode,不會進行更深層次的比較

然后是在組件中數(shù)據(jù)發(fā)生變化時,會觸發(fā) setter 然后通過 Notify 通知 Watcher,對應(yīng)的 Watcher 會通知更新并執(zhí)行更新函數(shù),它會執(zhí)行 render 函數(shù)獲取新的虛擬 DOM,然后執(zhí)行 patch 對比上次渲染結(jié)果的老的虛擬 DOM,并計算出最小的變化,然后再去根據(jù)這個最小的變化去更新真實的 DOM,也就是視圖

那么它是怎么計算的? 先看個圖

比如有上圖這樣的 DOM 結(jié)構(gòu),是怎么計算出變化?簡單說就是

  • 遍歷老的虛擬 DOM
  • 遍歷新的虛擬 DOM
  • 然后根據(jù)變化,比如上面的改變和新增,再重新排序

可是這樣會有很大問題,假如有1000個節(jié)點,就需要計算 10003 次,也就是10億次,這樣是無法讓人接受的,所以 Vue 或者 React 里使用 Diff 算法的時候都遵循深度優(yōu)先,同層比較的策略做了一些優(yōu)化,來計算出最小變化

Diff 算法的優(yōu)化

1. 只比較同一層級,不跨級比較

如圖,Diff 過程只會把同顏色框起來的同一層級的 DOM 進行比較,這樣來簡化比較次數(shù),這是第一個方面

2. 比較標簽名

如果同一層級的比較標簽名不同,就直接移除老的虛擬 DOM 對應(yīng)的節(jié)點,不繼續(xù)按這個樹狀結(jié)構(gòu)做深度比較,這是簡化比較次數(shù)的第二個方面

3. 比較 key

如果標簽名相同,key 也相同,就會認為是相同節(jié)點,也不繼續(xù)按這個樹狀結(jié)構(gòu)做深度比較,比如我們寫 v-for 的時候會比較 key,不寫 key 就會報錯,這也就是因為 Diff 算法需要比較 key

面試中有一道特別常見的題,就是讓你說一下 key 的作用,實際上考查的就是大家對虛擬 DOM 和 patch 細節(jié)的掌握程度,能夠反應(yīng)出我們面試者的理解層次,所以這里擴展一下 key

key 的作用

比如有一個列表,我們需要在中間插入一個元素,會發(fā)生什么變化呢?先看個圖

如圖的 li1li2 不會重新渲染,這個沒有爭議的。而 li3、li4、li5 都會重新渲染

因為在不使用 key 或者列表的 index 作為 key 的時候,每個元素對應(yīng)的位置關(guān)系都是 index,上圖中的結(jié)果直接導致我們插入的元素到后面的全部元素,對應(yīng)的位置關(guān)系都發(fā)生了變更,所以全部都會執(zhí)行更新操作,這可不是我們想要的,我們希望的是渲染添加的那一個元素,其他四個元素不做任何變更,也就不要重新渲染

而在使用唯一 key 的情況下,每個元素對應(yīng)的位置關(guān)系就是 key,來看一下使用唯一 key 值的情況下

這樣如圖中的 li3li4 就不會重新渲染,因為元素內(nèi)容沒發(fā)生改變,對應(yīng)的位置關(guān)系也沒有發(fā)生改變。

這也是為什么 v-for 必須要寫 key,而且不建議開發(fā)中使用數(shù)組的 index 作為 key 的原因

總結(jié)一下:

  • key 的作用主要是為了更高效的更新虛擬 DOM,因為它可以非常精確的找到相同節(jié)點,因此 patch 過程會非常高效
  • Vue 在 patch 過程中會判斷兩個節(jié)點是不是相同節(jié)點時,key 是一個必要條件。比如渲染列表時,如果不寫 key,Vue 在比較的時候,就可能會導致頻繁更新元素,使整個 patch 過程比較低效,影響性能
  • 應(yīng)該避免使用數(shù)組下標作為 key,因為 key 值不是唯一的話可能會導致上面圖中表示的 bug,使 Vue 無法區(qū)分它他,還有比如在使用相同標簽元素過渡切換的時候,就會導致只替換其內(nèi)部屬性而不會觸發(fā)過渡效果
  • 從源碼里可以知道,Vue 判斷兩個節(jié)點是否相同時主要判斷兩者的元素類型和 key 等,如果不設(shè)置 key,就可能永遠認為這兩個是相同節(jié)點,只能去做更新操作,就造成大量不必要的 DOM 更新操作,明顯是不可取的

有興趣的可以去看一下源碼:src\core\vdom\patch.js -35行 sameVnode(),下面也有詳細介紹

Diff 算法核心原理——源碼

上面說了Diff 算法,在 Vue 里面就是 patch,鋪墊了這么多,下面進入源碼里看一下這個神乎其神的 patch 干了啥?

patch

源碼地址:src/core/vdom/patch.js -700行

其實 patch 就是一個函數(shù),我們先介紹一下源碼里的核心流程,再來看一下 patch 的源碼,源碼里每一行也有注釋

它可以接收四個參數(shù),主要還是前兩個

  • oldVnode:老的虛擬 DOM 節(jié)點
  • vnode:新的虛擬 DOM 節(jié)點
  • hydrating:是不是要和真實 DOM 混合,服務(wù)端渲染的話會用到,這里不過多說明
  • removeOnly:transition-group 會用到,這里不過多說明

主要流程是這樣的:

  • vnode 不存在,oldVnode 存在,就刪掉 oldVnode
  • vnode 存在,oldVnode 不存在,就創(chuàng)建 vnode
  • 兩個都存在的話,通過 sameVnode 函數(shù)(后面有詳解)對比是不是同一節(jié)點
    • 如果是同一節(jié)點的話,通過 patchVnode 進行后續(xù)對比節(jié)點文本變化或子節(jié)點變化
    • 如果不是同一節(jié)點,就把 vnode 掛載到 oldVnode 的父元素下
      • 如果組件的根節(jié)點被替換,就遍歷更新父節(jié)點,然后刪掉舊的節(jié)點
      • 如果是服務(wù)端渲染就用 hydrating 把 oldVnode 和真實 DOM 混合

下面看完整的 patch 函數(shù)源碼,說明我都寫在注釋里了

// 兩個判斷函數(shù)
function isUndef (v: any): boolean %checks {
  return v === undefined || v === null
}
function isDef (v: any): boolean %checks {
  return v !== undefined && v !== null
}
return function patch (oldVnode, vnode, hydrating, removeOnly) {
    // 如果新的 vnode 不存在,但是 oldVnode 存在
    if (isUndef(vnode)) {
      // 如果 oldVnode 存在,調(diào)用 oldVnode 的組件卸載鉤子 destroy
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }

    let isInitialPatch = false
    const insertedVnodeQueue = []
    
    // 如果 oldVnode 不存在的話,新的 vnode 是肯定存在的,比如首次渲染的時候
    if (isUndef(oldVnode)) {
      isInitialPatch = true
      // 就創(chuàng)建新的 vnode
      createElm(vnode, insertedVnodeQueue)
    } else {
      // 剩下的都是新的 vnode 和 oldVnode 都存在的話
      
      // 是不是元素節(jié)點
      const isRealElement = isDef(oldVnode.nodeType)
      // 是元素節(jié)點 && 通過 sameVnode 對比是不是同一個節(jié)點 (函數(shù)后面有詳解)
      if (!isRealElement && sameVnode(oldVnode, vnode)) {
        // 如果是 就用 patchVnode 進行后續(xù)對比 (函數(shù)后面有詳解)
        patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
      } else {
        // 如果不是同一元素節(jié)點的話
        if (isRealElement) {
          // const SSR_ATTR = 'data-server-rendered'
          // 如果是元素節(jié)點 并且有 'data-server-rendered' 這個屬性
          if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
            // 就是服務(wù)端渲染的,刪掉這個屬性
            oldVnode.removeAttribute(SSR_ATTR)
            hydrating = true
          }
          // 這個判斷里是服務(wù)端渲染的處理邏輯,就是混合
          if (isTrue(hydrating)) {
            if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
              invokeInsertHook(vnode, insertedVnodeQueue, true)
              return oldVnode
            } else if (process.env.NODE_ENV !== 'production') {
              warn('這是一段很長的警告信息')
            }
          }
          // function emptyNodeAt (elm) {
          //    return new VNode(nodeOps.tagName(elm).toLowerCase(), {}, [], undefined, elm)
          //  }
          // 如果不是服務(wù)端渲染的,或者混合失敗,就創(chuàng)建一個空的注釋節(jié)點替換 oldVnode
          oldVnode = emptyNodeAt(oldVnode)
        }
        
        // 拿到 oldVnode 的父節(jié)點
        const oldElm = oldVnode.elm
        const parentElm = nodeOps.parentNode(oldElm)
        
        // 根據(jù)新的 vnode 創(chuàng)建一個 DOM 節(jié)點,掛載到父節(jié)點上
        createElm(
          vnode,
          insertedVnodeQueue,
          oldElm._leaveCb ? null : parentElm,
          nodeOps.nextSibling(oldElm)
        )
        
        // 如果新的 vnode 的根節(jié)點存在,就是說根節(jié)點被修改了,就需要遍歷更新父節(jié)點
        if (isDef(vnode.parent)) {
          let ancestor = vnode.parent
          const patchable = isPatchable(vnode)
          // 遞歸更新父節(jié)點下的元素
          while (ancestor) {
            // 卸載老根節(jié)點下的全部組件
            for (let i = 0; i < cbs.destroy.length; ++i) {
              cbs.destroy[i](ancestor)
            }
            // 替換現(xiàn)有元素
            ancestor.elm = vnode.elm
            if (patchable) {
              for (let i = 0; i < cbs.create.length; ++i) {
                cbs.create[i](emptyNode, ancestor)
              }
              const insert = ancestor.data.hook.insert
              if (insert.merged) {
                for (let i = 1; i < insert.fns.length; i++) {
                  insert.fns[i]()
                }
              }
            } else {
              registerRef(ancestor)
            }
            // 更新父節(jié)點
            ancestor = ancestor.parent
          }
        }
        // 如果舊節(jié)點還存在,就刪掉舊節(jié)點
        if (isDef(parentElm)) {
          removeVnodes([oldVnode], 0, 0)
        } else if (isDef(oldVnode.tag)) {
          // 否則直接卸載 oldVnode
          invokeDestroyHook(oldVnode)
        }
      }
    }
    // 返回更新后的節(jié)點
    invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
    return vnode.elm
  }

sameVnode

源碼地址:src/core/vdom/patch.js -35行

這個是用來判斷是不是同一節(jié)點的函數(shù)

這個函數(shù)不長,直接看源碼吧

function sameVnode (a, b) {
  return (
    a.key === b.key &&  // key 是不是一樣
    a.asyncFactory === b.asyncFactory && ( // 是不是異步組件
      (
        a.tag === b.tag && // 標簽是不是一樣
        a.isComment === b.isComment && // 是不是注釋節(jié)點
        isDef(a.data) === isDef(b.data) && // 內(nèi)容數(shù)據(jù)是不是一樣
        sameInputType(a, b) // 判斷 input 的 type 是不是一樣
      ) || (
        isTrue(a.isAsyncPlaceholder) && // 判斷區(qū)分異步組件的占位符否存在
        isUndef(b.asyncFactory.error)
      )
    )
  )
}

patchVnode

源碼地址:src/core/vdom/patch.js -501行

這個是在新的 vnode 和 oldVnode 是同一節(jié)點的情況下,才會執(zhí)行的函數(shù),主要是對比節(jié)點文本變化或子節(jié)點變化

還是先介紹一下主要流程,再看源碼吧,流程是這樣的:

  • 如果 oldVnode 和 vnode 的引用地址是一樣的,就表示節(jié)點沒有變化,直接返回
  • 如果 oldVnode 的 isAsyncPlaceholder 存在,就跳過異步組件的檢查,直接返回
  • 如果 oldVnode 和 vnode 都是靜態(tài)節(jié)點,并且有一樣的 key,并且 vnode 是克隆節(jié)點或者 v-once 指令控制的節(jié)點時,把 oldVnode.elm 和 oldVnode.child 都復(fù)制到 vnode 上,然后返回
  • 如果 vnode 不是文本節(jié)點也不是注釋的情況下
    • 如果 vnode 和 oldVnode 都有子節(jié)點,而且子節(jié)點不一樣的話,就調(diào)用 updateChildren 更新子節(jié)點
    • 如果只有 vnode 有子節(jié)點,就調(diào)用 addVnodes 創(chuàng)建子節(jié)點
    • 如果只有 oldVnode 有子節(jié)點,就調(diào)用 removeVnodes 刪除該子節(jié)點
    • 如果 vnode 文本為 undefined,就刪掉 vnode.elm 文本
  • 如果 vnode 是文本節(jié)點但是和 oldVnode 文本內(nèi)容不一樣,就更新文本
  function patchVnode (
    oldVnode, // 老的虛擬 DOM 節(jié)點
    vnode, // 新的虛擬 DOM 節(jié)點
    insertedVnodeQueue, // 插入節(jié)點的隊列
    ownerArray, // 節(jié)點數(shù)組
    index, // 當前節(jié)點的下標
    removeOnly // 只有在
  ) {
    // 新老節(jié)點引用地址是一樣的,直接返回
    // 比如 props 沒有改變的時候,子組件就不做渲染,直接復(fù)用
    if (oldVnode === vnode) return
    
    // 新的 vnode 真實的 DOM 元素
    if (isDef(vnode.elm) && isDef(ownerArray)) {
      // clone reused vnode
      vnode = ownerArray[index] = cloneVNode(vnode)
    }

    const elm = vnode.elm = oldVnode.elm
    // 如果當前節(jié)點是注釋或 v-if 的,或者是異步函數(shù),就跳過檢查異步組件
    if (isTrue(oldVnode.isAsyncPlaceholder)) {
      if (isDef(vnode.asyncFactory.resolved)) {
        hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
      } else {
        vnode.isAsyncPlaceholder = true
      }
      return
    }
    // 當前節(jié)點是靜態(tài)節(jié)點的時候,key 也一樣,或者有 v-once 的時候,就直接賦值返回
    if (isTrue(vnode.isStatic) &&
      isTrue(oldVnode.isStatic) &&
      vnode.key === oldVnode.key &&
      (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
    ) {
      vnode.componentInstance = oldVnode.componentInstance
      return
    }
    // hook 相關(guān)的不用管
    let i
    const data = vnode.data
    if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
      i(oldVnode, vnode)
    }
    // 獲取子元素列表
    const oldCh = oldVnode.children
    const ch = vnode.children
    
    if (isDef(data) && isPatchable(vnode)) {
      // 遍歷調(diào)用 update 更新 oldVnode 所有屬性,比如 class,style,attrs,domProps,events...
      // 這里的 update 鉤子函數(shù)是 vnode 本身的鉤子函數(shù)
      for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
      // 這里的 update 鉤子函數(shù)是我們傳過來的函數(shù)
      if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
    }
    // 如果新節(jié)點不是文本節(jié)點,也就是說有子節(jié)點
    if (isUndef(vnode.text)) {
      // 如果新老節(jié)點都有子節(jié)點
      if (isDef(oldCh) && isDef(ch)) {
        // 如果新老節(jié)點的子節(jié)點不一樣,就執(zhí)行 updateChildren 函數(shù),對比子節(jié)點
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
      } else if (isDef(ch)) {
        // 如果新節(jié)點有子節(jié)點的話,就是說老節(jié)點沒有子節(jié)點
        
        // 如果老節(jié)點文本節(jié)點,就是說沒有子節(jié)點,就清空
        if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
        // 添加子節(jié)點
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
      } else if (isDef(oldCh)) {
        // 如果新節(jié)點沒有子節(jié)點,老節(jié)點有子節(jié)點,就刪除
        removeVnodes(oldCh, 0, oldCh.length - 1)
      } else if (isDef(oldVnode.text)) {
        // 如果老節(jié)點是文本節(jié)點,就清空
        nodeOps.setTextContent(elm, '')
      }
    } else if (oldVnode.text !== vnode.text) {
      // 新老節(jié)點都是文本節(jié)點,且文本不一樣,就更新文本
      nodeOps.setTextContent(elm, vnode.text)
    }
    if (isDef(data)) {
      // 執(zhí)行 postpatch 鉤子
      if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
    }
  }

updateChildren

源碼地址:src/core/vdom/patch.js -404行

這個是新的 vnode 和 oldVnode 都有子節(jié)點,且子節(jié)點不一樣的時候進行對比子節(jié)點的函數(shù)

這里很關(guān)鍵,很關(guān)鍵!

比如現(xiàn)在有兩個子節(jié)點列表對比,對比主要流程如下

循環(huán)遍歷兩個列表,循環(huán)停止條件是:其中一個列表的開始指針 startIdx 和 結(jié)束指針 endIdx 重合

循環(huán)內(nèi)容是:{

  • 新的頭和老的頭對比
  • 新的尾和老的尾對比
  • 新的頭和老的尾對比
  • 新的尾和老的頭對比。 這四種對比如圖

以上四種只要有一種判斷相等,就調(diào)用 patchVnode 對比節(jié)點文本變化或子節(jié)點變化,然后移動對比的下標,繼續(xù)下一輪循環(huán)對比

如果以上四種情況都沒有命中,就不斷拿新的開始節(jié)點的 key 去老的 children 里找

  • 如果沒找到,就創(chuàng)建一個新的節(jié)點
  • 如果找到了,再對比標簽是不是同一個節(jié)點
    • 如果是同一個節(jié)點,就調(diào)用 patchVnode 進行后續(xù)對比,然后把這個節(jié)點插入到老的開始前面,并且移動新的開始下標,繼續(xù)下一輪循環(huán)對比
    • 如果不是相同節(jié)點,就創(chuàng)建一個新的節(jié)點
      }
  • 如果老的 vnode 先遍歷完,就添加新的 vnode 沒有遍歷的節(jié)點
  • 如果新的 vnode 先遍歷完,就刪除老的 vnode 沒有遍歷的節(jié)點

為什么會有頭對尾,尾對頭的操作?

因為可以快速檢測出 reverse 操作,加快 Diff 效率

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    let oldStartIdx = 0 // 老 vnode 遍歷的下標
    let newStartIdx = 0 // 新 vnode 遍歷的下標
    let oldEndIdx = oldCh.length - 1 // 老 vnode 列表長度
    let oldStartVnode = oldCh[0] // 老 vnode 列表第一個子元素
    let oldEndVnode = oldCh[oldEndIdx] // 老 vnode 列表最后一個子元素
    let newEndIdx = newCh.length - 1 // 新 vnode 列表長度
    let newStartVnode = newCh[0] // 新 vnode 列表第一個子元素
    let newEndVnode = newCh[newEndIdx] // 新 vnode 列表最后一個子元素
    let oldKeyToIdx, idxInOld, vnodeToMove, refElm

    const canMove = !removeOnly
    
    // 循環(huán),規(guī)則是開始指針向右移動,結(jié)束指針向左移動移動
    // 當開始和結(jié)束的指針重合的時候就結(jié)束循環(huán)
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
      } else if (isUndef(oldEndVnode)) {
        oldEndVnode = oldCh[--oldEndIdx]
        
        // 老開始和新開始對比
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        // 是同一節(jié)點 遞歸調(diào)用 繼續(xù)對比這兩個節(jié)點的內(nèi)容和子節(jié)點
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        // 然后把指針后移一位,從前往后依次對比
        // 比如第一次對比兩個列表的[0],然后比[1]...,后面同理
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
        
        // 老結(jié)束和新結(jié)束對比
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        // 然后把指針前移一位,從后往前比
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
        
        // 老開始和新結(jié)束對比
      } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
        // 老的列表從前往后取值,新的列表從后往前取值,然后對比
        oldStartVnode = oldCh[++oldStartIdx]
        newEndVnode = newCh[--newEndIdx]
        
        // 老結(jié)束和新開始對比
      } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        // 老的列表從后往前取值,新的列表從前往后取值,然后對比
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
        
        // 以上四種情況都沒有命中的情況
      } else {
        if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        // 拿到新開始的 key,在老的 children 里去找有沒有某個節(jié)點有這個 key
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
          
        // 新的 children 里有,可是沒有在老的 children 里找到對應(yīng)的元素
        if (isUndef(idxInOld)) {
          /// 就創(chuàng)建新的元素
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        } else {
          // 在老的 children 里找到了對應(yīng)的元素
          vnodeToMove = oldCh[idxInOld]
          // 判斷標簽如果是一樣的
          if (sameVnode(vnodeToMove, newStartVnode)) {
            // 就把兩個相同的節(jié)點做一個更新
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
            oldCh[idxInOld] = undefined
            canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
          } else {
            // 如果標簽是不一樣的,就創(chuàng)建新的元素
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
          }
        }
        newStartVnode = newCh[++newStartIdx]
      }
    }
    // oldStartIdx > oldEndIdx 說明老的 vnode 先遍歷完
    if (oldStartIdx > oldEndIdx) {
      // 就添加從 newStartIdx 到 newEndIdx 之間的節(jié)點
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    
    // 否則就說明新的 vnode 先遍歷完
    } else if (newStartIdx > newEndIdx) {
      // 就刪除掉老的 vnode 里沒有遍歷的節(jié)點
      removeVnodes(oldCh, oldStartIdx, oldEndIdx)
    }
  }

至此,整個 Diff 流程的核心邏輯源碼到這就結(jié)束了,再來看一下 Vue 3 里做了哪些改變吧

Vue3 的優(yōu)化

本文源碼版本是 Vue2 的,在 Vue3 里整個重寫了 Diff 算法這一塊東西,所以源碼的話可以說基本是完全不一樣的,但是要做的事還是一樣的

關(guān)于 Vue3 的 Diff 完整源碼解析還在撰稿中,過幾天就發(fā)布了,這里先介紹一下相比 Vue2 優(yōu)化的部分,尤大公布的數(shù)據(jù)就是 update 性能提升了 1.3~2 倍,ssr 性能提升了 2~3 倍,來看看都有哪些優(yōu)化

  • 事件緩存:將事件緩存,可以理解為變成靜態(tài)的了
  • 添加靜態(tài)標記:Vue2 是全量 Diff,Vue3 是靜態(tài)標記 + 非全量 Diff
  • 靜態(tài)提升:創(chuàng)建靜態(tài)節(jié)點時保存,后續(xù)直接復(fù)用
  • 使用最長遞增子序列優(yōu)化了對比流程:Vue2 里在 updateChildren() 函數(shù)里對比變更,在 Vue3 里這一塊的邏輯主要在 patchKeyedChildren() 函數(shù)里,具體看下面

事件緩存

比如這樣一個有點擊事件的按鈕

<button @click="handleClick">按鈕</button>

來看下在 Vue3 被編譯后的結(jié)果

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("button", {
    onClick: _cache[0] || (_cache[0] = (...args) => (_ctx.handleClick && _ctx.handleClick(...args)))
  }, "按鈕"))
}

注意看,onClick 會先讀取緩存,如果緩存沒有的話,就把傳入的事件存到緩存里,都可以理解為變成靜態(tài)節(jié)點了,優(yōu)秀吧,而在 Vue2 中就沒有緩存,就是動態(tài)的

靜態(tài)標記

看一下靜態(tài)標記是啥?

源碼地址:packages/shared/src/patchFlags.ts

export const enum PatchFlags {
  TEXT = 1 ,  // 動態(tài)文本節(jié)點
  CLASS = 1 << 1,  // 2   動態(tài)class
  STYLE = 1 << 2,  // 4   動態(tài)style
  PROPS = 1 << 3,  // 8   除去class/style以外的動態(tài)屬性
  FULL_PROPS = 1 << 4,       // 16  有動態(tài)key屬性的節(jié)點,當key改變時,需進行完整的diff比較
  HYDRATE_EVENTS = 1 << 5,   // 32  有監(jiān)聽事件的節(jié)點
  STABLE_FRAGMENT = 1 << 6,  // 64  一個不會改變子節(jié)點順序的fragment (一個組件內(nèi)多個根元素就會用fragment包裹)
  KEYED_FRAGMENT = 1 << 7,   // 128 帶有key屬性的fragment或部分子節(jié)點有key
  UNKEYEN_FRAGMENT = 1 << 8, // 256  子節(jié)點沒有key的fragment
  NEED_PATCH = 1 << 9,       // 512  一個節(jié)點只會進行非props比較
  DYNAMIC_SLOTS = 1 << 10,   // 1024   動態(tài)slot
  HOISTED = -1,  // 靜態(tài)節(jié)點 
  BAIL = -2      // 表示 Diff 過程中不需要優(yōu)化
}

先了解一下靜態(tài)標記有什么用?看個圖

在什么地方用到的呢?比如下面這樣的代碼

<div id="app">
    <div>沐華</div>
    <p>{{ age }}</p>
</div>

在 Vue2 中編譯的結(jié)果是,有興趣的可以自行安裝 vue-template-compiler 自行測試

with(this){
    return _c(
      'div',
      {attrs:{"id":"app"}},
      [ 
        _c('div',[_v("沐華")]),
        _c('p',[_v(_s(age))])
      ]
    )
}

在 Vue3 中編譯的結(jié)果是這樣的,有興趣的可以點擊這里自行測試

const _hoisted_1 = { id: "app" }
const _hoisted_2 = /*#__PURE__*/_createElementVNode("div", null, "沐華", -1 /* HOISTED */)

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", _hoisted_1, [
    _hoisted_2,
    _createElementVNode("p", null, _toDisplayString(_ctx.age), 1 /* TEXT */)
  ]))
}

看到上面編譯結(jié)果中的 -11 了嗎,這就是靜態(tài)標記,這是在 Vue2 中沒有的,patch 過程中就會判斷這個標記來 Diff 優(yōu)化流程,跳過一些靜態(tài)節(jié)點對比

靜態(tài)提升

其實還是拿上面 Vue2 和 Vue3 靜態(tài)標記的例子,在 Vue2 里每當觸發(fā)更新的時候,不管元素是否參與更新,每次都會全部重新創(chuàng)建,就是下面這一堆

with(this){
    return _c(
      'div',
      {attrs:{"id":"app"}},
      [ 
        _c('div',[_v("沐華")]),
        _c('p',[_v(_s(age))])
      ]
    )
}

而在 Vue3 中會把這個不參與更新的元素保存起來,只創(chuàng)建一次,之后在每次渲染的時候不停地復(fù)用,比如上面例子中的這個,靜態(tài)的創(chuàng)建一次保存起來

const _hoisted_1 = { id: "app" }
const _hoisted_2 = /*#__PURE__*/_createElementVNode("div", null, "沐華", -1 /* HOISTED */)

然后每次更新 age 的時候,就只創(chuàng)建這個動態(tài)的內(nèi)容,復(fù)用上面保存的靜態(tài)內(nèi)容

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", _hoisted_1, [
    _hoisted_2,
    _createElementVNode("p", null, _toDisplayString(_ctx.age), 1 /* TEXT */)
  ]))
}

patchKeyedChildren

在 Vue2 里 updateChildren 會進行

  • 頭和頭比
  • 尾和尾比
  • 頭和尾比
  • 尾和頭比
  • 都沒有命中的對比

在 Vue3 里 patchKeyedChildren

  • 頭和頭比
  • 尾和尾比
  • 基于最長遞增子序列進行移動/添加/刪除

看個例子,比如

  • 老的 children:[ a, b, c, d, e, f, g ]
  • 新的 children:[ a, b, f, c, d, e, h, g ]
  1. 先進行頭和頭比,發(fā)現(xiàn)不同就結(jié)束循環(huán),得到 [ a, b ]
  2. 再進行尾和尾比,發(fā)現(xiàn)不同就結(jié)束循環(huán),得到 [ g ]
  3. 再保存沒有比較過的節(jié)點 [ f, c, d, e, h ],并通過 newIndexToOldIndexMap 拿到在數(shù)組里對應(yīng)的下標,生成數(shù)組 [ 5, 2, 3, 4, -1 ],-1 是老數(shù)組里沒有的就說明是新增
  4. 然后再拿取出數(shù)組里的最長遞增子序列,也就是 [ 2, 3, 4 ] 對應(yīng)的節(jié)點 [ c, d, e ]
  5. 然后只需要把其他剩余的節(jié)點,基于 [ c, d, e ] 的位置進行移動/新增/刪除就可以了

使用最長遞增子序列可以最大程度的減少 DOM 的移動,達到最少的 DOM 操作,有興趣的話去 leet-code 第300題(最長遞增子序列) 體驗下

往期精彩

結(jié)語

如果本文對你有一丁點幫助,點個贊支持一下下吧,感謝感謝

本文首發(fā)掘金:https://juejin.cn/post/7010594233253888013

已授權(quán)稀土掘金技術(shù)社區(qū)公眾號獨家使用,請勿轉(zhuǎn)載,感謝

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

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

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