Vue 2.x 與 Vue 3.x 渲染器 Diff 算法
先釐清一個名詞:Vuex 是狀態管理工具,沒有渲染器 Diff 算法。面試中如果有人說「Vuex 3.x 的 Diff」,通常是在問 Vue 3.x,或是把 Vue 與 Vuex 口誤混在一起。
這篇會比較 Vue 2.x 與 Vue 3.x 的渲染器 Diff。重點不是背源碼函式名稱,而是理解:Vue 如何用盡量少的 DOM 操作,把舊 VNode 樹更新成新 VNode 樹。
如果你還不熟「DOM 操作為什麼貴」,建議先讀:瀏覽器渲染管線與 DOM 操作成本。
一、 必考觀念
1. Diff 算法在 Vue 裡負責什麼?
Vue 的更新流程可以簡化成:
Diff 算法負責的是中間這段:比較舊 VNode 與新 VNode,找出哪些節點可以復用、哪些要新增、哪些要刪除、哪些要移動。
2. 為什麼不能直接重新渲染整棵 DOM?
因為 DOM 操作很貴。
如果每次狀態變更都把整個 DOM 子樹刪掉重建,會有幾個問題:
- 造成大量不必要的 DOM 建立與銷毀。
- 表單輸入、焦點、滾動位置可能被重置。
- 組件實例狀態可能無法正確保留。
- 大列表更新時效能會非常差。
所以 Diff 的核心目標不是「比較得最完美」,而是在常見 UI 更新場景下,用合理成本找出足夠好的更新方案。
3. Vue 的 Diff 有哪些重要前提?
Vue 不做任意樹之間的完整最小編輯距離比較,因為那種通用樹 Diff 成本太高。Vue 採用的是適合前端 UI 的啟發式策略:
- 只在同層 children 之間比較,不做跨層級任意匹配。
- 如果兩個 VNode 不是同類節點,直接替換。
key是列表節點身份的重要提示。- 編譯器能提供資訊時,執行期就少做猜測。
用面試話術說:Vue 的 Diff 是針對 UI 更新特徵設計的 O(n) 或接近 O(n) 策略,不追求通用樹 Diff 的理論最小操作數。
二、 Vue 2.x:雙端比較
Vue 2 的核心 children Diff 在 updateChildren 裡,主要是雙端比較,也常被稱為 double-ended diff。
1. 先判斷是不是同一個 VNode
Vue 2 會用類似 sameVnode 的判斷來決定兩個節點能不能復用。大致會看:
key是否相同。tag是否相同。- 是否同為註解節點。
- data 是否同時存在或同時不存在。
input類型是否相容。
如果不是同一類節點,就不會硬 patch,而是替換。
function isSameVNode(a, b) {
return a.key === b.key && a.tag === b.tag
}上面只是面試理解版,實際源碼判斷更細。
2. 四個指標
Vue 2 比較新舊 children 時,會同時維護四個指標:
| 指標 | 意義 |
|---|---|
oldStartIdx | 舊列表頭 |
oldEndIdx | 舊列表尾 |
newStartIdx | 新列表頭 |
newEndIdx | 新列表尾 |
假設:
old: A B C D
new: B C D ADiff 會從頭尾兩端開始嘗試匹配,而不是只從左到右傻傻比較。
3. 四種快速命中
Vue 2 的雙端比較會優先檢查四種情況:
| 比較方式 | 命中代表 |
|---|---|
| old 頭 vs new 頭 | 節點位置不變,直接 patch |
| old 尾 vs new 尾 | 尾部節點不變,直接 patch |
| old 頭 vs new 尾 | 舊頭節點移到右邊 |
| old 尾 vs new 頭 | 舊尾節點移到左邊 |
old: A B C D
new: D A B C
oldEnd D === newStart D
=> patch D,並把 D 的 DOM 移到 oldStart A 前面這對常見的頭尾插入、尾部追加、前後移動很有效。
4. key map 查找
如果四種快速命中都失敗,Vue 2 會建立舊 children 的 key 到 index 的對照表,嘗試用新頭節點的 key 去舊列表裡找可復用節點。
old: A B C D
new: C A B D
newStart = C
四端比較沒有命中
用 key 找到 old 裡的 C
=> patch C,並把 C 移到目前 oldStart 前面找不到就建立新節點;找到但不是同類節點,也會當新節點處理。
5. 收尾:新增或刪除剩餘節點
當其中一邊走完後:
- 如果舊列表先走完,代表新列表還有剩下節點,要新增。
- 如果新列表先走完,代表舊列表還有剩下節點,要刪除。
這就是 Vue 2 children Diff 的大致骨架。
三、 Vue 3.x:快速 Diff 與 LIS
Vue 3 的核心 keyed children Diff 在 patchKeyedChildren 裡。它保留了「同層比較」與「key 決定身份」的核心思想,但整體策略更系統化,並且會結合編譯期資訊減少執行期成本。
1. 先同步頭部
Vue 3 會先從左往右比較相同前綴:
old: A B C D E
new: A B F D E
A、B 相同,先 patch 掉
中間差異留到後面處理2. 再同步尾部
接著從右往左比較相同後綴:
old: A B C D E
new: A B F D E
D、E 相同,也先 patch 掉
剩下 C vs F這樣可以快速縮小真正需要處理的未知區間。
3. 處理純新增與純刪除
如果同步頭尾後,舊 children 已經走完,代表中間剩下的新節點都是新增。
old: A B
new: A B C D
C、D 直接 mount如果新 children 已經走完,代表舊節點剩下的都要移除。
old: A B C D
new: A B
C、D 直接 unmount4. 處理未知區間
真正困難的是新舊列表中間都有剩餘,而且順序可能變了:
old: A B C D E
new: A C B F E
頭部 A 相同
尾部 E 相同
未知區間:
old: B C D
new: C B FVue 3 會對新 children 的未知區間建立 key 到 newIndex 的 map,然後掃描舊 children:
- 舊節點在新列表找不到,移除。
- 找得到,就 patch,並記錄「新位置對應到舊位置」。
- 新列表裡完全沒有對應舊節點的位置,之後 mount。
5. 用最長遞增子序列減少移動
Vue 3 keyed diff 最常被問到的重點是 LIS,也就是 Longest Increasing Subsequence,最長遞增子序列。
它解決的問題是:哪些節點雖然在未知區間裡,但相對順序其實不用動?
例子:
old: A B C D E
new: A C B D E中間 B C D 變成 C B D。假設記錄新節點對應的舊 index,可能得到:
new unknown: C B D
old index: 2 1 32, 3 是遞增的,代表 C 和 D 的相對順序可保留,只需要移動 B。
如果不用 LIS,可能會移動太多節點;用了 LIS,Vue 可以保留最長的一段穩定順序,只移動不在序列裡的節點。
6. Vue 3 還有編譯期優化
Vue 3 不只改了 children Diff,還把很多資訊提前交給編譯器處理。
常見概念有:
| 概念 | 作用 |
|---|---|
| Patch Flags | 標記哪些部分是動態的,例如 class、style、props、text |
| Block Tree | 收集動態節點,更新時跳過大量靜態節點 |
| hoistStatic | 靜態節點提升,避免每次 render 都重新建立 |
| dynamicChildren | 讓 runtime 只 patch 動態子節點 |
所以 Vue 3 的效能提升不只來自 LIS,也來自「編譯器告訴渲染器哪些地方需要關心」。
四、 Vue 2 與 Vue 3 對比
| 面向 | Vue 2.x | Vue 3.x |
|---|---|---|
| children Diff 核心 | 雙端比較 | 頭尾同步 + 未知區間 + LIS |
| 移動優化 | 透過四端命中與 key map | 透過 LIS 減少不必要移動 |
| 編譯期資訊 | 較少 | Patch Flags、Block Tree、靜態提升 |
| 靜態節點處理 | 有 static tree reuse | 更系統化地跳過靜態內容 |
| 對 key 的依賴 | 很重要 | 仍然很重要 |
| 面試關鍵字 | sameVnode、updateChildren、雙端比較 | patchKeyedChildren、getSequence、Patch Flags |
簡化來說:
- Vue 2 的 Diff 重點是「用四個指標處理常見列表變化」。
- Vue 3 的 Diff 重點是「先縮小差異區間,再用 LIS 降低移動成本」。
- Vue 3 的整體渲染效率還依賴編譯期標記,不只是 Diff 算法本身。
五、 追問題庫
Q1:為什麼列表一定要寫穩定的 key?
key 是 VNode 身份的提示。沒有穩定 key,Vue 只能用就地復用策略,可能導致狀態錯位。
例如表單列表使用 index 當 key:
<li v-for="(item, index) in list" :key="index">
<input v-model="item.name" />
</li>如果中間插入一筆資料,後面節點的 index 都會變。Vue 可能復用錯 DOM 或組件實例,造成輸入框狀態對不上資料。
比較好的做法:
<li v-for="item in list" :key="item.id">
<input v-model="item.name" />
</li>Q2:Vue 的 Diff 會跨層級移動節點嗎?
不會。
Vue 主要做同層 children 的比較。如果節點跨層級移動,通常會被視為舊位置刪除、新位置新增。這是為了控制演算法成本,也符合大部分 UI 更新場景。
Q3:為什麼相同 key 但不同 tag 不能復用?
因為 key 只是身份提示,不代表節點類型一定相同。
如果舊節點是:
<div :key="item.id" />新節點變成:
<span :key="item.id" />即使 key 相同,DOM tag 不同,也應該替換,而不是硬 patch 成同一個元素。
Q4:Vue 3 有 LIS,所以一定比 Vue 2 快嗎?
不能這樣簡化。
LIS 主要優化的是 keyed children reorder 的移動次數。Vue 3 的整體更新更快,還有很大一部分來自編譯期優化,例如 Patch Flags、Block Tree、靜態提升。
如果一個頁面沒有大量列表重排,LIS 不一定是主要效能來源。
Q5:Diff 算法的時間複雜度是多少?
Vue 的 children Diff 在常見情況下是 O(n) 或接近 O(n)。Vue 3 的未知區間會計算 LIS,常見實作是 O(n log n),但它換來的是更少 DOM 移動。
要注意:時間複雜度不是唯一指標。瀏覽器 DOM move、mount、unmount 的成本,常常比純 JavaScript 陣列計算更值得優化。
六、 實作題
題目:解釋下面列表更新會發生什麼
old: A B C D
new: B A D C如果使用 Vue 2 的雙端比較,可以這樣思考:
- 頭頭:A vs B,不同。
- 尾尾:D vs C,不同。
- 舊頭 vs 新尾:A vs C,不同。
- 舊尾 vs 新頭:D vs B,不同。
- 建立 key map,用新頭 B 去舊列表找。
- 找到 B,patch B,並把 B 移到目前舊頭 A 前面。
- 繼續處理 A、D、C。
如果使用 Vue 3 的思路:
- 頭部沒有相同前綴。
- 尾部沒有相同後綴。
- 進入未知區間。
- 建立 new key 到 index 的 map。
- 掃描 old,記錄新位置對應舊位置。
- 計算 LIS,保留相對順序已經穩定的節點。
- 從右往左 mount 或 move 需要處理的節點。
面試時不需要手推每一步 DOM API,但要能講出兩個版本的核心差異:Vue 2 靠雙端命中與 key map,Vue 3 會額外用 LIS 減少移動。
七、 資深視角
1. Diff 問題最後都會回到資料建模
很多 Diff 問題不是演算法救不了,而是資料本身沒有穩定身份。
例如:
- 後端沒有提供唯一 id。
- 前端用 index 當 key。
- 同一筆資料在不同列表中被複製成不同物件。
- 拖拉排序時只改 DOM,不回寫資料順序。
這些都會讓渲染器很難判斷節點身份。
2. 不要為了 Diff 過度手動優化
看到列表就想手動操作 DOM,通常不是好方向。更好的優化順序是:
- 確認 key 穩定。
- 降低不必要的響應式依賴。
- 拆分組件邊界。
- 使用虛擬列表處理超大資料量。
- 再考慮
v-memo、shallowRef、手寫 render function 等手段。
3. Vue 3 的優勢是編譯器與 runtime 合作
資深回答不要只停在「Vue 3 用 LIS」。更完整的說法是:Vue 3 的渲染優化來自兩層合作。
- 編譯期:標記動態節點,提升靜態節點,產生 block tree。
- 執行期:根據 patch flag 與 children 類型走更精準的 patch 分支。
這代表 Vue 3 不只是把 Diff 算法換掉,而是讓整個渲染管線更知道「什麼需要更新,什麼可以跳過」。
總結
| 問題 | 面試回答重點 |
|---|---|
| Diff 是什麼 | 比較新舊 VNode,最小化真實 DOM 更新成本 |
| Vue 2 怎麼 Diff | updateChildren 使用雙端比較與 key map |
| Vue 3 怎麼 Diff | patchKeyedChildren 先同步頭尾,再處理未知區間,透過 LIS 減少移動 |
| key 的作用 | 提供節點身份,避免錯誤復用 |
| Vue 3 為何更快 | 不只是 LIS,還有 Patch Flags、Block Tree、靜態提升 |
一句完整的面試回答可以是:
Vue 的 Diff 是同層 VNode 比較,用 key 判斷節點身份,目標是減少真實 DOM 操作。Vue 2 的 children Diff 主要靠雙端比較,透過舊頭、新頭、舊尾、新尾四個指標處理常見移動;Vue 3 則先同步相同前綴與後綴,再處理中間未知區間,並用最長遞增子序列減少 DOM move。除此之外,Vue 3 還透過 Patch Flags、Block Tree 和靜態提升,把很多判斷提前到編譯期,所以整體渲染效率更好。