幾種diff算法

首先我們知道 diff 算法本質的就是在新舊子節點中,正確找到需要移動,
需要移除,需要新增的節點,通過 js 運算儘量減少 patch 和操作 dom。
從而提高性能。現在主要有三種 diff 算法。記錄學習如下。

react 使用的 diff 算法思想

此 diff 是 react16 之前未引進 fiber 之前的算法的思想。
核心思想就是定義一個flag標誌新節點在舊節點最大的index值,
如果小於這個值則代表需要移動,大於這個值不需要移動。
並且在循環中不斷更新這個值。 
其實我們也能想明白,這個算法的本質是在新節點中不移動遞增的節點。
因爲遞增的節點代表他們的位置不需要調整,就算遞增的節點和舊的不一樣,沒關係。
我們只要把非遞增的節點移動就好了。

1. 找到新舊子節點相同 key,然後進行 patch

const preChildrenMap = {};
// 最大索引值
let maxIdx = 0;
for (let i = 0; i < preChildren.length; i++) {
  const node = preChildrenMap[i];
  if (node.key !== null) {
    preChildrenMap[node.key] = node;
  }
}
for (let j = 0; j < nextChildren.length; j++) {
  const nextChild = nextChildren[j];
  if (nextChild.key !== null) {
    const preNode = preChildrenMap[nextChild.key];
    if (preNode) {
      patch(preNode, nextChild.key, container);
      //需要進行移動
      if (j < maxIdx) {
      } else {
        //更新最大索引
        maxIdx = j;
      }
    }
  }
}

2. 找到需要移動的節點

假設移動之前的節點是abc,移動之後的節點是abc,
我們發現移動後的節點對應的舊節點的 index 爲0→1→2。
遞增關係
假設移動之前的節點是abc,移動之後的節點是cba, 我們發現索引變爲 2→1>0。
遞增的順序被打破,b 的位置 1 和 c 的位置 0 都比 c 的位置小。
說明 a 和 b 都需要移動,且需要移動到 c 的前面。
也就是說,我們在新節點的遍歷中需要記錄遇到的最大索引值,
如果在後續中如果遇到比最大索引小的就說明節點需要被移動。
    for (let j = 0; j < nextChildren.length; j++) {
      const nextChild = nextChildren[j];
      if (nextChild.key !== null) {
        const preNode = preChildrenMap[nextChild.key];
        if (preNode) {
          patch(preNode, nextChild.key, container);
          //需要進行移動
          if (j < maxIdx) {
          } else {
            //更新索引
            maxIdx = j;
          }
        }
      }
    }

3. 如何移動節點

