本文 AI 產出,尚未審核

Vue3 效能與最佳化(Performance Optimization)

主題:useMemoizedFn 模式


簡介

在 Vue 3 中,組件的重新渲染是透過 reactivity system 自動追蹤依賴來完成的。雖然這樣的機制讓開發者可以免除大量的手動更新工作,但若不小心在模板或 setup 裡直接宣告 新函式,每一次渲染都會產生新的函式實例,導致子組件的 props 變更、watch 重新觸發,甚至觸發不必要的 computed 計算,最終影響效能。

useMemoizedFn(或稱 記憶化函式)是一種 Composition API 的實用模式,透過 memoization(記憶化)讓函式在依賴未變時保持同一個引用(reference),從根本上避免「函式每次都重新產生」的問題。本文將深入說明這個模式的原理、實作方式與最佳實踐,幫助你在 Vue 3 專案中寫出更高效、可維護的程式碼。


核心概念

1. 為什麼函式的引用會影響效能?

在 Vue 的模板或 v-on 事件綁定中,如果直接寫成:

<button @click="() => count++">+</button>

每一次渲染都會產生一個全新的箭頭函式。子組件收到的 props(若將此函式透過 props 傳遞)會被視為 變更,即使實際上功能相同。這會導致:

  • 子組件重新渲染(尤其是使用 defineProps / defineEmits 的情況)
  • watcher 重新觸發
  • computed 重新計算

若這類函式在大型列表或頻繁更新的 UI 中大量出現,效能下降將非常明顯。

2. useMemoizedFn 的概念

useMemoizedFn 是一個 自訂 Hook(Composition Function),它接受一個原始函式 fn 與一組 依賴陣列(類似 watch 的第二個參數),回傳一個 記憶化後的函式引用。只要依賴未改變,返回的函式就保持相同的記憶體位址;當依賴變化時,才會重新產生新函式。

簡單來說,它的行為類似 React 的 useCallback,但在 Vue 中我們可以用 ref、computed 或 watchEffect 來自行實作。

3. 基本實作原理

核心步驟如下:

  1. 保存上一個依賴:使用 ref 追蹤依賴的快照。
  2. 依賴比對:在 watch 中比較新舊依賴是否相等(深比較或淺比較視需求而定)。
  3. 重新產生函式:若依賴變化,將 fn 包裝成新的函式並更新引用。
  4. 返回 memoized 函式:外部只會拿到 memoizedFn.value(或直接返回函式本身)。

下面給出一個最小實作範例,之後會在「程式碼範例」章節展開更多變化。


程式碼範例

範例 1️⃣ 基本 useMemoizedFn 實作

import { ref, watch } from 'vue'

/**
 * @param {Function} fn 原始函式
 * @param {Array} deps 依賴陣列(可為 reactive 變數)
 * @returns {Function} 記憶化後的函式
 */
export function useMemoizedFn(fn, deps = []) {
  const memoized = ref(fn) // 初始保存

  // 監控依賴變化,依賴改變時重新產生函式
  watch(
    deps,
    () => {
      // 重新包裝 fn,確保 this 仍指向正確的上下文
      memoized.value = (...args) => fn(...args)
    },
    { deep: true } // 深度偵測(視需求可改成 shallow)
  )

  // 直接回傳函式本身,使用時不需要 .value
  return (...args) => memoized.value(...args)
}

重點:此實作返回的是一個 純函式,外部使用時感覺與普通函式無異,卻能保證在依賴不變時引用不變。


範例 2️⃣ 在組件中使用 useMemoizedFn

<script setup>
import { ref } from 'vue'
import { useMemoizedFn } from '@/hooks/useMemoizedFn'

const count = ref(0)
const step = ref(1)

// 只要 step 改變,incrementFn 才會重新產生
const incrementFn = useMemoizedFn(() => {
  count.value += step.value
}, [step]) // step 為依賴

// 在模板中直接使用 memoized 函式
</script>

