跳至主要內容
Skip to content

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,而是替換。

typescript
function isSameVNode(a, b) {
  return a.key === b.key && a.tag === b.tag
}

上面只是面試理解版,實際源碼判斷更細。

2. 四個指標

Vue 2 比較新舊 children 時,會同時維護四個指標:

指標意義
oldStartIdx舊列表頭
oldEndIdx舊列表尾
newStartIdx新列表頭
newEndIdx新列表尾

假設:

text
old: A B C D
new: B C D A

Diff 會從頭尾兩端開始嘗試匹配,而不是只從左到右傻傻比較。

3. 四種快速命中

Vue 2 的雙端比較會優先檢查四種情況:

比較方式命中代表
old 頭 vs new 頭節點位置不變,直接 patch
old 尾 vs new 尾尾部節點不變,直接 patch
old 頭 vs new 尾舊頭節點移到右邊
old 尾 vs new 頭舊尾節點移到左邊
text
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 去舊列表裡找可復用節點。

text
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 會先從左往右比較相同前綴:

text
old: A B C D E
new: A B F D E

A、B 相同,先 patch 掉
中間差異留到後面處理

2. 再同步尾部

接著從右往左比較相同後綴:

text
old: A B C D E
new: A B F D E

D、E 相同,也先 patch 掉
剩下 C vs F

這樣可以快速縮小真正需要處理的未知區間。

3. 處理純新增與純刪除

如果同步頭尾後,舊 children 已經走完,代表中間剩下的新節點都是新增。

text
old: A B
new: A B C D

C、D 直接 mount

如果新 children 已經走完,代表舊節點剩下的都要移除。

text
old: A B C D
new: A B

C、D 直接 unmount

4. 處理未知區間

真正困難的是新舊列表中間都有剩餘,而且順序可能變了:

text
old: A B C D E
new: A C B F E

頭部 A 相同
尾部 E 相同
未知區間:
old: B C D
new: C B F

Vue 3 會對新 children 的未知區間建立 key 到 newIndex 的 map,然後掃描舊 children:

  • 舊節點在新列表找不到,移除。
  • 找得到,就 patch,並記錄「新位置對應到舊位置」。
  • 新列表裡完全沒有對應舊節點的位置,之後 mount。

5. 用最長遞增子序列減少移動

Vue 3 keyed diff 最常被問到的重點是 LIS,也就是 Longest Increasing Subsequence,最長遞增子序列。

它解決的問題是:哪些節點雖然在未知區間裡,但相對順序其實不用動?

例子:

text
old: A B C D E
new: A C B D E

中間 B C D 變成 C B D。假設記錄新節點對應的舊 index,可能得到:

text
new unknown: C B D
old index:   2 1 3

2, 3 是遞增的,代表 CD 的相對順序可保留,只需要移動 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.xVue 3.x
children Diff 核心雙端比較頭尾同步 + 未知區間 + LIS
移動優化透過四端命中與 key map透過 LIS 減少不必要移動
編譯期資訊較少Patch Flags、Block Tree、靜態提升
靜態節點處理有 static tree reuse更系統化地跳過靜態內容
對 key 的依賴很重要仍然很重要
面試關鍵字sameVnodeupdateChildren、雙端比較patchKeyedChildrengetSequence、Patch Flags

簡化來說:

  • Vue 2 的 Diff 重點是「用四個指標處理常見列表變化」。
  • Vue 3 的 Diff 重點是「先縮小差異區間,再用 LIS 降低移動成本」。
  • Vue 3 的整體渲染效率還依賴編譯期標記,不只是 Diff 算法本身。

五、 追問題庫

Q1:為什麼列表一定要寫穩定的 key

key 是 VNode 身份的提示。沒有穩定 key,Vue 只能用就地復用策略,可能導致狀態錯位。

例如表單列表使用 index 當 key:

vue
<li v-for="(item, index) in list" :key="index">
  <input v-model="item.name" />
</li>

如果中間插入一筆資料,後面節點的 index 都會變。Vue 可能復用錯 DOM 或組件實例,造成輸入框狀態對不上資料。

比較好的做法:

vue
<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 只是身份提示,不代表節點類型一定相同。

如果舊節點是:

vue
<div :key="item.id" />

新節點變成:

vue
<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 陣列計算更值得優化。


六、 實作題

題目:解釋下面列表更新會發生什麼

text
old: A B C D
new: B A D C

如果使用 Vue 2 的雙端比較,可以這樣思考:

  1. 頭頭:A vs B,不同。
  2. 尾尾:D vs C,不同。
  3. 舊頭 vs 新尾:A vs C,不同。
  4. 舊尾 vs 新頭:D vs B,不同。
  5. 建立 key map,用新頭 B 去舊列表找。
  6. 找到 B,patch B,並把 B 移到目前舊頭 A 前面。
  7. 繼續處理 A、D、C。

如果使用 Vue 3 的思路:

  1. 頭部沒有相同前綴。
  2. 尾部沒有相同後綴。
  3. 進入未知區間。
  4. 建立 new key 到 index 的 map。
  5. 掃描 old,記錄新位置對應舊位置。
  6. 計算 LIS,保留相對順序已經穩定的節點。
  7. 從右往左 mount 或 move 需要處理的節點。

面試時不需要手推每一步 DOM API,但要能講出兩個版本的核心差異:Vue 2 靠雙端命中與 key map,Vue 3 會額外用 LIS 減少移動。


七、 資深視角

1. Diff 問題最後都會回到資料建模

很多 Diff 問題不是演算法救不了,而是資料本身沒有穩定身份。

例如:

  • 後端沒有提供唯一 id。
  • 前端用 index 當 key。
  • 同一筆資料在不同列表中被複製成不同物件。
  • 拖拉排序時只改 DOM,不回寫資料順序。

這些都會讓渲染器很難判斷節點身份。

2. 不要為了 Diff 過度手動優化

看到列表就想手動操作 DOM,通常不是好方向。更好的優化順序是:

  1. 確認 key 穩定。
  2. 降低不必要的響應式依賴。
  3. 拆分組件邊界。
  4. 使用虛擬列表處理超大資料量。
  5. 再考慮 v-memoshallowRef、手寫 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 怎麼 DiffupdateChildren 使用雙端比較與 key map
Vue 3 怎麼 DiffpatchKeyedChildren 先同步頭尾,再處理未知區間,透過 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 和靜態提升,把很多判斷提前到編譯期,所以整體渲染效率更好。

延伸閱讀