和麪試官聊聊Diff___Vue3

這是 聊diff 的第三篇文章,聊聊vue3的diff思路.思路主要來自 vue-design 項目

【第一篇】和麪試官聊聊Diff___React
【第二篇】和麪試官聊聊Diff___vue2
【第三篇】和麪試官聊聊Diff___Vue3(本文)html

爲了更好的閱讀體驗,建議從第一篇看起前端

我是一名前端的小學生。行文中對某些設計原理理解有誤十分歡迎你們討論指正😁😁😁,謝謝啦!固然有好的建議也謝謝提出來
在這裏插入圖片描述
(玩笑)vue

Let's startnode


Vue3_diff

過程分析

本文注重的是patch過程,具體的細節和邊界就沒有考慮。react

==另 外 注 意==git

  • 三篇文章 diff 的講解,爲了方便展現 節點複用, 用了 children 保存內容,實際上這是不合理的,由於children不一樣還會遞歸補丁(patch)
  • diff也不是vue optimize的所有,只是其中一部分,例如compile時肯定節點類型,不一樣類型 不一樣的 mount/patch 處理方式等等。
    Vue2.x的 diff 相對於 react 更好一些,避免了一些沒必要要的比對。

我先假設有以下節點, keyVnodekey, children 表明該節點的內容github

// 之前的節點
const preNodes = [
  {key: "k-1", children: "<span>old1</span>"},
  {key: "k-2", children: "<span>old2</span>"},
  {key: "k-3", children: "<span>old3</span>"},
  {key: "k-4", children: "<span>old4</span>"},
  {key: "k-5", children: "<span>old5</span>"},
  {key: "k-6", children: "<span>old6</span>"},
]
// 新節點,最後更新的結果
const nextNodes = [
  {key: "k-11", children: "<span>11</span>"},
  {key: "k-0", children: "<span>0</span>"},
  {key: "k-5", children: "<span>5</span>"},
  {key: "k-13", children: "<span>13</span>"},
  {key: "k-1", children: "<span>1</span>"},
  {key: "k-7", children: "<span>7</span>"},
  {key: "k-16", children: "<span>16</span>"},
  {key: "k-3", children: "<span>3</span>"},
  {key: "k-15", children: "<span>15</span>"},
  {key: "k-17", children: "<span>7</span>"},
  {key: "k-4", children: "<span>4</span>"},
  {key: "k-6", children: "<span>6</span>"}
]

diff 是基於新舊的 diff, 先要明確這個大前提,若是剛剛開始沒有節點,則會先 mount 而不會 patch
最後指望的結果(老節點都獲得了複用)
在這裏插入圖片描述面試

另外新產生節點(newNodes)是基於老節點的,因而算法

// 最終的節點數據,由於最終的節點是基於老節點的,這裏作個模擬
let newNodes = JSON.parse(JSON.stringify(preNodes));

preNodes: 老節點;
nextNodes: 新節點;
newNodes: 新產生節點,最後用於渲染爲真實dom.其實vue2早期就是先徹底產生新節點,最後再渲染爲真實dom.後面版本變爲一次(patch)優化遍歷時就更新相應的Dom.segmentfault

中心思想提煉: 從老節點中找到與新節點中key相同的節點,進行復用。

詳細思路:

1. 先找兩端相同的節點(key相同),找到即再往中間找。

在這裏插入圖片描述

如圖,J 從開頭找, preEndIndexnextEndIndex 分別對應老節點和新節點的末尾索引。找到就增長 J 或者減小 preEndIndexnextEndIndex
在這裏插入圖片描述

代碼以下

let j = 0;

let preEndIndex = preNodes.length - 1;
let nextEndIndex = nextNodes.length - 1;
let preVNode = preNodes[j];
let nextVNode = nextNodes[j];

while(preVNode.key === nextVNode.key){
  j++;
  preVNode = preNodes[j];
  nextVNode = nextNodes[j];
}
preVNode = preNodes[preEndIndex];
nextVNode = nextNodes[nextEndIndex];
while(preVNode.key === nextVNode.key){
  preVNode = preNodes[--preEndIndex];
  nextVNode = nextNodes[--nextEndIndex];
}

考慮到一種狀況
在這裏插入圖片描述
上面的狀況會出現老節點比對完了,新節點還存在,那麼最後會形成 J > preEndIndex[狀況1],同理,老節點未比對完,新節點已經比對完,那麼會出現 J > nextEndIdnex[狀況2].
針對這兩種狀況,

  • 都要避免循環,引入label解決。
  • 另外,狀況1須要(從newNodes中)刪除多餘的節點,狀況2須要(向newNodes)增長新節點中未遍歷的節點。

因而把原來代碼改一下:

