圖解 Diff 算法——Vue 篇

一、虛擬 dom

1. 虛擬 dom 是什麼?

虛擬 dom 是一個對象,一個用 js 來模擬真實 dom 的對象;

// 真實的dom結構
<ul id='list'>
    <li class='item1'>111</li>
    <li class='item2'>222</li>
    <li class='item3'>333</li>
</ul>

那麼上述 dom 結構,在虛擬 dom 中是如何進行展示的呢?

// 舊的虛擬dom結構
const oldVDom = { 
     tagName: 'ul', // 標籤名
     props: {  // 標籤屬性
        id: 'list' 
     },
     children: [ // 標籤子節點
        { tagName: 'li', props: { class: 'item1' }, children: ['111'] },
        { tagName: 'li', props: { class: 'item2' }, children: ['222'] },
        { tagName: 'li', props: { class: 'item3' }, children: ['333'] },
     ]
}

此時我修改一下真實的 dom 結構後:

<ul id='list'>
    <li class='item1'>111</li>
    <li class='item2'>222</li>
    <li class='item3'>three-three</li>
</ul>

之後會生成新的虛擬 dom:

// 新的虛擬dom結構
const newVDom = { 
     tagName: 'ul', // 標籤名
     props: {  // 標籤屬性
        id: 'list' 
     },
     children: [ // 標籤子節點
         // 在diff中,會通過patch發現此處兩個節點沒有變化,並將其複用
        { tagName: 'li', props: { class: 'item1' }, children: ['111'] },
        { tagName: 'li', props: { class: 'item2' }, children: ['222'] },
        // 在diff的過程中,會通過patch來找出此處發生了更改,並將其替換
        { tagName: 'li', props: { class: 'item3' }, children: ['three-three']},
     ]
}

此時看到的兩個 dom 結構就是我們常說的 新舊虛擬 dom

2. 爲什麼要有虛擬 dom ?解決了什麼問題?

在虛擬 dom 出現之前,我們都是 jQuery 一把梭(不多說了 jQuery yyds)。

這裏先來了解一下瀏覽器的渲染原理:

由圖可以發現觸發一次重排的代價還是比較大的;如果頻繁觸發瀏覽器的重排,無疑會造成很大的性能成本。

我們都知道,在每一次事件循環後瀏覽器會有一個 UI 的渲染過程,那麼在一次事件循環內觸發的所有 dom 操作都會被當作爲異步任務被放進異步任務隊列中等待被處理。

那麼此例子只是更改了一次 dom 結構,如果更改 100 + 次呢?

雖然瀏覽器做了優化,在一段時間內頻繁觸發的 dom 不會被立即執行,瀏覽器會積攢變動以最高 60HZ 的頻率更新視圖;但是難免還是會造成一定次數的重排。

這時候,虛擬 dom 就派上了用場:不管更改多少次,多少個地方的結構,都會映射到新的虛擬 dom 結構中去,然後進行 diff 的對比,最終渲染成真實的 dom,在這一次 render 中只會操作一次真實的 dom 結構,所以只會造成一次重排。

同時,採用 JS 對象去模擬 DOM 結構的好處是,頁面的更新完全可以映射到 JS 對象中去處理,而操作內存中的 JS 對象速度也會更快。

所以纔有了虛擬 dom 的出現,可以看下圖虛擬 dom 工作原理:

看了上述虛擬 dom 的優點,我們來聊聊使用它的一些代價:

  1. 首屏加載時間更長

由於我們需要根據當前的節點,來生成對應的虛擬 dom,我們都知道虛擬 dom 是一個 JS 對象,所以在項目初始化的時候去生成對應的虛擬節點也是一筆時間上的開銷;因此項目的首次加載可能耗費更多時間

  1. 極端場景下性能不是最優解

栗子🌰:如果當前頁面的節點基本全都改變了,那我們去做了一次 diff 的 patch 過程相當於做了無效操作;

二、Diff 算法

瞭解了虛擬 dom 結構之後,我們都清楚了 diff 的觸發時機是在新舊 VDom 進行對比的時候

tips:既然所有的更改都被映射到了新的 VDom 上,那麼爲何不直接將新的 VDom 渲染成真實的 dom 呢?

answer:如果直接渲染的話,會默認把所有節點都更新一遍,造成不必要的節點更新;而經過了 diff 的比較後可以精確的找出那些節點需要更新,從而實現按需更新的理念,節省性能;

