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. 基本實作原理
核心步驟如下:
- 保存上一個依賴:使用
ref追蹤依賴的快照。 - 依賴比對:在
watch中比較新舊依賴是否相等(深比較或淺比較視需求而定)。 - 重新產生函式:若依賴變化,將
fn包裝成新的函式並更新引用。 - 返回 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沒變,子組件接收到的onIncrementprop 不會重新觸發,提升渲染效能。
範例 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 共享同一個函式。 |
最佳實踐清單
- 始終為函式提供依賴陣列,即使只是一個
ref。 - 保持
useMemoizedFn的呼叫位置穩定(通常放在setup最上方),避免在循環或條件分支內部呼叫。 - 配合 TypeScript 時,為回傳函式加上正確的型別,提升 IDE 識別與自動完成。
- 使用
shallowRef(若僅需要淺層比較)可以減少不必要的深度偵測。 - 測試效能:使用 Vue Devtools 的 “Performance” 面板或 Chrome 的 “Performance” 記錄,觀察 memoized 前後的渲染次數與時間。
實際應用場景
表單元件的
onChange回呼- 大型表單內每個欄位都會傳遞
onChange給子組件,若每次渲染都產生新函式,子表單會頻繁重新渲染。使用useMemoizedFn固定onChange引用,可將渲染次數降低至 僅當欄位值真正改變時。
- 大型表單內每個欄位都會傳遞
資料表格的排序 / 分頁函式
- 排序、分頁的處理函式往往依賴當前的
sortKey、pageSize。將這類函式 memoize 後,可避免在資料變更時重新建立,讓virtual-scroller或infinite-scroll的效能提升顯著。
- 排序、分頁的處理函式往往依賴當前的
圖表庫的事件綁定
- 使用
echarts、chart.js等第三方圖表時,需要將 click、hover 等回呼傳入圖表實例。若每次組件更新都重新產生回呼,圖表會重新註冊事件,導致記憶體泄漏。useMemoizedFn可保證回呼的唯一性。
- 使用
跨組件共享的業務邏輯
- 例如「加入購物車」的業務函式在多個頁面使用,將其 memoize 後透過
provide/inject或 Pinia store 注入,可確保所有子組件都使用同一個函式實例,減少不必要的 re‑render。
- 例如「加入購物車」的業務函式在多個頁面使用,將其 memoize 後透過
防抖 / 節流的 API 呼叫
- 如前範例 4,防抖函式若每次渲染都重新產生,防抖計時會被重置,導致呼叫頻率失效。
useMemoizedFn能讓防抖函式在整個生命週期內保持相同引用。
- 如前範例 4,防抖函式若每次渲染都重新產生,防抖計時會被重置,導致呼叫頻率失效。
總結
useMemoizedFn 是 Vue 3 Composition API 中一個簡潔卻強大的效能優化工具。它的核心在於 記憶化函式引用,讓開發者能:
- 避免不必要的子組件重新渲染
- 減少 watch / computed 的重複觸發
- 確保防抖、節流等高階函式的正確行為
只要遵守以下幾點,即可在日常開發中安全使用:
- 明確列出依賴,並根據需求選擇淺層或深層偵測。
- 將
useMemoizedFn放在穩定的位置(如setup最外層),避免在迴圈或條件中呼叫。 - 配合 Vue Devtools 觀測效能,確保 memoization 真正帶來渲染次數的下降。
透過上述概念與範例,你現在已掌握 useMemoizedFn 的 原理、寫法與實務應用,可以在自己的 Vue 3 專案中即時提升效能,為使用者帶來更流暢的體驗。祝開發順利,效能永遠在線!