// ....
outer: {
  while(preVNode.key === nextVNode.key){
    j++;
    if(j> preEndIndex || j > newEndIndex) {
      break outer;
    }
    preVNode = preNodes[j];
    nextVNode = nextNodes[j];
  }
  preVNode = preNodes[preEndIndex];
  nextVNode = nextNodes[nextEndIndex];
  while(preVNode.key === nextVNode.key){
    if(j> preEndIndex || j > nextEndIndex) {
      break outer;
    }
    preVNode = preNodes[--preEndIndex];
    nextVNode = nextNodes[--nextEndIndex];
  }
}
if(j > preEndIndex) {
  // 老節點遍歷完了,新節點還存在,將新節點放入。
  for(let i = j; i< nextEndIndex; i++){
    const addedNode = nextNodes[i];
    // 注意: 框架內部是利用appendchild 更新dom.
    newNodes.splice(i,0,addedNode);
  }
}
else if(j > nextEndIndex){
  // 新節點遍歷完了,老節點還存在,將老節點刪除。
  const deleteLen = preEndIndex - j;
  // 注意: 框架內部是重寫removeChild 更新dom.
  newNodes.splice(i,deleteLen);
}else {  //均還有不一樣節點時 }

大多數狀況就像剛開始的例子同樣: 中間都還存在不一樣的節點,須要移動和新增。這個狀況是patch主要處理的地方,代碼寫在上面的 else 裏面。

具體思路是怎樣呢,

2. 產生一個老節點可複用節點的映射數組

先生成一個 每項爲 -1 的數組noPatchedIndex,長度爲 新節點未遍歷的節點長度。

遍歷未比對的 老節點和新節點(這裏有個優化細節: 新節點不用遍歷,由於結構的特殊性,直接生成key 對應 index 的對象 keyInIndex 例如{k-1: 0, k-2: 1, ...},後面直接取)。

未比對老節點中若是存在未比對新節點相同的節點那麼在 noPatchedIndex 相應位置保存起來它在老節點中的索引。

另外判斷時,新節點中不存在還需刪除老節點,而且得出是否須要移動元素(索引數組 noPatchedIndex 中存在非遞增排序,即數組中當前項不能大於以後的項)

大概這個意思
在這裏插入圖片描述

新生成節點。k-2在新節點(nextNodes)中不存在,因此被刪除。
在這裏插入圖片描述

3. 處理須要移動的狀況(複用節點處理)

大概有以下步驟:

  • ①找出 noPatchedIndex 最大遞增子序列 lisArr索引數組
  • ②將未比對新節點與依次插入到新節點。

針對①,這裏有一個函數 lis

lis([3,1,5,4,2]) //[1, 4] | 1,2 爲最大遞增子序列
lis([1,2,3]) //[0, 1, 2] | 1,2,3 爲最大遞增子序列
lis([0,-1,8,6,10,7]) //[1, 3, 5] | -1,6,7 爲最大遞增子序列
lis 具體實現請參考個人另外一篇文章 [算法篇---尋找最大遞增子序列]()

因而有
在這裏插入圖片描述

針對②,爲何要找最大遞增子序列 lisArr呢,由於對於 lisArr 裏面的項順序是不用動的,新節點的未比對節點只須要在這些項先後插入便可。
具體實現就是遍歷noPatchedIndexlisArr

  1. noPatchedIndex 項等於 -1,表示,老節點中不存在的項,須要新增
  2. noPatchedIndex 索引 與 lisArr 不相等時,須要移動老節點到響應的位置
  3. noPatchedIndex 索引 與 lisArr 相等時,不作操做。

須要注意:遍歷都是從後向前遍歷,目的是防止數組長度變換影響索引值進而影響節點取值,插入,刪除。

爲了直觀的理解,下面來一波操做圖:i jnoPatchedIndexlisArr

刪除的爲紅色,新增的爲綠色,複用的節點爲灰色;
節點複用插入時調用的是DOM API [insertBefore]()這個是先回添加該節點若是存在重複是會刪除原有節點的;
插入位置 爲 節點 nextNodes[nopatchedIndex[lisArr[j]]] 對應在 oldNodes 的位置

1)i =10, j = 2 ; 節點 k-4 複用
在這裏插入圖片描述
2)i=9, j=1
在這裏插入圖片描述

3)i=8, j=1. 新增
在這裏插入圖片描述

4)i=7,j=1. k-3複用
在這裏插入圖片描述

5)i=6, j=0
在這裏插入圖片描述
6)i=5, j=0
在這裏插入圖片描述
7)i=4, j=0.插入(涉及到增長和刪除),這裏由於插入的是老節點,而原節點(2)在插入位置(0)後面,因此新增以後刪除的索引位置要減一(代碼中會有體現)
在這裏插入圖片描述

