本文 AI 產出,尚未審核

Vue3 – 效能與最佳化:Memoization(computed 快取)


簡介

在單頁應用程式 (SPA) 中,隨著畫面與資料的交互日益頻繁,效能 逐漸成為使用者體驗的關鍵因素。Vue 3 為了讓開發者更容易管理大量的計算與渲染工作,引入了 computed 這個「快取」機制。它本質上是一種 memoization(記憶化)技術:只要依賴的資料沒有改變,computed 的結果就會被緩存,下次使用時直接返回先前的值,避免不必要的重新計算。

掌握 computed 的運作原理與最佳化技巧,能讓你的 Vue 應用在 大量資料、複雜運算或頻繁更新 的情境下,仍然保持流暢。本文將從概念切入,透過實作範例說明如何正確使用 computed 快取,並提供常見陷阱與實務建議,幫助你在開發過程中即時提升效能。


核心概念

1. computed 與一般函式的差異

項目 普通函式 (method) computed
執行時機 每次呼叫都會執行 只有在依賴的 reactive 資料變化時才重新執行
快取 無快取,除非自行實作 內建快取 (memoization)
使用情境 需要即時、一次性的計算 需要根據 reactive 狀態產生衍生值,且這個值會在多處被使用

重點computed 只會在 依賴變動 時重新計算,這正是 memoization 的核心概念。

2. 依賴收集與觸發機制

Vue 3 採用 Proxy 來攔截對 reactive 物件的讀取與寫入。當 computed getter 內部讀取了某個 reactive 屬性,Vue 會自動把這個屬性加入 依賴集合。之後,只要該屬性被改寫,就會觸發 computed 的重新計算。

import { ref, computed } from 'vue'

const count = ref(0)

// 依賴收集:computed 內部讀取了 count.value
const double = computed(() => {
  console.log('計算 double')
  return count.value * 2
})

console.log(double.value) // 觸發計算,印出 "計算 double" 與 0
console.log(double.value) // 不會再計算,直接快取結果 0
count.value = 5           // 依賴變動,標記 double 為「dirty」
console.log(double.value) // 再次計算,印出 "計算 double" 與 10

3. computed 的「懶執行」

computed 不會在建立時即刻執行,而是等到第一次讀取 .value 才會跑一次 getter。這意味著如果某個 computed 在整個生命週期中從未被使用,相關的計算根本不會發生,進一步節省資源。

4. 只讀 vs 可寫 computed

  • 只讀:僅提供 getter,最常見的使用方式。
  • 可寫:同時提供 getter 與 setter,讓外部可以透過 computed 直接寫入,背後會觸發對應的 reactive 資料更新。
const firstName = ref('John')
const lastName  = ref('Doe')

const fullName = computed({
  get() {
    return `${firstName.value} ${lastName.value}`
  },
  set(val) {
    const [first, last] = val.split(' ')
    firstName.value = first
    lastName.value = last
  }
})

fullName.value = 'Jane Smith' // 會自動更新 firstName 與 lastName

程式碼範例

以下提供 5 個實用範例,示範在不同情境下如何運用 computed 進行 memoization。

範例 1:列表過濾與排序(大量資料)

import { ref, computed } from 'vue'

const rawList = ref([
  /* 假設有 10,000 筆商品資料 */
  { id: 1, name: '蘋果', price: 30, category: '水果' },
  // ...
])

const searchKeyword = ref('')
const sortByPriceDesc = ref(false)

// 只在 searchKeyword 或 sortByPriceDesc 改變時重新計算
const filteredAndSorted = computed(() => {
  console.log('執行過濾與排序')
  const keyword = searchKeyword.value.trim().toLowerCase()

  // 1. 先過濾
  let result = rawList.value.filter(item =>
    item.name.toLowerCase().includes(keyword)
  )

  // 2. 再排序
  result.sort((a, b) =>
    sortByPriceDesc.value ? b.price - a.price : a.price - b.price
  )

  return result
})

說明:若使用 method 每次渲染都會跑一次過濾與排序,對 10,000 筆資料的效能會大幅下降。computed 只在關鍵字或排序方式變動時重新計算,其他時候直接回傳快取結果。

範例 2:計算大型陣列的統計資訊(避免重複運算)

const numbers = ref(Array.from({ length: 5000 }, () => Math.random() * 100))

const stats = computed(() => {
  console.log('計算統計資訊')
  const sum = numbers.value.reduce((a, b) => a + b, 0)
  const avg = sum / numbers.value.length
  const max = Math.max(...numbers.value)
  const min = Math.min(...numbers.value)
  return { sum, avg, max, min }
})

// 在模板中使用 {{ stats.avg }} 時,只會在 numbers 改變時重新計算

範例 3:可寫 computed - 表單雙向綁定

