Vue3 – 效能與最佳化:Virtual DOM Diff 原理
簡介
在前端框架中,**Virtual DOM(虛擬 DOM)**是提升渲染效能的關鍵技術。Vue 3 透過對 Virtual DOM 的 diff(差異比對)演算法,僅在需要的地方更新真實 DOM,從而大幅降低重排(reflow)與重繪(repaint)的成本。
對於開發中大型 SPA(單頁應用)或列表渲染量巨大的情況,理解 diff 的底層運作不僅能幫助你寫出更 高效、可維護 的程式碼,也能在效能瓶頸出現時快速定位問題。
本篇文章將從 概念、實作細節、常見陷阱 以及 最佳實踐 四個面向,深入剖析 Vue 3 Virtual DOM diff 的原理,並提供實用的程式碼範例,協助你在日常開發中即時應用。
核心概念
1. Virtual DOM 是什麼?
Virtual DOM 是一棵用 JavaScript 物件描述的樹狀結構,代表了真實 DOM 的抽象快照。在每一次資料變更(state update)時,Vue 會:
- 產生一個新的 Virtual DOM 樹(newVNode)。
- 與上一次的 Virtual DOM 樹(oldVNode)進行 diff。
- 計算出最小化的變更集合(patch),最後只把這些變更套用到真實 DOM。
重點:diff 的目標是「最小化實際 DOM 操作」,因為 DOM 操作是瀏覽器最耗時的工作。
2. Diff 演算法的三大原則
Vue 3 採用了 雙端比較(双指针)與 同層比對 的策略,核心原則如下:
| 原則 | 說明 |
|---|---|
| 同層比對 | 只在同一層級的子節點之間做 diff,避免跨層級的遍歷。 |
| 鍵值 (key) 優先 | 若子節點擁有 key,Vue 會先以 key 建立映射表,快速定位搬移或刪除的節點。 |
| 最小化搬移 | 透過「Longest Increasing Subsequence(LIS)」演算法,找出不需要搬移的子序列,僅搬移其餘節點。 |
這三個原則讓 diff 從 O(N²) 降到 O(N)(在大多數實務情境下),大幅提升效能。
3. Diff 流程概述
以下以「列表渲染」為例,說明 Vue 3 diff 的完整流程:
建立映射表(key → index)
若子節點有key,Vue 先把舊節點的key與索引建立映射表oldKeyToIdx。正向遍歷新節點
- 從左到右遍歷,若新節點的
key在舊映射表中找不到,直接 插入;若找到則 更新(比較屬性、子節點)。
- 從左到右遍歷,若新節點的
逆向遍歷新節點
- 從右到左遍歷,處理尾端的搬移與刪除情況。
計算 LIS
- 針對需要搬移的節點,利用 LIS 找出「已經在正確順序」的子序列,僅搬移其餘節點。
產生 Patch
- 最終產生的 patch 包含
insert,move,remove,update四種操作,依序執行於真實 DOM。
- 最終產生的 patch 包含
小技巧:若列表 不提供
key,Vue 只能依序比對,效能會退化到 O(N²)。因此 務必在v-for中使用唯一且穩定的key。
4. 何時會觸發 Diff?
| 觸發時機 | 說明 |
|---|---|
| 響應式資料變更 | 透過 ref、reactive、computed 等 API 產生的變更。 |
| 父組件重新渲染 | 父組件的 VNode 改變時,子組件的 VNode 也會重新比對。 |
手動呼叫 forceUpdate |
強制重新渲染,雖不常用但可在特殊需求時使用。 |
程式碼範例
以下示範 Vue 3 中常見的 diff 操作,從最簡單的列表到進階的自訂渲染函式。
範例 1:基本 v-for + key
<template>
<ul>
<!-- 使用唯一的 id 作為 key -->
<li v-for="item in items" :key="item.id">
{{ item.name }}
</li>
</ul>
<button @click="shuffle">隨機排序</button>
</template>
<script setup>
import { ref } from 'vue'
const items = ref([
{ id: 1, name: '蘋果' },
{ id: 2, name: '香蕉' },
{ id: 3, name: '橘子' },
])
function shuffle() {
// 隨機改變陣列順序
items.value = items.value
.map(i => ({ ...i, sort: Math.random() }))
.sort((a, b) => a.sort - b.sort)
}
</script>
說明
key讓 Vue 能以id為基礎快速定位每一筆資料。- 按下「隨機排序」時,只有搬移操作會被執行,DOM 節點不會被重新建立。
範例 2:缺少 key 時的效能退化
<template>
<ul>
<!-- 故意省略 key -->
<li v-for="item in items">{{ item }}</li>
</ul>
<button @click="reverse">反向排序</button>
</template>
<script setup>
import { ref } from 'vue'
const items = ref([1, 2, 3, 4, 5])
function reverse() {
items.value = [...items.value].reverse()
}
</script>
觀察
- 沒有
key,Vue 只能逐一比較舊與新節點,導致 整個列表全部被重新建立(即使只反向)。 - 在大型列表(千筆以上)時,會產生明顯卡頓。
範例 3:使用 v-memo 減少不必要的子樹比對
<template>
<div>
<!-- 只在 `filter` 改變時重新渲染子樹 -->
<ItemList :items="filteredItems" v-memo="[filter]" />
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import ItemList from './ItemList.vue'
const rawItems = ref([...]) // 大量資料
const filter = ref('')
// 計算過濾後的結果
const filteredItems = computed(() => {
return rawItems.value.filter(i => i.type === filter.value)
})
</script>
說明
v-memo會把子組件的 VNode 樹快取,只有當filter改變時才重新 diff。- 適用於 計算成本高、但 變化頻率低 的子樹。
範例 4:自訂渲染函式(Render Function)與 h 的 diff
import { h, defineComponent } from 'vue'
export default defineComponent({
props: {
items: {
type: Array,
required: true,
},
},
render() {
// 手寫 VNode,仍然受 diff 演算法影響
return h(
'ul',
this.items.map(item =>
h('li', { key: item.id }, item.label)
)
)
},
})
重點
- 即使使用 render function,只要提供
key,Vue 仍會走同樣的 diff 流程。 - 這種寫法在需要高度自訂的 UI(如圖形庫)時非常有用。
範例 5:手動觸發 forceUpdate(不建議常用)
import { defineComponent, ref } from 'vue'
export default defineComponent({
setup() {
const count = ref(0)
function inc() {
count.value++
// 強制重新渲染整個組件
// 注意:會跳過 diff,直接重新生成 VNode
// 只在極端需求時使用
// this.$forceUpdate()
}
return { count, inc }
},
template: `<button @click="inc">Count: {{ count }}</button>`,
})
提醒
forceUpdate會 略過 diff,直接重新渲染,可能造成效能倒退。- 僅在 第三方庫無法觸發響應式更新 時才使用。
常見陷阱與最佳實踐
| 陷阱 | 可能的結果 | 解決方案 |
|---|---|---|
忘記在 v-for 中使用唯一 key |
效能急遽下降、DOM 重新建立、狀態遺失(例如輸入框內容被清除) | 永遠 為每個迭代項目提供穩定且唯一的 key(如資料庫 ID)。 |
使用索引 (index) 作為 key |
當資料順序變動時,搬移會被誤判為刪除與新增,導致不必要的重繪 | 只在 靜態、永不改變順序 的列表中使用索引。 |
| 在大型列表中直接渲染全部項目 | 初始渲染與更新都會觸發大量 diff,造成卡頓 | 使用 虛擬滾動(virtual scrolling)、v-show + v-if 或分頁技術。 |
在同一層級大量嵌套條件渲染 (v-if/v-else) |
每次條件改變都會重新建立子樹,diff 成本上升 | 用 <keep-alive> 包住不常變動的組件,或將條件抽離成子組件。 |
過度依賴 forceUpdate |
失去 Vue 的 diff 優勢,導致不必要的全量渲染 | 盡量使用 響應式 API(ref、reactive)或 watch 觸發更新。 |
最佳實踐清單
- 為
v-for明確設定key,且key必須是 唯一且不變 的值。 - 將大列表拆分或使用虛擬滾動(如
vue-virtual-scroller)減少一次性渲染的節點數。 - 利用
v-memo、<keep-alive>緩存不常變動的子樹,降低 diff 次數。 - 在計算屬性或 watch 中做資料前置處理,避免在模板內做大量運算。
- 適時使用
shallowRef/shallowReactive,避免不必要的深層追蹤,減少 diff 時的屬性遍歷。
實際應用場景
| 場景 | 為何需要關注 Diff | 建議做法 |
|---|---|---|
| 商品列表(數千筆) | 滾動時頻繁更新,若每筆都重新渲染會卡頓 | 使用 key + 虛擬滾動(vue-virtual-scroller),只渲染可視區域的項目。 |
| 即時聊天訊息 | 新訊息不斷插入列表頭或尾,需快速搬移 | 為訊息設定 id 為 key,使用 v-memo 緩存訊息子樹,避免每條訊息重新 diff。 |
| 表單編輯器(大量輸入框) | 使用者編輯時會觸發大量狀態變更 | 為每個欄位使用 key,並將不變的 UI(如說明文字)抽成子組件或 v-memo。 |
| 圖形化儀表板(圖表、圖形) | 每秒更新一次資料,圖表需要重新渲染 | 把圖表封裝為獨立組件,使用 shallowRef 傳入資料,讓 Vue 只在資料指標變更時重新 diff。 |
| 多語系切換 | 切換語系會導致大量文字內容更新 | 使用 v-memo 依語系快取已渲染的 VNode,減少文字比對成本。 |
總結
Virtual DOM diff 是 Vue 3 效能優化的核心。透過 同層比對、key 映射、LIS 搬移最小化 三大原則,Vue 能在 O(N) 的時間內完成大量節點的差異計算。
在實務開發中,正確使用 key、避免不必要的全量渲染、善用 v-memo 與虛擬滾動,即可讓應用在資料變更頻繁或列表龐大的情況下仍保持流暢。
只要掌握上述概念與最佳實踐,你就能在 Vue 3 專案中自信地處理任何效能挑戰,寫出既易讀又高效的程式碼。祝開發順利,效能無懈可擊! 🚀