8)i=3,j=0新增在這裏插入圖片描述

9)i=2, j=0.插入(涉及到增長和刪除),這裏由於插入的是老節點,而原節點(9)在插入位置(0)後面,因此新增以後刪除的索引位置要減一(代碼中會有體現)
在這裏插入圖片描述

10)i=1,j=0
在這裏插入圖片描述
11)i=0, j=0,新增
在這裏插入圖片描述
12) i= -1, 循環結束。

最後結果來看,k-5, k-1, k-2, k-4, k-6 獲得了複用, 那麼k-2 到哪去了呢,新節點nextNodes不含該節點天然在移動(move)前就刪除了!前面提到了。
至此,diff過程結束了相信其實看圖也能夠看明白

哎,畫圖太累了🤣,如今真心對那麼文章配有圖解說的博主 瑞思拜🙏🙏🙏(respect!!!)。absolute!!!

最後奉上所有代碼。

所有代碼

// 老節點
const preNodes = [
  {key: "k-1", children: "<span>old1</span>"},
  {key: "k-2", children: "<span>old2</span>"},
  {key: "k-3", children: "<span>old3</span>"},
  {key: "k-4", children: "<span>old4</span>"},
  {key: "k-5", children: "<span>old5</span>"},
  {key: "k-6", children: "<span>old6</span>"},
]
// 新節點
const nextNodes = [
  {key: "k-11", children: "<span>11</span>"},
  {key: "k-0", children: "<span>0</span>"},
  {key: "k-5", children: "<span>5</span>"},
  {key: "k-13", children: "<span>13</span>"},
  {key: "k-1", children: "<span>1</span>"},
  {key: "k-7", children: "<span>7</span>"},
  {key: "k-6", children: "<span>6</span>"},
  {key: "k-3", children: "<span>3</span>"},
  {key: "k-15", children: "<span>15</span>"},
  {key: "k-17", children: "<span>7</span>"},
  {key: "k-4", children: "<span>4</span>"},
  {key: "k-6", children: "<span>6</span>"}
]
// 最終的節點數據,由於最終的節點是基於老節點的,這裏作個模擬
let newNodes = JSON.parse(JSON.stringify(preNodes));

//兩個都從左邊開始比對的索引
let j = 0;

let preEndIndex = preNodes.length - 1;
let nextEndIndex = nextNodes.length - 1;
let preVNode = preNodes[j];
let nextVNode = nextNodes[j];

