跳至主要內容
Skip to content

Vue 響應式性能優化

Vue 的響應式系統讓資料變化可以自動驅動畫面更新,但響應式不是免費的。資料越大、層級越深、依賴越多、更新越頻繁,依賴收集與觸發更新的成本就越容易變成性能瓶頸。

一句話回答:Vue 響應式性能優化的核心,是不要把所有資料都做成深層響應式,也不要讓一次狀態變化牽動過大的依賴範圍;常見手段包含使用 shallowRefshallowReactivemarkRaw,避免不必要的 deep watch,合理區分 computed 與 watch,拆分狀態,並讓大型資料走分頁、索引或後端查詢。


一、 必考觀念

1. 響應式成本來自哪裡?

Vue 的響應式大致可以拆成兩件事:

text
讀取資料:收集依賴
修改資料:觸發依賴更新

以 Vue 3 的 Proxy 思路簡化理解:

typescript
const state = reactive({
  count: 0,
})

effect(() => {
  console.log(state.count)
})

state.count++

當 effect 裡讀取 state.count,Vue 會記錄「這段副作用依賴 count」。當 state.count 被修改,Vue 就能找到相關 effect 並安排更新。

這套機制很好用,但成本會出現在:

  • 深層物件需要被代理。
  • 大量屬性被讀取時會收集大量依賴。
  • 大量 effect、computed、watch 同時依賴同一份資料。
  • 一次更新觸發過大的組件子樹。
  • deep watch 需要遍歷整個物件。

2. Vue 2 和 Vue 3 的差異

Vue 2 主要使用 Object.defineProperty 攔截物件屬性的 getter / setter。

限制是:

  • 初始化時要走訪物件屬性。
  • 新增屬性、刪除屬性需要特別處理。
  • 陣列變更需要攔截部分方法。

Vue 3 改用 Proxy,可以攔截更多操作:

  • 新增屬性。
  • 刪除屬性。
  • in
  • ownKeys
  • Map / Set 等集合型資料。

但這不代表 Vue 3 沒有成本。大型深層資料只要被大量讀取、追蹤或遍歷,仍然會有性能壓力。

面試時可以這樣說:

Vue 3 的 Proxy 讓響應式能力更完整,也避免 Vue 2 新增屬性不響應等限制;但響應式追蹤本身仍有成本,尤其是大型深層物件、deep watch、頻繁更新的大列表,都需要控制依賴範圍。

3. 響應式性能和渲染性能是連在一起的

響應式資料變了,最後通常會導致組件更新。

text
資料變化
-> trigger effect
-> 組件重新 render
-> VNode Diff
-> DOM patch

所以頁面卡頓時,不一定是 DOM 太多,也可能是響應式資料設計太粗。

例如:

  • 整個頁面共用一個巨大 reactive store。
  • 任意欄位變化都讓很多組件依賴被觸發。
  • 每個 row 都 watch 同一個大物件。
  • deep watch 一份幾萬筆列表。
  • computed 裡每次都 sort/filter 巨大資料。

延伸讀:Vue 渲染性能優化


二、 不要把所有資料都深層 reactive

1. 深層 reactive 適合會被細粒度更新的資料

適合放進 reactive 的資料:

  • 表單狀態。
  • UI 狀態。
  • 小型業務物件。
  • 需要追蹤多個欄位變化的資料。

例如:

typescript
const form = reactive({
  name: '',
  email: '',
  role: 'user',
})

這種資料量小、欄位會被 UI 細粒度綁定,用深層 reactive 很合理。

2. 大型不可變資料可以用 shallowRef

如果資料很大,但通常是整包替換,而不是深層修改,可以用 shallowRef

typescript
import { shallowRef } from 'vue'

const users = shallowRef<User[]>([])

async function loadUsers() {
  users.value = await fetchUsers()
}

shallowRef 只追蹤 .value 這一層。

也就是:

typescript
users.value = nextUsers // 會觸發更新
users.value[0].name = 'Alice' // 不會因深層欄位變化自動觸發

這適合:

  • 大型列表。
  • 搜尋結果。
  • 後端回來的不可變資料。
  • 只需要整包替換的資料。

3. 淺層資料物件可以用 shallowReactive

shallowReactive 只讓第一層屬性響應式,巢狀物件不會被深層轉換。

typescript
import { shallowReactive } from 'vue'

const state = shallowReactive({
  loading: false,
  options: largeOptionsObject,
})

