寫 Vue 寫久了,一定會遇到這種情況:A 組件寫了一段邏輯,過兩天 B 組件也要一模一樣的東西。
最直覺的做法就是複製貼上。能動,但接下來的事大家應該都經歷過:某天發現那段邏輯有 bug,修了 A 忘了 B;或是想加個功能,要記得每個地方都改一次。複製的次數越多,維護的地雷就越多。
還記得 Day 14 寫過的 useModal 嗎?當時重點放在模板 ref,沒有細講為什麼它要叫 use 開頭、為什麼可以獨立成一個檔案。今天就來把這件事講清楚:Composables(組合式函式)。
用官方文件也拿來當例子的「追蹤滑鼠座標」來說明,直接寫在組件裡長這樣:
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const x = ref(0)
const y = ref(0)
function update(e) {
x.value = e.pageX
y.value = e.pageY
}
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
</script>
<template>
<p>滑鼠位置:{{ x }}, {{ y }}</p>
</template>
這段本身沒什麼問題。問題是當第二個、第三個組件也想要滑鼠座標時,這十幾行就會跟著到處跑。
既然是重複的邏輯,那就抽成一個函式:
// composables/useMouse.js
import { ref, onMounted, onUnmounted } from 'vue'
export function useMouse() {
const x = ref(0)
const y = ref(0)
function update(e) {
x.value = e.pageX
y.value = e.pageY
}
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
return { x, y }
}
組件裡只剩一行:
<script setup>
import { useMouse } from './composables/useMouse'
const { x, y } = useMouse()
</script>
<template>
<p>滑鼠位置:{{ x }}, {{ y }}</p>
</template>
看起來很簡單,就是把程式碼搬到另一個檔案而已。但這裡面其實藏了三個值得想一想的問題:
formatDate() 就不算?ref 和 onMounted 不是組件的東西嗎?為什麼可以寫在組件外面?x,而不是 x.value?先想想 formatDate(date) 這種工具函式:給它一個日期,它還你一個字串,然後就結束了。呼叫完之後,它不會「記得」任何事情,也不會再做任何事情。
但滑鼠座標不一樣。useMouse() 呼叫完之後,座標還要持續更新,組件卸載時還要記得把監聽拆掉。要做到這兩件事,它需要 utils 沒有的東西:
x、y 這兩個 ref
onMounted 綁事件、onUnmounted 拆事件命名上的慣例是用 use 開頭,例如 useMouse、useFetch、useModal,看到名字就知道「這裡面有響應式狀態,而且要在 setup 裡呼叫」。檔案通常會集中放在 composables/ 資料夾。
這題要拆成兩半講,因為 ref 和 onMounted 能搬出去的原因不一樣。
回想 Day 3 自己用 Proxy 寫的迷你 reactive,那段程式碼裡有出現任何組件嗎?沒有。它就是 Proxy 攔截讀寫、收集依賴、觸發更新,純粹的 JavaScript。
Vue 的響應式系統也是一樣,它是一個獨立的模組(@vue/reactivity),跟組件沒有綁在一起。所以 ref 寫在 .vue 檔裡可以動,寫在一般的 .js 檔裡也可以動。
onMounted 就沒那麼單純了。仔細看它的用法:
onMounted(() => { ... })
我們從頭到尾都沒告訴它「要掛在哪個組件上」。那 Vue 怎麼知道的?
原因是 Vue 在執行某個組件的 setup 時,會先把「目前正在 setup 的組件實例」記下來。onMounted 被呼叫時,就把 callback 掛到這個實例身上。
composable 說穿了只是一個普通函式,當它在 <script setup> 裡被呼叫時,那個「目前實例」還在,所以裡面的 onMounted 自然掛得上去。
這也帶出了 composable 最重要的使用規則:要在 setup 頂層同步呼叫。
// ❌ 在事件裡才呼叫
function handleClick() {
const { x, y } = useMouse()
}
按鈕被點的時候,setup 早就跑完了,「目前實例」已經不在,onMounted 找不到人可以掛,Vue 會在 console 丟出警告,事件也不會被綁上去。
同樣的道理,在 await 之後才呼叫也有風險,因為 await 之後的程式碼已經是在之後的某個時間點才執行:
// ⚠️ await 之後,目前實例可能已經不在了
const data = await fetchSomething()
const { x, y } = useMouse()
既然 composable 就是普通函式,每次呼叫都會重新執行一次 ref(0),所以 A 組件和 B 組件各自呼叫 useMouse(),拿到的是兩份互不相干的狀態。這跟上一篇 provide / inject「大家共用同一份」是不同的情境,選哪個看需求。
假設回傳時寫成這樣:
// ❌ 回傳的是快照
return { x: x.value, y: y.value }
組件拿到的 x 是什麼?是一個數字,呼叫當下的值,也就是 0。之後滑鼠怎麼移動,那個 0 都不會變,因為它已經跟 ref 斷開了。
所以慣例是回傳一個裝著 ref 的普通物件:
return { x, y }
這樣做的好處是組件可以直接解構,解構出來的 x、y 依然是 ref,響應性不會丟失,模板裡也會自動解包,不用寫 .value。
那如果回傳的是 reactive 呢?
const state = reactive({ x: 0, y: 0 })
return state
組件一解構 const { x, y } = useMouse(),就會拿到兩個普通的數字。原因跟 Day 4 講過的一樣:解構等於把屬性的值讀出來,而讀出來的是值本身,不再經過 Proxy。
真的想用 reactive 管理內部狀態的話,回傳時包一層 toRefs,把每個屬性轉成 ref:
const state = reactive({ x: 0, y: 0 })
return toRefs(state)
不過比起每次都要記得 toRefs,直接用 ref 組成普通物件回傳,會是比較不容易出錯的寫法。
composable 看起來只是「把程式碼搬到另一個檔案」,但能這樣搬,背後其實是 Vue 3 的設計:
ref 可以到處寫useXxx 登場的時候了。