15張圖,20分鐘吃透Diff算法核心原理,我說的!!!

前言

你們好,我是林三心,在平常面試中,Diff算法都是繞不過去的一道坎,用最通俗的話,講最難的知識點一直是我寫文章的宗旨,今天我就用通俗的方式來說解一下Diff算法吧?Lets Gohtml

image.png

什麼是虛擬DOM

Diff算法前,我先給你們講一講什麼是虛擬DOM吧。這有利於後面你們對Diff算法的理解加深。node

虛擬DOM是一個對象,一個什麼樣的對象呢?一個用來表示真實DOM的對象,要記住這句話。我舉個例子,請看如下真實DOM面試

<ul id="list">
    <li class="item">哈哈</li>
    <li class="item">呵呵</li>
    <li class="item">嘿嘿</li>
</ul>
複製代碼

對應的虛擬DOM爲:算法

let oldVDOM = { // 舊虛擬DOM
        tagName: 'ul', // 標籤名
        props: { // 標籤屬性
            id: 'list'
        },
        children: [ // 標籤子節點
            {
                tagName: 'li', props: { class: 'item' }, children: ['哈哈']
            },
            {
                tagName: 'li', props: { class: 'item' }, children: ['呵呵']
            },
            {
                tagName: 'li', props: { class: 'item' }, children: ['嘿嘿']
            },
        ]
    }
複製代碼

這時候,我修改一個li標籤的文本:api

<ul id="list">
    <li class="item">哈哈</li>
    <li class="item">呵呵</li>
    <li class="item">林三心哈哈哈哈哈</li> // 修改
</ul>
複製代碼

這時候生成的新虛擬DOM爲:數組

let newVDOM = { // 新虛擬DOM
        tagName: 'ul', // 標籤名
        props: { // 標籤屬性
            id: 'list'
        },
        children: [ // 標籤子節點
            {
                tagName: 'li', props: { class: 'item' }, children: ['哈哈']
            },
            {
                tagName: 'li', props: { class: 'item' }, children: ['呵呵']
            },
            {
                tagName: 'li', props: { class: 'item' }, children: ['林三心哈哈哈哈哈']
            },
        ]
    }
複製代碼

這就是我們日常說的新舊兩個虛擬DOM,這個時候的新虛擬DOM是數據的最新狀態,那麼咱們直接拿新虛擬DOM去渲染成真實DOM的話,效率真的會比直接操做真實DOM高嗎?那確定是不會的,看下圖:markdown

截屏2021-08-07 下午10.24.17.png

由上圖,一看便知,確定是第2種方式比較快,由於第1種方式中間還夾着一個虛擬DOM的步驟,因此虛擬DOM比真實DOM快這句話實際上是錯的,或者說是不嚴謹的。那正確的說法是什麼呢?虛擬DOM算法操做真實DOM,性能高於直接操做真實DOM虛擬DOM虛擬DOM算法是兩種概念。虛擬DOM算法 = 虛擬DOM + Diff算法app

什麼是Diff算法

上面我們說了虛擬DOM,也知道了只有虛擬DOM + Diff算法才能真正的提升性能,那講完虛擬DOM,咱們再來說講Diff算法吧,仍是上面的例子(這張圖被壓縮的有點小,你們能夠打開看,比較清晰):ide

截屏2021-08-07 下午10.59.31.png

上圖中,其實只有一個li標籤修改了文本,其餘都是不變的,因此不必全部的節點都要更新,只更新這個li標籤就行,Diff算法就是查出這個li標籤的算法。函數

總結:Diff算法是一種對比算法。對比二者是舊虛擬DOM和新虛擬DOM,對比出是哪一個虛擬節點更改了,找出這個虛擬節點,並只更新這個虛擬節點所對應的真實節點,而不用更新其餘數據沒發生改變的節點,實現精準地更新真實DOM,進而提升效率

使用虛擬DOM算法的損耗計算: 總損耗 = 虛擬DOM增刪改+(與Diff算法效率有關)真實DOM差別增刪改+(較少的節點)排版與重繪

