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

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

認(rèn)識虛擬 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

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

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

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

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

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

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

認(rèn)識 Diff 算法

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

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

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

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

然后是在組件中數(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 進(jìn)行比較米者,這樣來簡化比較次數(shù)韭畸,這是第一個方面

2. 比較標(biāo)簽名

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

3. 比較 key

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

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

key 的作用

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

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

因為在不使用 key 或者列表的 index 作為 key 的時候因谎,每個元素對應(yīng)的位置關(guān)系都是 index,上圖中的結(jié)果直接導(dǎo)致我們插入的元素到后面的全部元素颜懊,對應(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 在比較的時候当叭,就可能會導(dǎo)致頻繁更新元素茬故,使整個 patch 過程比較低效,影響性能
  • 應(yīng)該避免使用數(shù)組下標(biāo)作為 key蚁鳖,因為 key 值不是唯一的話可能會導(dǎo)致上面圖中表示的 bug磺芭,使 Vue 無法區(qū)分它他,還有比如在使用相同標(biāo)簽元素過渡切換的時候醉箕,就會導(dǎo)致只替換其內(nèi)部屬性而不會觸發(fā)過渡效果
  • 從源碼里可以知道钾腺,Vue 判斷兩個節(jié)點是否相同時主要判斷兩者的元素類型和 key 等徙垫,如果不設(shè)置 key,就可能永遠(yuǎn)認(rèn)為這兩個是相同節(jié)點放棒,只能去做更新操作姻报,就造成大量不必要的 DOM 更新操作,明顯是不可取的

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

Diff 算法核心原理——源碼

上面說了Diff 算法吴旋,在 Vue 里面就是 patch,鋪墊了這么多厢破,下面進(jìn)入源碼里看一下這個神乎其神的 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 進(jìn)行后續(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 進(jìn)行后續(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 && // 標(biāo)簽是不是一樣
        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, // 當(dāng)前節(jié)點的下標(biāo)
    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
    // 如果當(dāng)前節(jié)點是注釋或 v-if 的,或者是異步函數(shù)郎嫁,就跳過檢查異步組件
    if (isTrue(oldVnode.isAsyncPlaceholder)) {
      if (isDef(vnode.asyncFactory.resolved)) {
        hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
      } else {
        vnode.isAsyncPlaceholder = true
      }
      return
    }
    // 當(dāng)前節(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é)點不一樣的時候進(jìn)行對比子節(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é)點變化码荔,然后移動對比的下標(biāo),繼續(xù)下一輪循環(huán)對比

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

  • 如果沒找到缩搅,就創(chuàng)建一個新的節(jié)點
  • 如果找到了,再對比標(biāo)簽是不是同一個節(jié)點
    • 如果是同一個節(jié)點触幼,就調(diào)用 patchVnode 進(jìn)行后續(xù)對比硼瓣,然后把這個節(jié)點插入到老的開始前面,并且移動新的開始下標(biāo)置谦,繼續(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 遍歷的下標(biāo)
    let newStartIdx = 0 // 新 vnode 遍歷的下標(biāo)
    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é)束指針向左移動移動
    // 當(dāng)開始和結(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]
          // 判斷標(biāo)簽如果是一樣的
          if (sameVnode(vnodeToMove, newStartVnode)) {
            // 就把兩個相同的節(jié)點做一個更新
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
            oldCh[idxInOld] = undefined
            canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
          } else {
            // 如果標(biāo)簽是不一樣的拇颅,就創(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)標(biāo)記:Vue2 是全量 Diff流强,Vue3 是靜態(tài)標(biāo)記 + 非全量 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)標(biāo)記

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

源碼地址: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é)點充易,當(dāng)key改變時,需進(jìn)行完整的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é)點只會進(jìn)行非props比較
  DYNAMIC_SLOTS = 1 << 10,   // 1024   動態(tài)slot
  HOISTED = -1,  // 靜態(tài)節(jié)點 
  BAIL = -2      // 表示 Diff 過程中不需要優(yōu)化
}

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

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

<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)標(biāo)記,這是在 Vue2 中沒有的辕狰,patch 過程中就會判斷這個標(biāo)記來 Diff 優(yōu)化流程改备,跳過一些靜態(tài)節(jié)點對比

靜態(tài)提升

其實還是拿上面 Vue2 和 Vue3 靜態(tài)標(biāo)記的例子,在 Vue2 里每當(dāng)觸發(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 會進(jìn)行

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

在 Vue3 里 patchKeyedChildren

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

看個例子,比如

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

使用最長遞增子序列可以最大程度的減少 DOM 的移動望门,達(dá)到最少的 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)系作者
  • 序言:七十年代末哄酝,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子祷膳,更是在濱河造成了極大的恐慌陶衅,老刑警劉巖,帶你破解...
    沈念sama閱讀 222,104評論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件直晨,死亡現(xiàn)場離奇詭異搀军,居然都是意外死亡,警方通過查閱死者的電腦和手機勇皇,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,816評論 3 399
  • 文/潘曉璐 我一進(jìn)店門罩句,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人敛摘,你說我怎么就攤上這事门烂。” “怎么了兄淫?”我有些...
    開封第一講書人閱讀 168,697評論 0 360
  • 文/不壞的土叔 我叫張陵屯远,是天一觀的道長。 經(jīng)常有香客問我捕虽,道長氓润,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 59,836評論 1 298
  • 正文 為了忘掉前任薯鳍,我火速辦了婚禮咖气,結(jié)果婚禮上婆赠,老公的妹妹穿的比我還像新娘囤热。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 68,851評論 6 397
  • 文/花漫 我一把揭開白布芥颈。 她就那樣靜靜地躺著变勇,像睡著了一般骡送。 火紅的嫁衣襯著肌膚如雪世杀。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 52,441評論 1 310
  • 那天乳幸,我揣著相機與錄音瞪讼,去河邊找鬼。 笑死粹断,一個胖子當(dāng)著我的面吹牛符欠,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播瓶埋,決...
    沈念sama閱讀 40,992評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼希柿,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了养筒?” 一聲冷哼從身側(cè)響起曾撤,我...
    開封第一講書人閱讀 39,899評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎晕粪,沒想到半個月后挤悉,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 46,457評論 1 318
  • 正文 獨居荒郊野嶺守林人離奇死亡巫湘,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,529評論 3 341
  • 正文 我和宋清朗相戀三年尖啡,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片剩膘。...
    茶點故事閱讀 40,664評論 1 352
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖盆顾,靈堂內(nèi)的尸體忽然破棺而出怠褐,到底是詐尸還是另有隱情,我是刑警寧澤您宪,帶...
    沈念sama閱讀 36,346評論 5 350
  • 正文 年R本政府宣布奈懒,位于F島的核電站,受9級特大地震影響宪巨,放射性物質(zhì)發(fā)生泄漏磷杏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 42,025評論 3 334
  • 文/蒙蒙 一捏卓、第九天 我趴在偏房一處隱蔽的房頂上張望极祸。 院中可真熱鬧,春花似錦、人聲如沸遥金。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,511評論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽稿械。三九已至选泻,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間美莫,已是汗流浹背页眯。 一陣腳步聲響...
    開封第一講書人閱讀 33,611評論 1 272
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留厢呵,地道東北人窝撵。 一個月前我還...
    沈念sama閱讀 49,081評論 3 377
  • 正文 我出身青樓,卻偏偏與公主長得像述吸,于是被迫代替她去往敵國和親忿族。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,675評論 2 359

推薦閱讀更多精彩內(nèi)容