大家好,我卡頌。
凡是依賴虛擬DOM
的框架,都需要比較前後節點變化的Diff
演算法。
網上有大量講解Diff
演算法邏輯的文章。然而,即使作者語言再精練,再圖文並茂,相信大部分同學看完用不了多久就忘了。
今天,我們換一種一勞永逸的學習方法 —— 實現React
的核心Diff
演算法。
不難,只有40行程式碼。不信?往下看。
歡迎加入人類高質量前端框架群,帶飛
Diff演算法的設計思路
試想,Diff
演算法需要考慮多少種情況呢?大體分三種,分別是:
- 節點屬性變化,比如:
// 更新前
<ul>
<li key="0" className="before">0</li>
<li key="1">1</li>
</ul>
// 更新後
<ul>
<li key="0" className="after">0</li>
<li key="1">1</li>
</ul>
- 節點增刪,比如:
// 更新前
<ul>
<li key="0">0</li>
<li key="1">1</li>
<li key="2">2</li>
</ul>
// 更新後 情況1 —— 新增節點
<ul>
<li key="0">0</li>
<li key="1">1</li>
<li key="2">2</li>
<li key="3">3</li>
</ul>
// 更新後 情況2 —— 刪除節點
<ul>
<li key="0">0</li>
<li key="1">1</li>
</ul>
- 節點移動,比如:
// 更新前
<ul>
<li key="0">0</li>
<li key="1">1</li>
</ul>
// 更新後
<ul>
<li key="1">1</li>
<li key="0">0</li>
</ul>
該如何設計Diff
演算法呢?考慮到只有以上三種情況,一種常見的設計思路是:
- 首先判斷當前節點屬於哪種情況
- 如果是增刪,執行增刪邏輯
- 如果是屬性變化,執行屬性變化邏輯
- 如果是移動,執行移動邏輯
按這個方案,其實有個隱含的前提—— 不同操作的優先順序是相同的。但在日常開發中,節點移動發生較少,所以Diff
演算法會優先判斷其他情況。
基於這個理念,主流框架(React、Vue)的Diff
演算法都會經歷多輪遍歷,先處理常見情況,後處理不常見情況。
所以,這就要求處理不常見情況的演算法需要能給各種邊界case
兜底。
換句話說,完全可以僅使用處理不常見情況的演算法完成Diff
操作。主流框架之所以沒這麼做是為了效能考慮。
本文會砍掉處理常見情況的演算法,保留處理不常見情況的演算法。
這樣,只需要40行程式碼就能實現Diff
的核心邏輯。
Demo介紹
首先,我們定義虛擬DOM
節點的資料結構:
type Flag = 'Placement' | 'Deletion';
interface Node {
key: string;
flag?: Flag;
index?: number;
}
key
是node
的唯一標識,用於將節點在變化前、變化後關聯上。
flag
代表node
經過Diff
後,需要對相應的真實DOM
執行的操作,其中:
Placement
對於新生成的node
,代表對應DOM
需要插入到頁面中。對於已有的node
,代表對應DOM
需要在頁面中移動Deletion
代表node
對應DOM
需要從頁面中刪除
index
代表該node
在同級node
中的索引位置
注:本Demo
僅實現為node標記flag,沒有實現根據flag執行DOM操作。
我們希望實現的diff
方法,接收更新前
與更新後
的NodeList
,為他們標記flag
:
type NodeList = Node[];
function diff(before: NodeList, after: NodeList): NodeList {
// ...程式碼
}
比如對於:
// 更新前
const before = [
{key: 'a'}
]
// 更新後
const after = [
{key: 'd'}
]
// diff(before, after) 輸出
[
{key: "d", flag: "Placement"},
{key: "a", flag: "Deletion"}
]
{key: "d", flag: "Placement"}
代表d對應DOM
需要插入頁面。
{key: "a", flag: "Deletion"}
代表a對應DOM
需要被刪除。
執行後的結果就是:頁面中的a變為d。
再比如:
// 更新前
const before = [
{key: 'a'},
{key: 'b'},
{key: 'c'},
]
// 更新後
const after = [
{key: 'c'},
{key: 'b'},
{key: 'a'}
]
// diff(before, after) 輸出
[
{key: "b", flag: "Placement"},
{key: "a", flag: "Placement"}
]
由於b
之前已經存在,{key: "b", flag: "Placement"}
代表b對應DOM
需要向後移動(對應parentNode.appendChild
方法)。abc
經過該操作後變為acb
。
由於a
之前已經存在,{key: "a", flag: "Placement"}
代表a對應DOM
需要向後移動。acb
經過該操作後變為cba
。
執行後的結果就是:頁面中的abc變為cba。
Diff演算法實現
核心邏輯包括三步:
- 遍歷前的準備工作
- 遍歷
after
- 遍歷後的收尾工作
function diff(before: NodeList, after: NodeList): NodeList {
const result: NodeList = [];
// ...遍歷前的準備工作
for (let i = 0; i < after.length; i++) {
// ...核心遍歷邏輯
}
// ...遍歷後的收尾工作
return result;
}
遍歷前的準備工作
我們將before
中每個node
儲存在以node.key
為key
,node
為value
的Map
中。
這樣,以O(1)
複雜度就能通過key
找到before
中對應node
:
// 儲存結果
const result: NodeList = [];
// 將before儲存在map中
const beforeMap = new Map<string, Node>();
before.forEach((node, i) => {
node.index = i;
beforeMap.set(node.key, node);
})
遍歷after
當遍歷after
時,如果一個node
同時存在於before
與after
(key
相同),我們稱這個node
可複用。
比如,對於如下例子,b是可複用的:
// 更新前
const before = [
{key: 'a'},
{key: 'b'}
]
// 更新後
const after = [
{key: 'b'}
]
對於可複用的node
,本次更新一定屬於以下兩種情況之一:
- 不移動
- 移動
如何判斷可複用的node
是否移動呢?
我們用lastPlacedIndex
變數儲存遍歷到的最後一個可複用node在before中的index:
// 遍歷到的最後一個可複用node在before中的index
let lastPlacedIndex = 0;
當遍歷after
時,每輪遍歷到的node
,一定是當前遍歷到的所有node
中最靠右的那個。
如果這個node
是可複用的node
,那麼nodeBefore
與lastPlacedIndex
存在兩種關係:
注:nodeBefore
代表該可複用的node
在before
中的對應node
nodeBefore.index < lastPlacedIndex
代表更新前該node
在lastPlacedIndex對應node
左邊。
而更新後該node
不在lastPlacedIndex對應node
左邊(因為他是當前遍歷到的所有node中最靠右的那個)。
這就代表該node
向右移動了,需要標記Placement
。
nodeBefore.index >= lastPlacedIndex
該node
在原地,不需要移動。
// 遍歷到的最後一個可複用node在before中的index
let lastPlacedIndex = 0;
for (let i = 0; i < after.length; i++) {
const afterNode = after[i];
afterNode.index = i;
const beforeNode = beforeMap.get(afterNode.key);
if (beforeNode) {
// 存在可複用node
// 從map中剔除該 可複用node
beforeMap.delete(beforeNode.key);
const oldIndex = beforeNode.index as number;
// 核心判斷邏輯
if (oldIndex < lastPlacedIndex) {
// 移動
afterNode.flag = 'Placement';
result.push(afterNode);
continue;
} else {
// 不移動
lastPlacedIndex = oldIndex;
}
} else {
// 不存在可複用node,這是一個新節點
afterNode.flag = 'Placement';
result.push(afterNode);
}
遍歷後的收尾工作
經過遍歷,如果beforeMap
中還剩下node
,代表這些node
沒法複用,需要被標記刪除。
比如如下情況,遍歷完after
後,beforeMap
中還剩下{key: 'a'}
:
// 更新前
const before = [
{key: 'a'},
{key: 'b'}
]
// 更新後
const after = [
{key: 'b'}
]
這意味著a
需要被標記刪除。
所以,最後還需要加入標記刪除的邏輯:
beforeMap.forEach(node => {
node.flag = 'Deletion';
result.push(node);
});
完整程式碼見線上Demo地址
總結
整個Diff
演算法的難點在於lastPlacedIndex
相關邏輯。
跟著Demo
多除錯幾遍,相信你能明白其中原理。