const startDate = ref('2024-01-01')
const endDate   = ref('2024-12-31')

const period = computed({
  get() {
    return `${startDate.value} ~ ${endDate.value}`
  },
  set(val) {
    const [start, end] = val.split('~').map(s => s.trim())
    startDate.value = start
    endDate.value = end
  }
})

// <input v-model="period"> 會自動同步到 startDate、endDate

範例 4:依賴多個 reactive 物件的計算(使用 toRefs

import { reactive, toRefs, computed } from 'vue'

const state = reactive({
  user: { firstName: 'Alice', lastName: 'Wang' },
  locale: 'en'
})

// 把 reactive 轉成 refs,避免在 computed 中直接解構失去響應性
const { user, locale } = toRefs(state)

const greeting = computed(() => {
  console.log('產生問候語')
  const name = `${user.value.firstName} ${user.value.lastName}`
  return locale.value === 'en' ? `Hello, ${name}` : `哈囉,${name}`
})

範例 5:結合 watchEffectcomputed 的懶加載

import { ref, computed, watchEffect } from 'vue'

const query = ref('vue')
const results = ref([])

// 只在 query 改變時觸發 API 呼叫
const fetchUrl = computed(() => `https://api.example.com/search?q=${encodeURIComponent(query.value)}`)

watchEffect(async () => {
  console.log('發送請求至', fetchUrl.value)
  const resp = await fetch(fetchUrl.value)
  results.value = await resp.json()
})

// 即使在其他地方多次讀取 fetchUrl.value,也只會在 query 改變時重新產生 URL

常見陷阱與最佳實踐

陷阱 說明 解決方式
過度依賴 computed 把所有邏輯都寫在 computed,導致過於複雜、難以維護 只將「衍生資料」放在 computed,副作用 (如 API 請求) 應使用 watch / watchEffect
computed 內部直接改變 reactive 會產生「寫入-讀取」循環,可能導致無限迴圈或錯誤 computed 必須保持 純函式,若需要改變資料,使用 methodwatch
解構 reactive 物件導致失去依賴收集 const { a } = state 會把 a 變成普通變數,computed 無法追蹤 使用 toRefs(state) 或直接存取 state.a
忘記 computed 的懶執行特性 認為 computed 會立即執行,導致在建立時就觸發昂貴計算 若需要立即執行,可在 setup 中先讀一次 .value,或改用 watchEffect
使用 computed 包裝大量非同步運算 computed 只適合同步計算,非同步會返回 Promise,快取行為不符合預期 非同步資料請使用 ref + watch / async setup,或自行實作 memoization 函式

最佳實踐清單

  1. 保持純函式computed 的 getter 應僅依賴其他 reactive,且不產生副作用。
  2. 最小化依賴集合:只讀取必要的屬性,避免把整個大型物件一次性放入依賴,減少不必要的重新計算。
  3. 適度分割:對於複雜的計算,可拆成多個 computed,每個只負責一小塊邏輯,提升可讀性與快取命中率。
  4. 利用 debuggerconsole.log:在開發階段加入 console.log('計算...'),觀察是否真的在預期時重新計算。
  5. 結合 watch:當需要在計算結果變化時執行副作用(例如 API 請求、路由跳轉),使用 watch 監聽 computed 的值,而非在 getter 內直接呼叫。

實際應用場景

場景 為什麼使用 computed 範例
大型表格的分頁、篩選、排序 只在使用者變更條件時重新計算子集合,避免每次渲染都遍歷全部資料 範例 1
儀表板統計圖表 多個圖表共用相同的原始資料,透過 computed 快取中間統計結果,減少重複計算 範例 2
表單雙向綁定與格式化 讓顯示與內部資料保持同步,同時支援使用者直接編輯「合成」欄位 範例 3
多語系動態文字 當語系變更時,只重新計算受影響的文字,其他不變的部分保持快取 範例 4
依賴 URL 參數的資料抓取 URL 只在參數變動時才改變,computed 產生新的請求 URL,watchEffect 負責實際抓取 範例 5

總結

  • computed 是 Vue 3 提供的 memoization 工具,透過依賴收集與懶執行,能在資料未變時直接回傳快取結果,顯著降低不必要的計算成本。
  • 正確使用 computed 的關鍵在於 保持純函式、最小化依賴、分割複雜邏輯,以及在需要副作用時搭配 watch / watchEffect
  • 大量資料、頻繁更新、跨組件共享衍生狀態 的情境下,computed 能提供明顯的效能提升,同時讓程式碼更具可讀性與可維護性。

掌握了 computed 的快取機制與最佳化技巧,你的 Vue 3 應用將能在效能與開發體驗之間取得更好的平衡。祝你寫出更快、更穩定的前端程式!