outer: {
  while(preVNode.key === nextVNode.key){
    j++;
    if(j> preEndIndex || j > newEndIndex) {
      break outer;
    }
    preVNode = preNodes[j];
    nextVNode = nextNodes[j];
  }
  preVNode = preNodes[preEndIndex];
  nextVNode = nextNodes[nextEndIndex];
  while(preVNode.key === nextVNode.key){
    if(j> preEndIndex || j > nextEndIndex) {
      break outer;
    }
    preVNode = preNodes[--preEndIndex];
    nextVNode = nextNodes[--nextEndIndex];
  }
}
if(j > preEndIndex) {
  // 老節點遍歷完了,新節點還存在,將新節點放入。
  for(let i = j; i< nextEndIndex; i++){
    const addedNode = nextNodes[i];
    // 注意: 框架內部是利用appendchild 更新dom.
    newNodes.splice(i,0,addedNode);
  }
}
else if(j > nextEndIndex){
  // 新節點遍歷完了,老節點還存在,將老節點刪除。
  const deleteLen = preEndIndex - j;
  // 注意: 框架內部是重寫removeChild 更新dom.
  newNodes.splice(i,deleteLen);
}
else {
  //保存是否須要移動
  let moved = false;

  const preStart = j;
  const nextStart = j;
  let pos = 0;
  //保存新節點key-index 的map, 避免屢次循環
  const keyInIndex = {}; //{ k-1: 1, k-2: 2, k-3:3,...  }
  //新節點未比對的節點長度
  const newLength = nextEndIndex - nextStart + 1;
  for(let i = nextStart; i< newLength; i++) {
    keyInIndex[nextNodes[i].key] = i
  }
  const oldLength = preEndIndex - preStart + 1;
  //防止老節點比新節點多時,刪除已找到的重複的節點
  let patched = 0;
  // 產生新節點能複用的老節點的索引數組
  const noPatchedIndex = Array(newLength).fill(-1); //-1狀態保存
  for(let i = preStart; i< oldLength; i++) {
    const preNode = preNodes[i];
    if(patched <= nextEndIndex) {
      //保存老節點key在新節點中對應的index
      const k = keyInIndex[preNode.key];
      if(typeof k !== 'undefined'){
        let idx = k - preStart;
        noPatchedIndex[idx] = i;
        patched++;
        // 篩選出須要往前調換的元素
        if(k < pos) moved = true;
        else pos = k;
      }else {
        // 動態查找刪除項索引
        const deleteIndex = newNodes.findIndex(node => node.key === preNodes[i].key)
        newNodes.splice(deleteIndex,1);
      }
    }else {
      newNodes.splice(i,1)
    }
  }
  
  //處理須要移動的狀況
  if(moved){
    const newNodesCopy = JSON.parse(JSON.stringify(newNodes))
    //最大遞增子序列索引
    const lisArr = lis(noPatchedIndex);
    let j = lisArr.length - 1;
    //遍歷新節點中未比對的節點,從後面遍歷,防止更新過程index非預期變化。
    for(let i=newLength - 1; i>=0; i--){
      const current = noPatchedIndex[i];
      // 更新的實際位置
      const pos = i+nextStart;
      let insertPos = newNodes.findIndex(node => node.key === nextNodes[nextStart+lisArr[j]].key);
      if(current === -1){// -1即爲新增的狀況
        // 注意 [1,2,3].splice(0,4) => [4,1,2,3]
        newNodes.splice(insertPos+1, 0, nextNodes[pos]);
        continue;
      }else if(lisArr[j] !== i) {//能夠複用非遞增節點的狀況
        /*
          insertBefore 做用: 若是給定的子節點是對文檔中現有節點的引用,insertBefore() 會將其從當前位置移動到新位置
          如下的操做就是實現 insertBefore 方法。
        */
        //移動元素在新節點的位置
        let oldPos = newNodes.findIndex(node => node.key === nextNodes[pos].key);

        //須要刪除插入的節點對應原來的節點
        //新節點中插入老節點對應的位置
        newNodes.splice(insertPos+1, 0, newNodes[oldPos]);
        // 判斷插入節點的位置在被插入位置的前面仍是後面,若是是後面就加1
        oldPos = insertPos > oldPos ? oldPos : oldPos+1;
        newNodes.splice(oldPos, 1)
      }else {
        j--;
      }
    }
  }
}
console.log('newNodes: ', newNodes);
// 尋找最大遞增子序列 索引
// https://en.wikipedia.org/wiki/Longest_increasing_subsequence
/*
  [3,1,5,4,2] => [1,2]
*/
function lis(arr) {
  const p = arr.slice();
  const result = [0]; // 索引數組
  let i;
  let j;
  let u;
  let v;
  let c;
  const len = arr.length;
  for (i = 0; i < len; i++) {
    const arrI = arr[i];
    if (arrI !== 0) {
      // 取最後一個元素
      j = result[result.length - 1];
      if (arr[j] < arrI) {
        p[i] = j;
        result.push(i);
        continue;
      }
      u = 0;
      v = result.length - 1;
      //result長度大於1時
      while (u < v) {
        // 取中位數
        c = ((u + v) / 2) | 0;
        if (arr[result[c]] < arrI) {
          u = c + 1;
        } else {
          v = c; //result中位數大於等於 當前項。v取中位數
        }
      }
      if (arrI < arr[result[u]]) {
        if (u > 0) {
          p[i] = result[u - 1];
        }
        result[u] = i;
      }
    }
  }
  u = result.length;
  v = result[u - 1];
  while (u-- > 0) {
    result[u] = v;
    v = p[v];
  }
  return result;
}

總結

本文中例子只是爲了更好理解diff思路, patch過程與真實狀況還有些差別

  • 重複節點問題。新老節點有重複節點時,本文diff函數沒處理這種狀況。
  • 僅是用數組模擬了Vnode,真實的Vnode 不止 key和children,還有更多的參數
  • 比對相同節點時,僅比對了 key, 真實其實還涉及到 class(類名) 、attrs(屬性值)、孩子節點(遞歸)等屬性比對;另外上面的children也要比對若不一樣也要遞歸遍歷
  • 插入、刪除、添加節點我用的數組。其實應該用 insertbeforedeleteadd。這些方法均是單獨封裝不能採用相對應的 Dom Api,由於 vue 不止用在瀏覽器環境。
  • ...

Vue@3.2 已經出來了,React@18也快了,哎,框架學不完。仍是多看看不變的東西吧(js, 設計模式, 數據結構,算法...)

哎哎哎,,同志,看完怎麼不點贊,別看別人就說你呢,你幾個意思?
在這裏插入圖片描述


參考

站在別人肩膀能看的更遠。

【推薦】vue-design
【掘金小冊】剖析Vue.js內部運行機制
【Vue patch源碼地址】vue-next

另外,大佬們正在翻譯 vue3的 英文文檔 docs-next-zh-cn


以上。
在這裏插入圖片描述

相關文章
相關標籤/搜索