比如 cba 的索引爲2→1>0:
1. 遍歷到 c,索引爲 2,比默認的最大索引 0 大,所以更新最大索引爲 2
2. 遍歷到 b,索引爲 1,比默認的最大索引 2 小,代表需要移動,
我們之間吧 b 移動到 c 節點對應的真實 dom 的後面
3. 遍歷到 a, 索引爲 0,比默認的最大索引 2 小,同樣移動到 b 節點對應的真是 dom 的後面

   for (let j = 0; j < nextChildren.length; j++) {
     const nextChild = nextChildren[j];
     let isNotAdd = false;
     if (nextChild.key !== null) {
       const preNode = preChildrenMap[nextChild.key];
       if (preNode) {
         patch(preNode, nextChild.key, container);
         if (j < maxIdx) {
           //前一個節點對應真實的el的後面
           const _node = nextChildren[i - 1].el.nextSibling;
           container.insertBefore(preNode.el, _node);
         } else {
           maxIdx = j;
         }
       }
     }

4. 添加元素

1. 我們可以在新節點每次循環時候增加標記isNotAdd,默認爲 false。
然後只有當節點有 key,且在舊的節點列表找到相同 key 的節點才標爲 true。
2. 每次循環結束判斷如果爲 true, 如果當前節點是第一位,將節點插入到真實 dom 的最開頭。
3. 判斷爲 true,且不是第一位,則插入到之前節點真實 dom 的後面
for (let j = 0; j < nextChildren.length; j++) {
     const nextChild = nextChildren[j];
     let isNotAdd = false;
     if (nextChild.key !== null) {
       const preNode = preChildrenMap[nextChild.key];
       if (preNode) {
         isNotAdd = true;
         patch(preNode, nextChild.key, container);
         if (j < maxIdx) {
           //前一個節點對應真實的el的後面
           const _node = nextChildren[i - 1].el.nextSibling;
           container.insertBefore(preNode.el, _node);
         } else {
           maxIdx = j;
         }
       }
     }
     if (!isNotAdd) {
       const _node =j - 1 < 0 ? prevChildren[0].el : nextChildren[j - 1].el.nextSibling;
       container.insertBefore(el, _node);
     }

5. 刪除元素

移除元素的方案是,最後在遍歷一次舊的節點,如果找不到相同 key 的。則移除;
//全部代碼
const preChildrenMap = {};
const nextChildrenMap = {};
// 最大索引值
let maxIdx = 0;
for (let i = 0; i < preChildren.length; i++) {
  const node = preChildrenMap[i];
  if (node.key !== null) {
    preChildrenMap[node.key] = node;
  }
}
for (let j = 0; j < nextChildren.length; j++) {
  const nextChild = nextChildren[j];
  let isNotAdd = false;
  if (nextChild.key !== null) {
    nextChildrenMap[nextChild.key] === nextChild;
    const preNode = preChildrenMap[nextChild.key];
    if (preNode) {
      isNotAdd = true;
      patch(preNode, nextChild.key, container);
      if (j < maxIdx) {
        //前一個節點對應真實的el的後面
        const _node = nextChildren[i - 1].el.nextSibling;
        container.insertBefore(preNode.el, _node);
      } else {
        maxIdx = j;
      }
    }
  }
  if (!isNotAdd) {
    const _node =
      j - 1 < 0 ? prevChildren[0].el : nextChildren[j - 1].el.nextSibling;
    container.insertBefore(el, _node);
  }
}
for (let i = 0; i < preChildren.length; i++) {
  const node = preChildren[i];
  if (!node.key || nextChildrenMap[node.key] == null) {
    container.removeChild(node.el);
  }
}

總結:

我們發現此 diff 算法待優化空間很多:
1. 相同 key 的 patch 和對刪除節點的處理都需要過多的遍歷,
爲了減少時間複雜度。我們使用兩個 map 來降低複雜度。
2. 移動的核心思想是比最大索引值小的統一進行移動。但是在一些情況下,
事實上我們只需要移動最大索引值的節點也可以完成 diff,
且比移動較小值所需要移動的次數減少。
比如舊節點是abc,新節點是cab。
我們事實上只需要移動 c。但是按照現在的 diff,我們需要 a 和 b。

vue2.0 使用的雙端 diff 算法思想

上面的 diff 算法總結到還存在優化空間,會造成不必要的移動 dom。
雙端 diff 的思路剛好可以解決這個問題,移動哪個節點,怎麼移動,由兩個端點一起判斷。
這樣就減少abc→cba 這種新節點頭是舊節點尾的情況。

1. 定義需要的變量

//定義四個變量。四個指針,指向新老節點的開始和結束
let oldStartIdx = 0;
let oldEndIdx = prevChildren.length - 1;
let newStartIdx = 0;
let newEndIdx = nextChildren.length;
比對規則
1. 若oldStartIdx和newStartIdx對應的 vdom 的 key 相同,則 patch prevChildre[oldStartIdx]和nextChildren[newStartIdx],且,oldStartIdx,newStartIdx;繼續循環。
2. 若oldEndIdx和newEndIdx對應的 vdom 的 key 相同,則 patch prevChildre[oldEndIdx]和nextChildren[newEndIdx],且,oldEndIdx—,newEndIdx—;繼續循環。
3. 若oldStartIdx和newEndIdx對應的 vdom 的 key 相同,表示舊的列表的oldStartIdx已經移動到尾部oldEndIdx。先 pach 相應的兩個 vdom。然後將舊的列表的頭部的 dom 移動到oldEndIdx的後面。++oldStartIdx —newEndIdx 繼續循環
4. 若oldEndIdx和newStartIdx對應的 vdom 的 key 相同,表示舊的列表的oldEndIdx已經移動到newStartIdx。patch 萬相應的 vdom。然後將舊的oldEndIdx相應的 dom 放到oldStartIdx前面。—oldEndIdx,++newStartIdx繼續循環
5. 如果開始結尾都沒有相同的 key,則在舊的列表裏面尋找和當前newStartIdx對應的 vdom 相同的 key 的 vdom。如果找不到,則表示要 mount newStartIdx對應的 vdom,然後將對應的 el 插入到oldStartIdx 前面。若找到相同的 key 位置 k,先 patch 對應的 vdom。然後將 k 位置的 vdom 對應的 dom 插入到 oldStartIdx 前面。然後 newStartIdx++繼續循環
//定義四個變量。四個指針,指向新老節點的開始和結束
let oldStartIdx = 0;
let oldEndIdx = prevChildren.length - 1;
let newStartIdx = 0;
let newEndIdx = nextChildren.length;
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
  if (!prevChildren[oldStartIdx]) {
    ++oldStartIdx;
  } else if (!prevChildren[oldEndIdx]) {
    --oldEndIdx;
  } else {
    if (prevChildren[oldStartIdx].key === nextChildren[newStartIdx].key) {
      patch(prevChildren[oldStartIdx], nextChildren[newStartIdx], container);
      ++oldStartIdx;
      ++newStartIdx;
    } else if (prevChildren[oldEndIdx].key === nextChildren[newEndIdx].key) {
      patch(prevChildren[oldEndIdx], nextChildren[newEndIdx], container);
      --oldEndIdx;
      --newEndIdx;
    } else if (prevChildren[oldStartIdx].key === nextChildren[newEndIdx].key) {
      patch(prevChildren[oldStartIdx], nextChildren[newEndIdx], container);
      container.insertBefore(
        prevChildren[oldStartIdx].el,
        prevChildren[oldEndIdx].el.nextSibling
      );
      ++oldStartIdx;
      --newEndIdx;
    } else if (prevChildren[oldEndIdx].key === nextChildren[newStartIdx].key) {
      patch(prevChildren[oldEndIdx], nextChildren[newStartIdx], container);
      container.insertBefore(
        prevChildren[oldEndIdx].el,
        prevChildren[oldStartIdx].el
      );
      --oldEndIdx;
      ++newStartIdx;
    } else {
      const idx = prevChildren.findIndex(
        node => node.key === nextChildren[newStartIdx].key
      );
      if (idx >= 0) {
        const preNode = prevChildren[idx];
        patch(preNode, nextChildren[newStartIdx], container);
        container.insertBefore(preNode.el, prevChildren[oldStartIdx].el);
        prevChildren[idx] = undefined;
      } else {
        //省略mount,mount過後nextChildren[newStartIdx]有了自己的el
        container.insertBefore(nextChildren[newStartIdx].el, oldStartVNode.el);
      }
      ++newStartIdx;
    }
  }
}

增加和刪除節點

1. 若走完循環 oldEndIdx<oldStartIdx且newEndIdx≥newStartIdx, 說明新的列表還沒遍歷萬,將未遍歷完的全部加到oldStartIdx(遍歷萬後的oldStartIdx的位置已經是節點的最後了)的前面
2. 若循環完newEndIdx < newStartIdx 且 oldEndIdx≥oldStartIdx , 說明新的列表遍歷完,舊的沒有遍歷完,此時將舊的未循環完的全部刪除
if (oldEndIdx < oldStartIdx) {
  for (let i = newStartIdx; i <= newEndIdx; i++) {
    //省略mount,mount過後nextChildren[newStartIdx]有了自己的el
    container.insertBefore(nextChildren[i], prevChildren[oldStartIdx].el);
  }
}
if (newEndIdx < newStartIdx) {
  for (let i = oldStartIdx; i <= oldEndIdx; i++) {
    container.removeChild(prevChildren[i].el);
  }
}

總結:

使用雙端比較基本上性能已經很好了,但是還是有可以優化的地方。
比如
eabcf;
gabck;
我們在使用雙端比較的時候,試着走一遍流程,
發現所有的節點都需要操作一遍。
inferno 採用的 diff 算法會使用求一個最長子序列的方法來避免這個問題。

inferno 採用的 diff 算法

看介紹說此算法一開始使用兩個不同文本之間的差異比較。
衆所周知文本差異比較算法使用了動態規劃。
那麼此diff算法基於比較文本差異算法進行變異。
在比較有一個預處理的過程,預處理去除相同的前綴和後綴,
剩下的文本纔是我們需要diff的。
然後我們發現一個問題就是diff兩個文本的差異,到底哪些節點需要移動,
react的算法告訴我們小於當前已知的最大索引值的節點需要移動,
vue的算法告訴我們雙端比較(舊的頭和新的尾,新的尾和舊的頭)可以最小化移動的次數。
那麼最好的判斷是否移動的方式是什麼。其實是遞增子序列

那麼我們看下例子

abc
cab
其實我們發現新的vdom的index數組是[2,0,1],
我們求出遞增子序列,發現我們只需要移動2這個index就行。

預處理相同前綴和後綴

首先前後同時遍歷,patch 相同 key 的前綴和後綴
let j = 0; //表示新舊列表第一個不同key的節點
let prevEnd = prevChildren.length - 1; //舊節點的end
let nextEnd = nextChildren.length - 1; //新節點的end
while (prevChildren[j].key === nextChildren[j].key) {
  patch(prevChildren[j], nextChildren[j], container);
  j++;
}
while (prevChildren[prevEnd].key === nextChildren[nextEnd].key) {
  // 調用 patch 函數更新
  patch(prevChildren[prevEnd], nextChildren[nextEnd], container);
  prevEnd--;
  nextEnd--;
}
以abc和adbc爲例,比對完成 j=1;prevEnd 爲 0,nextEnd爲 1。
以abc和ac爲例,比對完成爲 j=1;preEnd爲 1;nextEnd爲 0。
我們發現:
1. j > prevEnd && j <= nextEnd時候 j~nextEnd之間的節點爲新增的節點
2. j > nextEnd && j≤prevEnd 時候 j~preEnd之間的節點爲刪除的節點
if (j > prevEnd && j <= nextEnd) {
  const nextPos = nextEnd + 1;
  const refNode =
    nextPos < nextChildren.length ? nextChildren[nextPos].el : null;
  while (j <= nextEnd) {
    //省略mount新vdom的過程
    container.insertBefore(nextChildren[j], refNode);
    j++;
  }
} else if (j > nextEnd) {
  while (j <= prevEnd) {
    container.removeChild(prevChildren[j++].el);
  }
}

如何找到需要移動的節點

先構建一個數組 sourse[]
j~nextEnd 的節點是還未處理的新的節點。j~preEnd 的節點是還未處理的舊的節點。
我們遍歷還未處理的舊的節點,如果在新的節點中找不到相應的節點則把相應的節點刪除,

如果找到了,將在新的節點列表找到的節點的 idx-j 作爲 source 的下標,
值爲舊的結點所在的下標。
const nextLeft = nextEnd - j + 1; // 新 children 中剩餘未處理節點的數量
const source = [];
for (let i = 0; i < nextLeft; i++) {
  source.push(-1);
}
const prevStart = j;
const nextStart = j;
let moved = false;
let pos = 0;
// 遍歷舊 children
let patched = 0;
const keyIndex = {};
for (let i = nextStart; i <= nextEnd; i++) {
  keyIndex[nextChildren[i].key] = i;
}
// 遍歷舊 children 的剩餘未處理節點
for (let i = prevStart; i <= prevEnd; i++) {
  prevVNode = prevChildren[i];
  if (patched < nextLeft) {
    // 通過索引錶快速找到新 children 中具有相同 key 的節點的位置
    const k = keyIndex[prevVNode.key];
    if (typeof k !== "undefined") {
      nextVNode = nextChildren[k];
      // patch 更新
      patch(prevVNode, nextVNode, container);
      patched++;
      // 更新 source 數組
      source[k - nextStart] = i;
      // 判斷是否需要移動
      if (k < pos) {
        moved = true;
      } else {
        pos = k;
      }
    } else {
      // 沒找到,說明舊節點在新 children 中已經不存在了,應該移除
      container.removeChild(prevVNode.el);
    }
  } else {
    // 多餘的節點,應該移除
    container.removeChild(prevVNode.el);
  }
}

移動節點

我們求好 source 數組後,事實上我們求這個數組的最大子序列。
然後從後向前遍歷新的節點列表,如果下標在最大子序列裏面,不處理。
如果不在裏面。則將真實的 dom 插入後邊的 dom 前面。直接看代碼
let j = 0;
let prevEnd = prevChildren.length - 1;
let nextEnd = nextChildren.length - 1;
outer: {
  while (prevChildren[j].key === nextChildren[j].key) {
    patch(prevChildren[j], nextChildren[j], container);
    j++;
    if (j > prevEnd || j > nextEnd) {
      break outer;
    }
  }
  while (prevChildren[prevEnd].key === nextChildren[nextEnd].key) {
    // 調用 patch 函數更新
    patch(prevChildren[prevEnd], nextChildren[nextEnd], container);
    prevEnd--;
    nextEnd--;
    if (j > prevEnd || j > nextEnd) {
      break outer;
    }
  }
  // 滿足條件,則說明從 j -> nextEnd 之間的節點應作爲新節點插入
  if (j > prevEnd && j <= nextEnd) {
    // 所有新節點應該插入到位於 nextPos 位置的節點的前面
    const nextPos = nextEnd + 1;
    const refNode =
      nextPos < nextChildren.length ? nextChildren[nextPos].el : null;
    // 採用 while 循環,調用 mount 函數掛載節點
    while (j <= nextEnd) {
      //省略mount新vdom的過程
      container.insertBefore(nextChildren[j], refNode);
      j++;
    }
  } else if (j > nextEnd) {
    // j -> prevEnd 之間的節點應該被移除
    while (j <= prevEnd) {
      container.removeChild(prevChildren[j++].el);
    }
  } else {
    const nextLeft = nextEnd - j + 1; // 新 children 中剩餘未處理節點的數量
    const source = [];
    for (let i = 0; i < nextLeft; i++) {
      source.push(-1);
    }
    const prevStart = j;
    const nextStart = j;
    let moved = false;
    let pos = 0;
    // 遍歷舊 children
    let patched = 0;
    const keyIndex = {};
    for (let i = nextStart; i <= nextEnd; i++) {
      keyIndex[nextChildren[i].key] = i;
    }
    // 遍歷舊 children 的剩餘未處理節點
    for (let i = prevStart; i <= prevEnd; i++) {
      prevVNode = prevChildren[i];
      if (patched < nextLeft) {
        // 通過索引錶快速找到新 children 中具有相同 key 的節點的位置
        const k = keyIndex[prevVNode.key];
        if (typeof k !== "undefined") {
          nextVNode = nextChildren[k];
          // patch 更新
          patch(prevVNode, nextVNode, container);
          patched++;
          // 更新 source 數組
          source[k - nextStart] = i;
          // 判斷是否需要移動
          if (k < pos) {
            moved = true;
          } else {
            pos = k;
          }
        } else {
          // 沒找到,說明舊節點在新 children 中已經不存在了,應該移除
          container.removeChild(prevVNode.el);
        }
      } else {
        // 多餘的節點,應該移除
        container.removeChild(prevVNode.el);
      }
    }
    if (moved) {
      //求最長子序列 過程略過
      const seq = lis(source);
      // j 指向最長遞增子序列的最後一個值
      let j = seq.length - 1;
      // 從後向前遍歷新 children 中的剩餘未處理節點
      for (let i = nextLeft - 1; i >= 0; i--) {
        if (source[i] === -1) {
          // 作爲全新的節點掛載
          // 該節點在新 children 中的真實位置索引
          const pos = i + nextStart;
          const nextVNode = nextChildren[pos];
          // 該節點下一個節點的位置索引
          const nextPos = pos + 1;
          // 掛載
          container.insertBefore(
            nextVNode,
            nextPos < nextChildren.length ? nextChildren[nextPos].el : null
          );
        } else if (i !== seq[j]) {
          // 該節點在新 children 中的真實位置索引
          const pos = i + nextStart;
          const nextVNode = nextChildren[pos];
          // 該節點下一個節點的位置索引
          const nextPos = pos + 1;
          // 移動
          container.insertBefore(
            nextVNode.el,
            nextPos < nextChildren.length ? nextChildren[nextPos].el : null
          );
        } else {
          // 當 i === seq[j] 時,說明該位置的節點不需要移動
          // 並讓 j 指向下一個位置
          j--;
        }
      }
    }
  }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章