直接操做真實DOM的損耗計算: 總損耗 = 真實DOM徹底增刪改+(可能較多的節點)排版與重繪

Diff算法的原理

Diff同層對比

新舊虛擬DOM對比的時候,Diff算法比較只會在同層級進行, 不會跨層級比較。 因此Diff算法是:廣度優先算法。 時間複雜度:O(n)

截屏2021-08-08 上午11.32.47.png

Diff對比流程

當數據改變時,會觸發setter,而且經過Dep.notify去通知全部訂閱者Watcher,訂閱者們就會調用patch方法,給真實DOM打補丁,更新相應的視圖。對於這一步不太瞭解的能夠看一下我以前寫Vue源碼系列

newVnode和oldVnode:同層的新舊虛擬節點 截屏2021-08-08 上午11.49.38.png

patch方法

這個方法做用就是,對比當前同層的虛擬節點是否爲同一種類型的標籤(同一類型的標準,下面會講)

  • 是:繼續執行patchVnode方法進行深層比對
  • 否:不必比對了,直接整個節點替換成新虛擬節點

來看看patch的核心原理代碼

function patch(oldVnode, newVnode) {
  // 比較是否爲一個類型的節點
  if (sameVnode(oldVnode, newVnode)) {
    // 是:繼續進行深層比較
    patchVnode(oldVnode, newVnode)
  } else {
    // 否
    const oldEl = oldVnode.el // 舊虛擬節點的真實DOM節點
    const parentEle = api.parentNode(oldEl) // 獲取父節點
    createEle(newVnode) // 建立新虛擬節點對應的真實DOM節點
    if (parentEle !== null) {
      api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) // 將新元素添加進父元素
      api.removeChild(parentEle, oldVnode.el)  // 移除之前的舊元素節點
      // 設置null,釋放內存
      oldVnode = null
    }
  }

  return newVnode
}
複製代碼

sameVnode方法

patch關鍵的一步就是sameVnode方法判斷是否爲同一類型節點,那問題來了,怎麼纔算是同一類型節點呢?這個類型的標準是什麼呢?

我們來看看sameVnode方法的核心原理代碼,就一目瞭然了

function sameVnode(oldVnode, newVnode) {
  return (
    oldVnode.key === newVnode.key && // key值是否同樣
    oldVnode.tagName === newVnode.tagName && // 標籤名是否同樣
    oldVnode.isComment === newVnode.isComment && // 是否都爲註釋節點
    isDef(oldVnode.data) === isDef(newVnode.data) && // 是否都定義了data
    sameInputType(oldVnode, newVnode) // 當標籤爲input時,type必須是否相同
  )
}
複製代碼

patchVnode方法

這個函數作了如下事情:

  • 找到對應的真實DOM,稱爲el
  • 判斷newVnodeoldVnode是否指向同一個對象,若是是,那麼直接return
  • 若是他們都有文本節點而且不相等,那麼將el的文本節點設置爲newVnode的文本節點。
  • 若是oldVnode有子節點而newVnode沒有,則刪除el的子節點
  • 若是oldVnode沒有子節點而newVnode有,則將newVnode的子節點真實化以後添加到el
  • 若是二者都有子節點,則執行updateChildren函數比較子節點,這一步很重要
function patchVnode(oldVnode, newVnode) {
  const el = newVnode.el = oldVnode.el // 獲取真實DOM對象
  // 獲取新舊虛擬節點的子節點數組
  const oldCh = oldVnode.children, newCh = newVnode.children
  // 若是新舊虛擬節點是同一個對象,則終止
  if (oldVnode === newVnode) return
  // 若是新舊虛擬節點是文本節點,且文本不同
  if (oldVnode.text !== null && newVnode.text !== null && oldVnode.text !== newVnode.text) {
    // 則直接將真實DOM中文本更新爲新虛擬節點的文本
    api.setTextContent(el, newVnode.text)
  } else {
    // 不然

    if (oldCh && newCh && oldCh !== newCh) {
      // 新舊虛擬節點都有子節點,且子節點不同

      // 對比子節點,並更新
      updateChildren(el, oldCh, newCh)
    } else if (newCh) {
      // 新虛擬節點有子節點,舊虛擬節點沒有

      // 建立新虛擬節點的子節點,並更新到真實DOM上去
      createEle(newVnode)
    } else if (oldCh) {
      // 舊虛擬節點有子節點,新虛擬節點沒有

      //直接刪除真實DOM裏對應的子節點
      api.removeChild(el)
    }
  }
}
複製代碼