那麼 Diff 算法的比較規則有哪些呢?

同層比較

爲什麼要同層比較?

如果不同層比較的話,全部的對比完一整個 dom 結構,時間複雜度是 O(n^3) ; 時間成本太大了;所以改用同層比較這樣的方法去犧牲了精度而提高了時間效率。

可以看到圖中每一層的節點,都是同層在進行對比,這樣的好處就是,不會每一層的對比都是相對獨立的,不會影響到下一層的對比;同時同層對比的時間複雜度也是 O(n);

同時也是遵循一個深度優先的原則;diff 的過程是一個深度優先遍歷節點,然後將該節點與 newVDom 中的同層節點進行對比,如果有差異,則記錄該節點到 JS 對象中。

在同層對比的過程中有這樣幾種情況:

<div>
    <p>ppp</p>
    <ul id='list' >
        <li class='item1'>111</li>   
        <li class='item2'>222</li>  
        <li class='item3'>333</li>
    </ul>
    <div>div</div>
</div>
<div>
    // 1. 節點類型發生了改變
    <h3>ppp</h3>
    // 2. 節點類型一樣,屬性發生變化
    <ul id='list-change'>
        <li class='item1'>111</li>   
        <li class='item2'>222</li>  
        // 3. 節點被刪除
        // <li class='item3'>333</li> 
        // 4. 新增節點
        <li class='item4'>444</li>  
    </ul>
    // 4. 文本變化
    <div>屬性變化</div>
</div>

1. 節點類型變了

節點p標籤 變成了h3標籤,此時 diff 的過程中p節點會被直接銷燬,然後掛載新的節點 h3,同時p標籤的子節點也會被全部銷燬;雖然可能造成一些不必要的銷燬,但是爲了實現同層比較的方法節省時間成本只能這樣做咯;同時這樣也告誡我們在寫代碼的時候,可以規避一些不必要的父節點的類型替換,比如將p標籤換成了div等。

2. 節點類型一樣,屬性或者屬性值發生變化

此時不會觸發節點的卸載和掛載,只會觸發當前節點的更新

3. 刪除 / 新增 / 改變 節點

這時候就需要找出這些節點並在 newVDom 中進行插入 / 刪除,這個過程請看下面 vue 和 react 是如何利用 key 值來處理的吧!

4. 文本變化

只會觸發文本的改變

三、vue 中的 diff 算法

在瞭解了虛擬 dom 和 diff 算法的相關內容後,我們來看看各大框架中是如何做處理的吧!

vue2-- 雙端比較

這裏你需要提前瞭解 vue2 內部的響應式原理是如何運作的,推薦文章:vue2 的響應式原理 [1]。那麼,當觸發了 vue 的數據的響應式後,其內部的一系列變化又是如何呢?

首先,數據改變會觸發 setter,然後調用 Dep.notify(), 並且通過Dep.notify去通知所有訂閱者Watcher  訂閱者們就會調用patch方法  給真實 DOM 打補丁,更新相應的視圖。

接下來我們來分析幾個核心函數吧:

patch ()

diff 的入口函數;

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, newVnode.el, api.nextSibling(oldEl)) // 將新元素添加進父元素
      api.removeChild(parentEle, oldVnode.el)  // 移除以前的舊元素節點
      // 設置null,釋放內存
      oldVnode = null
    }
  }
  return newVnode
}

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 ()

此階段我們已經找到了需要去對比的節點,那麼該方法主要做了什麼呢?

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) {
      // 新舊虛擬節點都有子節點,且子節點不一樣
      // 對比子節點,並更新
      /*  diff核心!!*/  
      updateChildren(el, oldCh, newCh) 
    } else if (newCh) {
      // 新虛擬節點有子節點,舊虛擬節點沒有
      // 創建新虛擬節點的子節點,並更新到真實DOM上去
      createEle(newVnode)
    } else if (oldCh) {
      // 舊虛擬節點有子節點,新虛擬節點沒有
      // 直接刪除真實DOM裏對應的子節點
      api.removeChild(el)
    }
  }
}

updateChildren ()

此方法就是 diff 算法的核心部分,當發現新舊虛擬節點的的子節點都存在時候,我們就需要通過一些方法來判斷哪些節點是需要移動的,哪些節點是可以直接複用的,來提高我們整個 diff 的效率;

下面就通過一些圖解來講解吧:

<ul>
    <li>a</li>
    <li>b</li>
    <li>c</li>
    <li>b</li>
 </ul>
修改數據後 ⬇️ // a,b,c,d  ->  d,b,a,c
<ul>
    <li>d</li>
    <li>b</li>
    <li>a</li>
    <li>c</li>
</ul>
1、理想情況下

經過四次比較可以找到替換的節點,可以看到圖中第四次找到了可替換的節點;

可以看到在 oldCh 和 newCh 的首尾定義了四個指針:

這是一個不斷向內部收縮的過程,直到對比完所有的節點;

function vue2Diff(prevChildren, nextChildren, parent) {
  // 在新舊首尾,分別定義四個指針
  let oldStartIndex = 0,
    oldEndIndex = prevChildren.length - 1
    newStartIndex = 0,
    newEndIndex = nextChildren.length - 1;
  let oldStartNode = prevChildren[oldStartIndex],
    oldEndNode = prevChildren[oldEndIndex],
    newStartNode = nextChildren[newStartIndex],
    newEndNode = nextChildren[newEndIndex];
   // 不斷向內收縮
  while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
      if (oldStartNode.key === newStartNode.key) {
        ...
      } else if (oldEndNode.key === newEndNode.key) {
        ...
      } else if (oldStartNode.key === newEndNode.key) {
        ...
      } else if (oldEndNode.key === newStartNode.key) {
        ...
      }
  }
}

在經歷了上面的循環後,我們可以找出一些節點並將其複用,但是我們複用的過程中,需要怎麼插入這些節點呢?

以上圖中的爲第一步,我們可以發現,d 節點原本在舊列表末尾的節點,卻是新列表中的開頭節點,沒有人比它更靠前,因爲他是第一個,所以我們只需要把當前的節點移動到原本舊列表中的第一個節點之前,讓它成爲第一個節點即可

第二步:

第二步我們可以發現了 key 相同的 c 節點,舊列表的尾節點oldE新列表的尾節點newE爲複用節點。原本在舊列表中就是尾節點,在新列表中也是尾節點,說明該節點不需要移動,所以我們什麼都不需要做。

第三步:

在第三步中我們可以看到 a 節點是可以複用的,舊列表的頭節點oldS新列表的尾節點newE爲複用節點,我們只要將DOM-a移動到DOM-b後面就可以了。原本舊列表中是頭節點,然後在新列表中是尾節點。那麼只要在舊列表中把當前的節點移動到原本尾節點的後面,就可以了

第四步:這一步不需要移動節點,直接複用;

最終呢,我們就得到了對比後的 dbac 啦,同時發現只有 d 和 a 節點需要進行移動,而 b 、c 節點都是不需要移動的;那麼至此,一個理想狀態下的 diff 比較過程就結束了,是不是感覺很清晰易懂呢?

2、非理想狀態下

可以看到圖中四次比較都沒有找到可以複用的節點,那麼我們只能把所有舊子節點的 key 做一個映射到舊節點下標的 key -> index 表,然後用新 vnode 的 key 去找出在舊節點中可以複用的位置;可以看下圖的處理。拿新列表的第一個節點去舊列表中找與其key相同的節點。

那麼我們就以 newCh 的首節點的 key 值,去到 oldChkey - index 的映射表中,去根據key值找到對應的節點,同時將 b 節點移動到首部去,因爲在新列表中 b 就屬於首部,所以在oldCh中也需要移動到首部 ;同時,還需要將 oldCh 中的 b 節點設爲 undefined , 因爲已經複用過了,就可以跳過比較了。

這個非理想的狀態下的對比時間複雜度爲 O(n^2):

function vue2Diff(prevChildren, nextChildren, parent) {
  //...
  while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
    if (oldStartNode.key === newStartNode.key) {
    //...
    } else if (oldEndNode.key === newEndNode.key) {
    //...
    } else if (oldStartNode.key === newEndNode.key) {
    //...
    } else if (oldEndNode.key === newStartNode.key) {
    //...
    } else {
      // 在舊列表中找到 和新列表頭節點key 相同的節點
      let newtKey = newStartNode.key,
        oldIndex = prevChildren.findIndex(child => child.key === newKey);
      
      if (oldIndex > -1) {
        let oldNode = prevChildren[oldIndex];
        patch(oldNode, newStartNode, parent)
        parent.insertBefore(oldNode.el, oldStartNode.el)
        // 複用後,設置爲 undefined 
        prevChildren[oldIndex] = undefined
      }
      newStartNode = nextChildren[++newStartIndex]
    }
  }
}