當你只關心 state.loadingstate.options 這種第一層引用變化,而不需要追蹤 options 裡每個欄位,shallowReactive 會比深層 reactive 更克制。

4. 第三方實例用 markRaw

圖表、地圖、編輯器、表格套件通常會建立自己的 class instance。

這類物件通常不需要 Vue 代理:

typescript
import { markRaw, shallowRef } from 'vue'

const chart = shallowRef<Chart | null>(null)

function initChart(el: HTMLElement) {
  chart.value = markRaw(createChart(el))
}

適合 markRaw 的東西:

  • ECharts instance。
  • Map instance。
  • Editor instance。
  • WebGL / canvas 相關物件。
  • 很大的靜態設定物件。

重點是:不要讓 Vue 追蹤它不需要追蹤的東西。


三、 避免不必要的 deep watch

1. deep watch 的成本

deep: true 會讓 Vue 深度追蹤物件內部變化。

typescript
watch(
  form,
  () => {
    saveDraft(form)
  },
  { deep: true }
)

如果 form 很小,這沒有太大問題。

但如果你 deep watch 一個巨大列表:

typescript
watch(
  users,
  () => {
    syncUsers(users.value)
  },
  { deep: true }
)

資料越大,遍歷與依賴追蹤成本越高;任何深層欄位變化都可能觸發 watcher,後續副作用也容易變重。

2. 優先監聽必要欄位

與其 deep watch 整個物件,不如監聽真正需要的欄位。

typescript
watch(
  () => form.email,
  email => {
    validateEmail(email)
  }
)

監聽多個欄位:

typescript
watch(
  () => [form.name, form.email],
  ([name, email]) => {
    validateProfile(name, email)
  }
)

這樣依賴範圍更小,意圖也更清楚。

3. 大型資料改用版本號或事件

如果你只需要知道「整份資料換了」,不需要追蹤每個欄位變化,可以用版本號。

typescript
const users = shallowRef<User[]>([])
const usersVersion = ref(0)

function replaceUsers(nextUsers: User[]) {
  users.value = nextUsers
  usersVersion.value++
}

watch(usersVersion, () => {
  rebuildUserIndex(users.value)
})

這比 deep watch 整份大型資料更可控。

4. watch 的副作用要能取消

watch 常用來處理非同步副作用,例如請求。

typescript
watch(
  () => keyword.value,
  async (keyword, _, onCleanup) => {
    const controller = new AbortController()

    onCleanup(() => {
      controller.abort()
    })

    results.value = await search(keyword, {
      signal: controller.signal,
    })
  }
)

這不是單純性能問題,也能避免舊請求晚回來覆蓋新資料。

延伸讀:watch 與 computed 的區別


四、 computed、watch、watchEffect 的選擇

1. computed 適合衍生資料

computed 有快取。依賴不變時,多次讀取會回傳快取結果。

typescript
const activeUsers = computed(() => {
  return users.value.filter(user => user.active)
})

適合:

  • 從既有狀態推導資料。
  • 模板需要讀取的結果。
  • 過濾、格式化、統計等純計算。

但 computed 也不是免費的。如果依賴是五萬筆資料,每次 users 換整包都會重新計算。

2. watch 適合副作用

watch 適合「資料變了,要做某件事」。

例如:

  • 發請求。
  • 寫入 localStorage。
  • 上報 analytics。
  • 操作第三方套件。
  • 與 router 或外部狀態同步。
typescript
watch(
  () => route.query.page,
  page => {
    loadPage(Number(page || 1))
  }
)

如果只是要得到一個衍生值,不要用 watch 把結果塞到另一個 ref,computed 更自然。

3. watchEffect 適合依賴簡單的副作用

watchEffect 會自動收集同步執行過程中讀到的依賴。

typescript
watchEffect(() => {
  document.title = `${unreadCount.value} unread`
})

它適合依賴少、邏輯短的副作用。

如果邏輯很長,或你希望精準控制依賴來源,用 watch 會更清楚。

4. 避免用 watch 製造狀態鏈

不好的做法:

typescript
watch(price, value => {
  tax.value = value * 0.05
})

watch(tax, value => {
  total.value = price.value + value
})

這種狀態鏈容易變得難追蹤,也可能觸發多次更新。

更好的做法:

typescript
const tax = computed(() => price.value * 0.05)
const total = computed(() => price.value + tax.value)