其餘幾個點都很好理解,咱們詳細來說一下updateChildren

updateChildren方法

這是patchVnode裏最重要的一個方法,新舊虛擬節點的子節點對比,就是發生在updateChildren方法中,接下來就結合一些圖來說,讓你們更好理解吧

是怎麼樣一個對比方法呢?就是首尾指針法,新的子節點集合和舊的子節點集合,各有首尾兩個指針,舉個例子:

<ul>
    <li>a</li>
    <li>b</li>
    <li>c</li>
</ul>

修改數據後

<ul>
    <li>b</li>
    <li>c</li>
    <li>e</li>
    <li>a</li>
</ul>

複製代碼

那麼新舊兩個子節點集合以及其首尾指針爲:

截屏2021-08-08 下午2.55.26.png

而後會進行互相進行比較,總共有五種比較狀況:

  • 一、oldS 和 newS 使用sameVnode方法進行比較,sameVnode(oldS, newS)
  • 二、oldS 和 newE 使用sameVnode方法進行比較,sameVnode(oldS, newE)
  • 三、oldE 和 newS 使用sameVnode方法進行比較,sameVnode(oldE, newS)
  • 四、oldE 和 newE 使用sameVnode方法進行比較,sameVnode(oldE, newE)
  • 五、若是以上邏輯都匹配不到,再把全部舊子節點的 key 作一個映射到舊節點下標的 key -> index 表,而後用新 vnode 的 key 去找出在舊節點中能夠複用的位置。

截屏2021-08-08 下午2.57.22.png

接下來就以上面代碼爲例,分析一下比較的過程

分析以前,請你們記住一點,最終的渲染結果都要以newVDOM爲準,這也解釋了爲何以後的節點移動須要移動到newVDOM所對應的位置

截屏2021-08-08 下午3.03.31.png

  • 第一步
oldS = a, oldE = c
newS = b, newE = a
複製代碼

比較結果:oldS 和 newE 相等,須要把節點a移動到newE所對應的位置,也就是末尾,同時oldS++newE--

截屏2021-08-08 下午3.26.25.png

  • 第二步
oldS = b, oldE = c
newS = b, newE = e
複製代碼

比較結果:oldS 和 newS相等,須要把節點b移動到newS所對應的位置,同時oldS++,newS++

截屏2021-08-08 下午3.27.13.png

  • 第三步
oldS = c, oldE = c
newS = c, newE = e
複製代碼

比較結果:oldS、oldE 和 newS相等,須要把節點c移動到newS所對應的位置,同時oldS++,oldE--,newS++

截屏2021-08-08 下午3.31.48.png

  • 第四步

oldS > oldE,則oldCh先遍歷完成了,而newCh還沒遍歷完,說明newCh比oldCh多,因此須要將多出來的節點,插入到真實DOM上對應的位置上

截屏2021-08-08 下午3.37.51.png

  • 思考題

我在這裏給你們留一個思考題哈。上面的例子是newCh比oldCh多,假如相反,是oldCh比newCh多的話,那就是newCh先走完循環,而後oldCh會有多出的節點,結果會在真實DOM裏進行刪除這些舊節點。你們能夠本身思考一下,模擬一下這個過程,像我同樣,畫圖模擬,才能鞏固上面的知識。

