首先我們知道 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--;
}
}
}
}
}