本文 AI 產出,尚未審核

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 會:

  1. 產生一個新的 Virtual DOM 樹(newVNode)。
  2. 與上一次的 Virtual DOM 樹(oldVNode)進行 diff
  3. 計算出最小化的變更集合(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 的完整流程:

  1. 建立映射表(key → index)
    若子節點有 key,Vue 先把舊節點的 key 與索引建立映射表 oldKeyToIdx

  2. 正向遍歷新節點

    • 從左到右遍歷,若新節點的 key 在舊映射表中找不到,直接 插入;若找到則 更新(比較屬性、子節點)。
  3. 逆向遍歷新節點

    • 從右到左遍歷,處理尾端的搬移與刪除情況。
  4. 計算 LIS

    • 針對需要搬移的節點,利用 LIS 找出「已經在正確順序」的子序列,僅搬移其餘節點。
  5. 產生 Patch

    • 最終產生的 patch 包含 insert, move, remove, update 四種操作,依序執行於真實 DOM。

小技巧:若列表 不提供 key,Vue 只能依序比對,效能會退化到 O(N²)。因此 務必在 v-for 中使用唯一且穩定的 key

4. 何時會觸發 Diff?

觸發時機 說明
響應式資料變更 透過 refreactivecomputed 等 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 優勢,導致不必要的全量渲染 盡量使用 響應式 APIrefreactive)或 watch 觸發更新。

最佳實踐清單

  1. v-for 明確設定 key,且 key 必須是 唯一且不變 的值。
  2. 將大列表拆分或使用虛擬滾動(如 vue-virtual-scroller)減少一次性渲染的節點數。
  3. 利用 v-memo<keep-alive> 緩存不常變動的子樹,降低 diff 次數。
  4. 在計算屬性或 watch 中做資料前置處理,避免在模板內做大量運算。
  5. 適時使用 shallowRef/shallowReactive,避免不必要的深層追蹤,減少 diff 時的屬性遍歷。

實際應用場景

場景 為何需要關注 Diff 建議做法
商品列表(數千筆) 滾動時頻繁更新,若每筆都重新渲染會卡頓 使用 key + 虛擬滾動(vue-virtual-scroller),只渲染可視區域的項目。
即時聊天訊息 新訊息不斷插入列表頭或尾,需快速搬移 為訊息設定 idkey,使用 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 專案中自信地處理任何效能挑戰,寫出既易讀高效的程式碼。祝開發順利,效能無懈可擊! 🚀