附上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
  let idxInOld
  let elmToMove
  let before
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (oldStartVnode == 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)) {
      patchVnode(oldStartVnode, newStartVnode)
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      patchVnode(oldEndVnode, newEndVnode)
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldStartVnode, newEndVnode)) {
      patchVnode(oldStartVnode, newEndVnode)
      api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el))
      oldStartVnode = oldCh[++oldStartIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldEndVnode, newStartVnode)) {
      patchVnode(oldEndVnode, newStartVnode)
      api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el)
      oldEndVnode = oldCh[--oldEndIdx]
      newStartVnode = newCh[++newStartIdx]
    } else {
      // 使用key時的比較
      if (oldKeyToIdx === undefined) {
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 有key生成index表
      }
      idxInOld = oldKeyToIdx[newStartVnode.key]
      if (!idxInOld) {
        api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
        newStartVnode = newCh[++newStartIdx]
      }
      else {
        elmToMove = oldCh[idxInOld]
        if (elmToMove.sel !== newStartVnode.sel) {
          api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
        } else {
          patchVnode(elmToMove, newStartVnode)
          oldCh[idxInOld] = null
          api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el)
        }
        newStartVnode = newCh[++newStartIdx]
      }
    }
  }
  if (oldStartIdx > oldEndIdx) {
    before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
    addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
  } else if (newStartIdx > newEndIdx) {
    removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
  }
}
複製代碼

用index作key

日常v-for循環渲染的時候,爲何不建議用index做爲循環項的key呢?

咱們舉個例子,左邊是初始數據,而後我在數據前插入一個新數據,變成右邊的列表

<ul>                      <ul> <li key="0">a</li> <li key="0">林三心</li> <li key="1">b</li> <li key="1">a</li> <li key="2">c</li> <li key="2">b</li> <li key="3">c</li> </ul>                     </ul>

複製代碼

按理說,最理想的結果是:只插入一個li標籤新節點,其餘都不動,確保操做DOM效率最高。可是咱們這裏用了index來當key的話,真的會實現咱們的理想結果嗎?廢話很少說,實踐一下:

<ul>
   <li v-for="(item, index) in list" :key="index">{{ item.title }}</li>
</ul>
<button @click="add">增長</button>

list: [
        { title: "a", id: "100" },
        { title: "b", id: "101" },
        { title: "c", id: "102" },
      ]
      
add() {
      this.list.unshift({ title: "林三心", id: "99" });
    }
複製代碼

點擊按鈕咱們能夠看到,並非咱們預想的結果,而是全部li標籤都更新了

keyindex.gif

爲何會這樣呢?仍是經過圖來解釋

按理說,a,b,c三個li標籤都是複用以前的,由於他們三個根本沒改變,改變的只是前面新增了一個林三心

截屏2021-08-08 下午5.43.25.png

可是咱們前面說了,在進行子節點的 diff算法 過程當中,會進行 舊首節點和新首節點的sameNode對比,這一步命中了邏輯,由於如今新舊兩次首部節點 的 key 都是 0了,同理,key爲1和2的也是命中了邏輯,致使相同key的節點會去進行patchVnode更新文本,而本來就有的c節點,卻由於以前沒有key爲4的節點,而被當作了新節點,因此很搞笑,使用index作key,最後新增的竟然是原本就已有的c節點。因此前三個都進行patchVnode更新文本,最後一個進行了新增,那就解釋了爲何全部li標籤都更新了。

截屏2021-08-08 下午5.45.17.png

那咱們能夠怎麼解決呢?其實咱們只要使用一個獨一無二的值來當作key就好了

<ul>
   <li v-for="item in list" :key="item.id">{{ item.title }}</li>
</ul>
複製代碼

如今再來看看效果

idkey.gif

爲何用了id來當作key就實現了咱們的理想效果呢,由於這麼作的話,a,b,c節點key就會是永遠不變的,更新先後key都是同樣的,而且又因爲a,b,c節點的內容原本就沒變,因此就算是進行了patchVnode,也不會執行裏面複雜的更新操做,節省了性能,而林三心節點,因爲更新前沒有他的key所對應的節點,因此他被當作新的節點,增長到真實DOM上去了。

截屏2021-08-08 下午6.04.34.png

結語

但願能幫到那些一直想了解虛擬DOM和Diff算法的同窗

若是你以爲本文有幫到你一點點的話,請點個讚唄哈哈

歡迎各位同窗指出個人錯誤,我會及時更改滴

學習羣請點這裏,一塊兒學習,一塊兒摸魚!!!

image.png

相關文章
相關標籤/搜索