vue3-- 最長遞增子序列

那麼相比 vue2 中的雙端對比,在 vue3 中的 diff 算法,又做了哪些優化呢?

以下面的例子來看:

1. 從頭對比找到有相同的節點 patch ,發現不同,立即跳出。

2. 如果第一步沒有 patch 完,立即,從後往前開始 patch , 如果發現不同立即跳出循環。

3. 如果新的節點大於老的節點數 ,對於剩下的節點全部以新的 vnode 處理(這種情況說明已經 patch 完相同的 vnode)。

4. 對於老的節點大於新的節點的情況 , 對於超出的節點全部卸載(這種情況說明已經 patch 完相同的 vnode)。

5. 不確定的元素(這種情況說明沒有 patch 完相同的 vnode) 與 3 ,4 對立關係。

前面的邏輯跟 vue2 還是比較像,逐漸向中間收縮,那麼關鍵點就在判斷哪些節點是需要變動的。

在經歷上述操作後,會出現以下節點需要判斷(即圖中圈起來的節點):

首先,我們以新節點的數量創建一個 source 數組,並用 -1 填滿;

這個source數組就是用來做新舊節點的對應關係的,我們將新節點舊列表的位置存儲在該數組中,我們再根據source計算出它的最長遞增子序列用於移動 DOM 節點。

其次,我們先建立一個對象存儲當前新列表中的節點index的關係:

const newVNodeMap = {
    c: '1', 
    d: '2',
    b: '3',
    i: '4'
}

然後再去舊列表中去找相同的節點,並記錄其index的位置。

在找節點時,如果舊節點在新列表中沒有的話,直接刪除就好。除此之外,我們還需要一個數量表示記錄我們已經patch過的節點,如果數量已經與新列表剩餘的節點數量一樣,那麼剩下的舊節點我們就直接刪除了就可以了。

Dom 如何移動?

首先,我們需要定義一個 Lis 數組來存儲 source 中的最長連續遞增子序列的下標:-   然後從後往前遍歷 source 數組;這個過程中會發生三種情況

tips:沒看懂這三種情況?不要慌:

我們來一步一步拆解:

  1. 首先,i = 3,即上圖中,值爲 -1 爲第一種情況,節點需要新增,i--

  1. i = 2,索引爲 2 != Lis[j] **** 爲第三種情況,節點需要移動,直接在舊列表中,將 b 節點插入到尾部位置,i --

  1. i = 1,此時索引 i == Lis[j] 爲第二種情況,我們的節點不需要移動;

  1. i = 0,此時索引 i == Lis[j] 爲第二種情況,我們的節點也不需要移動;

至此 vue3 的 diff 的對比過程就已經完成了,相比於 2 中的首尾指針法,在這種非理想情況下的節點對比採用了最長遞增子序列的算法思想來做處理;

這三種情況對應在源碼中