<template>
  <div>
    <p>Count: {{ count }}</p>
    <button @click="incrementFn">+ (step = {{ step }})</button>
    <input type="number" v-model="step" />
  </div>
</template>

說明:

  • 按下按鈕時呼叫的 incrementFn 引用保持不變,除非 step 被改變。
  • 若 step 沒變,子組件接收到的 onIncrement prop 不會重新觸發,提升渲染效能。

範例 3️⃣ 結合 async/await 的記憶化函式

import { ref } from 'vue'
import { useMemoizedFn } from '@/hooks/useMemoizedFn'

const query = ref('')
const result = ref(null)
const loading = ref(false)

const fetchData = useMemoizedFn(async (keyword) => {
  loading.value = true
  try {
    const res = await fetch(`https://api.example.com/search?q=${keyword}`)
    result.value = await res.json()
  } finally {
    loading.value = false
  }
}, [query]) // 當 query 改變時重新產生

// 使用方式:在搜尋欄的 @keyup.enter 觸發
</script>

<template>
  <input v-model="query" @keyup.enter="fetchData(query)" placeholder="輸入關鍵字" />
  <div v-if="loading">載入中…</div>
  <pre v-else>{{ result }}</pre>
</template>

技巧:即使 fetchData 為 非同步函式,useMemoizedFn 仍能正確記憶化,只要依賴陣列不變,函式引用不會改變。


範例 4️⃣ 搭配 debounce(防抖)實作

import { ref } from 'vue'
import { useMemoizedFn } from '@/hooks/useMemoizedFn'
import { debounce } from 'lodash-es'

const search = ref('')
const result = ref([])

// 先產生防抖版的搜尋函式,再用 memoized 包裝
const debouncedSearch = useMemoizedFn(
  debounce(async (keyword) => {
    const res = await fetch(`https://api.example.com/search?q=${keyword}`)
    result.value = await res.json()
  }, 300),
  [] // 防抖函式本身不依賴外部變數
)

watch(search, (newVal) => {
  debouncedSearch(newVal) // 每次輸入只會觸發一次 API 呼叫
})

說明:

  • debounce 本身回傳一個新的函式,若直接在 watch 裡寫會在每次渲染時重新產生,失去防抖效果。
  • 透過 useMemoizedFn 固定 debouncedSearch 的引用,確保防抖行為正確。

範例 5️⃣ 在子組件中傳遞 memoized 函式(避免不必要的 re‑render)

父組件:

<script setup>
import Child from './Child.vue'
import { ref } from 'vue'
import { useMemoizedFn } from '@/hooks/useMemoizedFn'

const items = ref([{ id: 1, name: 'A' }, { id: 2, name: 'B' }])
const selected = ref(null)

// 只要 items 不變,handleSelect 的引用保持不變
const handleSelect = useMemoizedFn((id) => {
  selected.value = items.value.find((it) => it.id === id) ?? null
})
</script>

<template>
  <Child :items="items" :on-select="handleSelect" />
  <p>已選:{{ selected?.name ?? '無' }}</p>
</template>

子組件 Child.vue:

<script setup>
import { defineProps } from 'vue'

const props = defineProps({
  items: Array,
  onSelect: Function
})
</script>

<template>
  <ul>
    <li v-for="item in props.items" :key="item.id">
      <button @click="props.onSelect(item.id)">{{ item.name }}</button>
    </li>
  </ul>
</template>

結果:

  • onSelect 只在 items 改變時才重新產生,子組件因 props 未變而不會每次父組件渲染時重新渲染。

常見陷阱與最佳實踐