衍生資料用 computed,副作用才用 watch。


五、 狀態拆分與更新頻率

1. 高頻狀態和低頻狀態分開

例如一個後台頁面有:

  • 搜尋輸入。
  • 表格資料。
  • 勾選狀態。
  • 統計圖表。
  • 權限資訊。
  • 使用者偏好設定。

不要全部塞在同一個大物件裡,讓每次輸入都牽動一整包狀態。

比較好的做法是按更新頻率拆分:

typescript
const keyword = ref('')
const selectedIds = ref(new Set<string>())
const tableRows = shallowRef<Row[]>([])
const userPreferences = reactive({
  pageSize: 20,
  density: 'compact',
})

高頻變化的狀態要小,低頻大資料可以淺層或整包替換。

2. Store 不要變成全域大物件

Pinia 或 Vuex 很方便,但不要把所有東西都放進同一個 store。

問題可能是:

  • 很多頁面依賴同一個巨大 store。
  • 任意欄位變化讓大量 computed 重新評估。
  • store 裡同時混合列表、表單、UI、快取與權限。

比較好的拆法:

  • 按 domain 拆 store。
  • 高頻 UI 狀態留在局部組件。
  • 大型查詢結果可以用淺層 ref 或快取層。
  • 不要讓全站都依賴同一個大 getter。

3. Props 要保持穩定

如果父組件每次 render 都建立新物件,子組件就更容易被牽動。

不理想:

vue
<UserRow
  v-for="user in users"
  :key="user.id"
  :options="{ compact: true, editable: canEdit }"
  :user="user"
/>

每次 render 都會建立新的 options 物件。

可以改成 computed 或常量:

typescript
const rowOptions = computed(() => ({
  compact: true,
  editable: canEdit.value,
}))
vue
<UserRow
  v-for="user in users"
  :key="user.id"
  :options="rowOptions"
  :user="user"
/>

這不是所有情況都必要,但在大型列表或重型子組件中值得注意。


六、 大型資料處理

1. 不要每次 render 都 filter / sort / map 大資料

不建議:

vue
<UserRow
  v-for="user in users.filter(user => user.active).sort(sortByName)"
  :key="user.id"
  :user="user"
/>

每次 render 都可能重新過濾和排序。

更好的做法是放進 computed:

typescript
const activeUsers = computed(() => {
  return users.value
    .filter(user => user.active)
    .toSorted(sortByName)
})

但如果資料非常大,computed 也可能很重。這時要考慮:

  • 後端分頁與排序。
  • 建立索引。
  • Web Worker。
  • 搜尋條件防抖。
  • 分批處理。
  • 虛擬列表只渲染可視區域。

2. 搜尋輸入要防抖

使用者每打一個字就對五萬筆資料 filter,會很容易卡。

typescript
const keyword = ref('')
const debouncedKeyword = ref('')

let timer: ReturnType<typeof setTimeout> | null = null

watch(keyword, value => {
  if (timer) {
    clearTimeout(timer)
  }

  timer = setTimeout(() => {
    debouncedKeyword.value = value
  }, 250)
})

再用 debouncedKeyword 去觸發搜尋或 computed。

真正大型的資料搜尋,通常應該交給後端或索引,而不是每次在前端全量掃描。

3. 大型資料和渲染策略要一起看

響應式優化只能減少資料追蹤成本,但如果畫面仍然一次渲染大量 DOM,還是會卡。

所以大型列表通常要同時處理:

  • 資料層:後端分頁、索引、快取、淺層 ref。
  • 計算層:computed、memo、worker、防抖。
  • 渲染層:虛擬列表、分頁、降低 row 複雜度。

延伸讀:Vue 渲染性能優化


七、 追問題庫

Q1:Vue 3 用 Proxy 了,還需要關心響應式性能嗎?

需要。

Proxy 解決了 Vue 2 的很多限制,但依賴收集、觸發更新、深層資料讀取、deep watch、大型 computed 仍然有成本。Vue 3 更強,但不是無成本。

Q2:什麼情況用 shallowRef

當資料很大,但你只關心整體引用變化,不需要追蹤內部每個欄位時。

例如大型列表、後端查詢結果、不可變資料、第三方資料模型。更新時用整包替換,避免深層響應式追蹤。

Q3:什麼情況用 markRaw

當某個物件不需要 Vue 代理,甚至被代理可能有副作用時。