function vue3Diff(prevChildren, nextChildren, parent) {
  //...
  if (move) {
    // 需要移動
    const seq = lis(source); // [0, 1]
    let j = seq.length - 1;  // 最長子序列的指針
    // 從後向前遍歷
    for (let i = nextLeft - 1;i >= 0; i--) {
      let pos = nextStart + i, // 對應新列表的index
        nextNode = nextChildren[pos], // 找到vnode
        nextPos = pos + 1,    // 下一個節點的位置,用於移動DOM
        refNode = nextPos >= nextChildren.length ? null : nextChildren[nextPos].el, //DOM節點
        cur = source[i];      // 當前source的值,用來判斷節點是否需要移動
      if (cur === -1) {
        // 情況1,該節點是全新節點
        mount(nextNode, parent, refNode)
      } else if (cur === seq[j]) {
        // 情況2,是遞增子序列,該節點不需要移動
        // 讓j指向下一個
        j--
      } else {
        // 情況3,不是遞增子序列,該節點需要移動
        parent.insetBefore(nextNode.el, refNode)
      }
    }
  } else {
  // 不需要移動
  for (let i = nextLeft - 1;i >= 0; i--) {
      let cur = source[i];              // 當前source的值,用來判斷節點是否需要移動
    
      if (cur === -1) {
       let pos = nextStart + i,         // 對應新列表的index
          nextNode = nextChildren[pos], // 找到vnode
          nextPos = pos + 1,           // 下一個節點的位置,用於移動DOM
          refNode = nextPos >= nextChildren.length ? null : nextChildren[nextPos].el, //DOM節點
          mount(nextNode, parent, refNode)
      }
    }
}

你可能會問,你這邊遞增的子序列需要連續嗎,那麼這裏給你將例子稍微變動一下:這時候你會發現連續遞增的節點是 c, d, e 他們不是緊密連續的,但是在整個 list 中卻是保持 index 遞增的,也不需要移動。

思考題

參考上面的圖解,結合源碼,看看下面例子中的虛擬 dom 節點是怎麼移動的。

時間複雜度的優化

這裏我們只需要找出 source 中的最長連續遞增子序列 就 ok 了:

舉個例子:[10,5,6,7,4,1,2,8,9]

那麼在該此例子中,連續遞增的子序列是 [5,6,7,8,9], 所以返回的個數是 5;

可以參考該算法的基礎實現:

const arr = [10,5,6,7,4,1,2,8,9]
function lis(arr) {
  let len = arr.length,
    dp = new Array(len).fill(1); // 用於保存長度
  // i = 0 => O(n^2) ;  i != 0 =>  O(nlogn)
  for (let i = len - 1; i >= 0; i--) { 
    let cur = arr[i]
    for(let j = i + 1; j < len; j++) {
      let next = arr[j]
      // 如果是遞增 取更大的長度值
      if (cur < next) dp[i] = Math.max(dp[j]+1, dp[i])
    }
  }
  return Math.max(...dp)
}
lis(arr) // 5

由算法可以看出:在最好的情況下即 i != 0 的條件下,平均的時間複雜度是 O(nlgn) 那麼在 i = 0 時,時間複雜度爲 O(n^2)

在 vue2 中關於的這種節點的查找和替換的時間複雜度穩定爲 O(n^2)

在 vue3 中依賴於最長遞增子序列去做節點的移動和刪除 / 新增,時間複雜度爲 O(nlgn)~O(n^2)

四、key 值的作用,爲什麼不能使用 index 作爲 key 值?

key 的作用 -- 性能更高

爲什麼不能使用 index 作爲 key?

很簡單,來看個例子:

<ul>                      <ul>
    <li key= 0 >a</li>        <li key= 0 >new</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>

按理來說,我們應該會複用裏面的 a、b、c 三個節點對吧;

看這個例子,我們直接 unshift() 插入列表一個新元素,這時候 index 發生了變化!!即 key 也會發生變化!!

但是我們知道:按照 Vue 中的比較思路,這樣的話,我們就無法複用哪些本來可以複用的節點,導致該節點被重新渲染一次,造成 vue 組件內一些列的更新,如果列表一旦很大,開銷成本巨大

只要此時你的列表是一個動態的列表:而且使用了 index 作爲 key 值,當你新增或者刪除列表時候,key 的排序總是以 0、1、2、3... 去排序的,而這樣也會導致列表元素的 key 值在不斷變化;導致 Vue 不能準確的找到可複用的節點,而是去直接做了 patch 操作,造成很多額外的工作。

解決辦法 -- 唯一值

這也是我們爲什麼要用一個唯一的值去作爲列表的 key 值的原因了!所以我們一般可以用 id / 唯一值作爲 key,這是規範問題,所以大家以後再看到項目中有 index 作爲 key 的情況,請讓他去學習 diff 算法吧哈哈哈!

所以在學習了 diff 之後要警示我們:

總結

 另外,特別感謝本文審校的同學👏(人名不分先後):米澤,李世奇,胡元港

參考資料

[1]

vue2 的響應式原理: https://juejin.cn/post/6844903981957791757

[2]

最長遞增子序列: https://leetcode-cn.com/problems/longest-increasing-subsequence/

[3]

vue3 diff 源碼: https://github.com/vuejs/core/blob/main/packages/runtime-core/src/vnode.ts

[4]

爲什麼不能用 index 作爲 key 值 - diff 算法詳解: https://juejin.cn/post/6844904113587634184#heading-13

[5]

vue 的 diff 算法詳解: https://juejin.cn/post/6844903607913938951

[6]

react、vue2 和 vue3---diff 算法: https://juejin.cn/post/6919376064833667080#heading-1

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/8M-pJdKjF6bx5ijtSFKIcw