
JS Utility Function 和 Vue Composable,傻傻分不清楚
熟悉 Vue 3 的朋友們應該都用過 Composition API 寫過 composable 吧?
之前聽過有人問:「為什麼我要寫個 useXxx 的 composable,而不是直接寫個普通的 util function?」
其實 utility function 和 composable 本質上的確都是 function,但 composable 有一些重要的約定。
今天就來聊聊這兩者的差異,這也是目前 AI 最常犯的錯誤。ლ(╹ε╹ლ)
Utility Function 就是純 JS function
先來看最單純的情況。utility function 就是一般的 JavaScript function,負責做資料轉換、格式化、驗證等等純粹的運算。
例如:
export function formatDate(date: Date) {
return new Intl.DateTimeFormat('zh-TW').format(date)
}
export function formatCurrency(amount: number) {
return new Intl.NumberFormat('zh-TW', {
style: 'currency',
currency: 'TWD',
}).format(amount)
}
export function validateEmail(email: string) {
return /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/.test(email)
}這類 function 有幾個特點:
- 沒有狀態:輸入相同,輸出一定相同
- 不依賴 Vue:拿去 Node.js、React 裡面用也完全沒問題
- 隨時隨地都能呼叫:在
setup、onMounted、watch、甚至setTimeout裡面呼叫都可以
Composable 不只是 function
composable 雖然也是 function,但它會利用 Composition API(ref、onMounted 等)來封裝有狀態的邏輯。
例如以下是一個偵測視窗大小的 composable:
import { onMounted, onUnmounted, ref } from 'vue'
export function useWindowSize() {
const width = ref(window.innerWidth)
const height = ref(window.innerHeight)
function update() {
width.value = window.innerWidth
height.value = window.innerHeight
}
onMounted(() => window.addEventListener('resize', update))
onUnmounted(() => window.removeEventListener('resize', update))
return { width, height }
}這時候你可能會想問:「Vue 的響應式系統本來就可以獨立運作,就算不是 Vue 也可以用,所以分界線在哪?」
關鍵點是 composable 內部可能使用 onMounted、onUnmounted 等 lifecycle hook 或 watch、computed 等 effect,這些 API 必須在元件實例的同步流程中才能正確註冊並自動清除。
Composable 必須在同步流程中呼叫
Vue 在執行 <script setup> 時,會將目前的元件實例設為 current instance。只要你還在這個同步的呼叫堆疊中,lifecycle hook 或 effect 就能正確註冊到元件上。
所以以下這些地方呼叫 composable 都是可以的:
<script setup lang="ts">
import { onMounted } from 'vue'
import { useWindowSize } from './composables/use-window-size'
// ✓ script setup 頂層
const { width, height } = useWindowSize()
// ✓ onMounted callback(Vue 會設定 current instance)
onMounted(() => {
const size = useWindowSize()
})
</script>因為 <script setup> 頂層和 onMounted 等 lifecycle hook 的 callback 執行時,Vue 都會將元件實例設為 current instance,所以能正確註冊。╰(*´︶`*)╯
但是,一旦脫離同步流程,元件實例就不再是 current instance 了:
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { useWindowSize } from './composables/use-window-size'
const enabled = ref(false)
// ✗ watch callback 觸發時機不確定,可能不在同步流程中
watch(enabled, () => {
const size = useWindowSize()
})
onMounted(() => {
// ✗ setTimeout 是非同步,脫離了元件的同步流程
setTimeout(() => {
const size = useWindowSize()
}, 100)
// ✗ Promise.then 也是非同步
fetch('/api').then(() => {
const size = useWindowSize()
})
})
// ✗ await 之後也是非同步
const data = await fetchSomething()
const size = useWindowSize()
</script>watch callback、setTimeout、Promise.then、await 之後的程式碼,都不在元件實例的同步呼叫堆疊中,lifecycle hook 或 effect 無法正確註冊,自動清除機制就會壞掉。
意思就是在非同步流程中使用 composable 可能會導致 memory leak!(́⊙◞౪◟⊙‵)
Utility Function 可以隨便放
相比之下,utility function 沒有這些限制,因為它們根本不碰 Vue 的響應式系統和生命週期。
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { formatDate } from './utils/formatters'
import { validateEmail } from './utils/validators'
const email = ref('')
const today = ref('')
// ✓ 在頂層呼叫
today.value = formatDate(new Date())
// ✓ 在 onMounted 呼叫
onMounted(() => {
console.log(formatDate(new Date()))
})
// ✓ 在 watch 呼叫
watch(email, (value) => {
if (validateEmail(value)) {
console.log('Email 合法')
}
})
// ✓ 甚至在 setTimeout 呼叫
setTimeout(() => {
console.log(formatDate(new Date()))
}, 1000)
</script>畢竟就是普通的 function,沒有副作用,也不依賴 Vue 的元件實例。
不要把 Utility 包成 Composable
有時候會看到有人把明明是 utility 的東西,硬是包成 composable 的格式:
// ✗ 不需要這樣做
export function useFormatters() {
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat('zh-TW').format(date)
}
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('zh-TW', {
style: 'currency',
currency: 'TWD',
}).format(amount)
}
return { formatDate, formatCurrency }
}這樣做完全沒有好處,反而帶來壞處:
- 多了一層不必要的包裝
use前綴讓人誤以為裡面有響應式狀態- 使用時多了解構的步驟
直接 export function 就好了,簡單明瞭。◝( •ω• )◟
Composable 搭配 Utility 一起用
不過 composable 和 utility function 並非互斥。composable 裡面當然可以使用 utility function:
export function validateEmail(email: string) {
return /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/.test(email)
}import { computed, ref } from 'vue'
import { validateEmail } from '../utils/validators'
export function useEmailInput(initialValue = '') {
const email = ref(initialValue)
const isValid = computed(() => validateEmail(email.value))
const error = computed(() =>
email.value && !isValid.value ? '無效的 Email 格式' : null
)
return { email, isValid, error }
}validateEmail 是純粹的 util,useEmailInput 才是 composable。
各司其職,結構清晰。( •̀ ω •́ )✧
所以真的會怎麼樣嗎?
讓我們用實際的例子來感受可能會有甚麼問題,我們使用 VueUse 的 useIntervalFn 舉例。
這裡使用簡化過的 useIntervalFn,內容如下:
import { onScopeDispose, onUnmounted, ref } from 'vue'
/** 簡化版 useIntervalFn,用於示範 composable 的自動清除機制 */
export function useIntervalFnSimple(
callback: () => void,
interval: number,
) {
let timerId: ReturnType<typeof setInterval> | null = null
const isActive = ref(false)
function stop() {
if (timerId) {
clearInterval(timerId)
timerId = null
}
isActive.value = false
}
function start() {
stop()
timerId = setInterval(callback, interval)
isActive.value = true
}
start()
onScopeDispose(stop)
return { isActive, start, stop }
}重點在最後那行 onScopeDispose(stop)。它會在作用域銷毀時自動呼叫 stop(),interval 就會跟著被清除。
前提是 onScopeDispose 要在正確的時機被呼叫,也就是在元件實例的同步流程中。
接下來讓我們用實際的元件來對比看看。兩邊都是每秒 +1 的計數器,差別只在 composable 的呼叫位置。
content\blog-vue\js-util-vs-vue-composable\interval-top-level.vue
<template>
<span class="text-xs opacity-50">
interval-top-level 已安裝
</span>
</template>
<script setup lang="ts">
import { syncCount } from './interval-store'
import { useIntervalFnSimple } from './use-interval-fn-simple'
// ✓ 在同步流程中呼叫,onUnmounted 綁定元件實例
useIntervalFnSimple(() => {
syncCount.value++
}, 1000)
</script>content\blog-vue\js-util-vs-vue-composable\interval-in-timeout.vue
<template>
<span class="text-xs opacity-50">
interval-in-timeout 已安裝
</span>
</template>
<script setup lang="ts">
import { asyncCount } from './interval-store'
import { useIntervalFnSimple } from './use-interval-fn-simple'
// ✗ 模擬非同步操作(例如等待 API 回應)後才啟動 interval
// setTimeout callback 執行時,已經脫離元件的同步流程
// onUnmounted 無法正確註冊,元件銷毀後 interval 不會被清除
setTimeout(() => {
useIntervalFnSimple(() => {
asyncCount.value++
}, 1000)
}, 1000)
</script>點「銷毀元件」後觀察計數器的變化:
✓ 同步呼叫✗ 非同步呼叫左邊在同步流程中呼叫,銷毀後計數停止。右邊在 setTimeout(非同步)中呼叫,銷毀後計數還在跑。
同樣的 composable,只是呼叫時機不同,結果天差地別。( ˘•ω•˘ )
這就是為什麼 composable 必須在元件的同步流程中呼叫,否則作用域無法正確與元件實例綁定,導致 memory leak。
不過其實 Vue 在 dev 模式下會貼心地給你一個警告:
[Vue warn]: onUnmounted is called when there is no active component
instance to be associated with.
Lifecycle injection APIs can only be used during execution of setup().
If you are using async setup(), make sure to register lifecycle hooks
before the first await statement.所以如果你在 console 看到這段訊息,就表示有 composable 在非同步流程中被呼叫了,趕快檢查一下吧。( •̀ ω •́ )✧
用 Effect Scope 補救
雖然說最好的做法是把 composable 放在同步流程中呼叫,但如果真的遇到沒有辦法的情境,可以用 Vue 提供的 effectScope 來補救:
<template>
<span class="text-xs opacity-50">
interval-effect-scope 已安裝
</span>
</template>
<script setup lang="ts">
import { effectScope } from 'vue'
import { scopeCount } from './interval-store'
import { useIntervalFnSimple } from './use-interval-fn-simple'
// 在同步流程中建立 scope,會自動成為元件 scope 的子 scope,除非將 detached 設為 true
const scope = effectScope()
// ✓ 雖然在 setTimeout(非同步)中呼叫,但用 effectScope 包住
// composable 內部的 onScopeDispose 會被收集到 scope 中
// 元件銷毀時 scope 自動停止,interval 也跟著被清除
setTimeout(() => scope.run(() => {
useIntervalFnSimple(() => {
scopeCount.value++
}, 1000)
}), 1000)
</script>effectScope() 在同步流程中建立時,會自動成為元件 scope 的子 scope。元件銷毀時,子 scope 也會跟著被停止,裡面收集的 onScopeDispose、watch、computed 等 effect 都會一起清除。
實際效果如下,同樣在 setTimeout 中呼叫,但用 effectScope 包住後,銷毀元件就能正確停止 interval:
✓ effectScope 補救不過要注意的是,scope.run() 只能收集基於 onScopeDispose 的清除邏輯。如果 composable 內部是用 onUnmounted 而不是 onScopeDispose,scope.stop() 管不到喔。乁( ◔ ௰◔)「
所以寫 composable 時,建議用 onScopeDispose 取代 onUnmounted,這樣使用者不管是在同步流程還是 effectScope 中呼叫,都能正確清除,可靠性更高。( •̀ ω •́ )✧
總結 🐟
- utility function 是純粹的 JavaScript function,不依賴 Vue,隨時隨地都能呼叫
- composable 利用 Composition API 封裝有狀態的邏輯,必須在元件實例的同步流程中呼叫
<script setup>頂層、onMounted等 lifecycle hook callback 屬於同步流程,可以安全呼叫 composablewatchcallback、setTimeout、Promise.then、await之後不在同步流程中,lifecycle hook 無法正確註冊,會導致 memory leak- 非同步情境可以用
effectScope補救,但 composable 內部必須使用onScopeDispose才能被正確收集 - 不要把純粹的 util function 包成 composable,
use前綴應該保留給有響應式狀態、生命週期管理的 function - 寫 composable 時建議用
onScopeDispose取代onUnmounted,相容性更好
感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。有錯誤還請多多指教。( ´∀`)~♥