常見是第三方 class instance,例如圖表、地圖、編輯器、播放器、WebGL 物件。這些物件通常由套件自己管理狀態,Vue 只需要保存引用。

Q4:deep watch 為什麼可能很貴?

因為 deep watch 需要深度遍歷物件來追蹤內部依賴。資料越大、層級越深、變化越頻繁,成本越高。

更好的方式是監聽必要欄位,或用版本號、事件、整包替換來表達資料變化。

Q5:computed 一定比 method 快嗎?

不一定。

computed 的優勢是依賴不變時有快取。如果每次依賴都會變,或 computed 本身處理超大型資料,它仍然會重新計算。

method 每次 render 都會執行;computed 依賴不變會復用快取。要看使用場景。


八、 實作題

題目:一個頁面載入 5 萬筆資料後,任何操作都很卡,你怎麼排查?

可以這樣回答:

  1. 先用 Performance 看卡在 JS、render、Layout 還是資料處理。
  2. 檢查 5 萬筆資料是否被放進深層 reactive
  3. 如果資料主要整包替換,改用 shallowRef 保存列表。
  4. 檢查是否有 deep watch 監聽整份列表。
  5. 檢查 computed 是否每次對 5 萬筆資料 filter / sort / map。
  6. 搜尋、篩選、排序是否能交給後端或建立索引。
  7. 高頻輸入加防抖,避免每個字都全量計算。
  8. 檢查畫面是否一次渲染 5 萬筆 DOM,必要時用虛擬列表或分頁。
  9. 檢查 store 是否過大,任意欄位變化是否觸發大量組件更新。
  10. 優化後重新量測 long task、更新耗時、記憶體與渲染節點數。

重點是:這不是單一 API 能解決的問題,要同時處理響應式追蹤、資料計算與渲染策略。


九、 資深視角

1. 響應式資料要有邊界感

不是所有資料都應該交給 Vue 深層追蹤。

可以問自己:

  • 這份資料需要欄位級響應嗎?
  • 還是只需要知道整份資料換了?
  • 是否會被很多組件讀取?
  • 是否會頻繁更新?
  • 是否是第三方實例或外部狀態?

如果答案是「只需要保存引用」,就不一定要深層 reactive。

2. 大型資料優化通常要改資料流

如果前端拿 5 萬筆資料,然後每次輸入都在本地 filter/sort,再一次渲染出來,問題不在 Vue,而在資料流設計。

更成熟的方案可能是:

  • 後端分頁。
  • 後端搜尋與排序。
  • 前端建立索引。
  • Web Worker 處理重計算。
  • 虛擬列表降低 DOM。
  • 用快取避免重複請求。

Vue API 只能幫你降低部分成本,不能替代資料策略。

3. 優化要保留可維護性

shallowRefmarkRaw、版本號、手動觸發更新都會讓資料流更偏手動。

這些工具有價值,但也增加心智負擔:

  • 深層修改不會自動更新。
  • 團隊需要知道何時整包替換。
  • 狀態更新規則要更清楚。
  • 測試要覆蓋資料更新路徑。

資深回答要能說出:我不是為了用工具而用工具,而是在資料量和更新頻率真的需要時才用。


總結

問題回答重點
響應式成本來自哪裡依賴收集、觸發更新、深層代理、deep watch、大型 computed
大型資料怎麼存不需要深層追蹤時用 shallowRef / shallowReactive
第三方實例怎麼處理markRaw 避免 Vue 代理
deep watch 怎麼替代監聽必要欄位、版本號、事件、整包替換
computed / watch 怎麼選衍生資料用 computed,副作用用 watch
資深取捨控制依賴範圍,同時注意資料流與可維護性

一句完整的面試回答可以是:

Vue 響應式性能優化我會先看資料是否真的需要深層響應式。小型表單或 UI 狀態用 reactive 很合理,但大型列表、後端查詢結果、不可變資料可以用 shallowRefshallowReactive,第三方實例用 markRaw 避免代理。接著會檢查是否有 deep watch、大型 computed、模板中重複 filter/sort/map,以及 store 是否過大導致依賴範圍太廣。衍生資料用 computed,副作用用 watch,高頻輸入加防抖,大型資料交給後端分頁、索引或 Web Worker。最後還要搭配虛擬列表或分頁,因為響應式優化不能解決一次渲染大量 DOM 的問題。

延伸閱讀