本文 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 中我們可以用 refcomputedwatchEffect 來自行實作。

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 內部使用 refcomputedprops 時,檢查是否已加入 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. 資料表格的排序 / 分頁函式

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

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

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

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

總結

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

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

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

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

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