陷阱 說明 解決方式
依賴陣列忘記加入必要變數 若忘記把使用到的 reactive 變數放入 deps,函式不會在變數變化時重新產生,導致 資料不同步。 每次在 fn 內部使用 ref、computed 或 props 時,檢查是否已加入 deps。
深度偵測過度 使用 { deep: true } 會在每次依賴內部屬性變化時觸發重建,可能抵消 memoization 的效益。 只在需要監控物件內部變化時使用深度偵測,否則改用淺層比較或手動列出子屬性。
返回值為函式本身時忘記 .value 若 useMemoizedFn 回傳 ref(如 memoized),在模板或事件中直接使用會得到 Ref 而非函式。 直接回傳包裝好的函式(如範例 1)或在使用時寫 memoized.value。
與 watchEffect 混用產生副作用 把 useMemoizedFn 放在 watchEffect 裡會導致每次副作用重新執行時都重新建立函式,失去 memoization。 把 useMemoizedFn 放在 setup 最外層或在 watch 之外的穩定位置。
在大型列表中大量建立 useMemoizedFn 每筆資料都呼叫一次 useMemoizedFn 仍會產生多個 ref,記憶體開銷不小。 若列表資料多且函式相同,可將 memoized 函式提升至父層或使用 provide/inject 共享同一個函式。

最佳實踐清單

  1. 始終為函式提供依賴陣列,即使只是一個 ref。
  2. 保持 useMemoizedFn 的呼叫位置穩定(通常放在 setup 最上方),避免在循環或條件分支內部呼叫。
  3. 配合 TypeScript 時,為回傳函式加上正確的型別,提升 IDE 識別與自動完成。
  4. 使用 shallowRef(若僅需要淺層比較)可以減少不必要的深度偵測。
  5. 測試效能:使用 Vue Devtools 的 “Performance” 面板或 Chrome 的 “Performance” 記錄,觀察 memoized 前後的渲染次數與時間。

實際應用場景

  1. 表單元件的 onChange 回呼

    • 大型表單內每個欄位都會傳遞 onChange 給子組件,若每次渲染都產生新函式,子表單會頻繁重新渲染。使用 useMemoizedFn 固定 onChange 引用,可將渲染次數降低至 僅當欄位值真正改變時。
  2. 資料表格的排序 / 分頁函式

    • 排序、分頁的處理函式往往依賴當前的 sortKey、pageSize。將這類函式 memoize 後,可避免在資料變更時重新建立,讓 virtual-scroller 或 infinite-scroll 的效能提升顯著。
  3. 圖表庫的事件綁定

    • 使用 echarts、chart.js 等第三方圖表時,需要將 click、hover 等回呼傳入圖表實例。若每次組件更新都重新產生回呼,圖表會重新註冊事件,導致記憶體泄漏。useMemoizedFn 可保證回呼的唯一性。
  4. 跨組件共享的業務邏輯

    • 例如「加入購物車」的業務函式在多個頁面使用,將其 memoize 後透過 provide/inject 或 Pinia store 注入,可確保所有子組件都使用同一個函式實例,減少不必要的 re‑render。
  5. 防抖 / 節流的 API 呼叫

    • 如前範例 4,防抖函式若每次渲染都重新產生,防抖計時會被重置,導致呼叫頻率失效。useMemoizedFn 能讓防抖函式在整個生命週期內保持相同引用。

總結

useMemoizedFn 是 Vue 3 Composition API 中一個簡潔卻強大的效能優化工具。它的核心在於 記憶化函式引用,讓開發者能:

  • 避免不必要的子組件重新渲染
  • 減少 watch / computed 的重複觸發
  • 確保防抖、節流等高階函式的正確行為

只要遵守以下幾點,即可在日常開發中安全使用:

  1. 明確列出依賴,並根據需求選擇淺層或深層偵測。
  2. 將 useMemoizedFn 放在穩定的位置(如 setup 最外層),避免在迴圈或條件中呼叫。
  3. 配合 Vue Devtools 觀測效能,確保 memoization 真正帶來渲染次數的下降。

透過上述概念與範例,你現在已掌握 useMemoizedFn 的 原理、寫法與實務應用,可以在自己的 Vue 3 專案中即時提升效能,為使用者帶來更流暢的體驗。祝開發順利,效能永遠在線!