Vue3 Composition API(核心)— 組合函式(Composables)
簡介
在 Vue 3 中,Composition API 為我們提供了一種全新的組織程式碼的方式。相較於 Options API,Composition API 讓邏輯更易於抽取、重用與測試,而 組合函式(Composables) 正是這個概念的核心。
透過 composable,我們可以把「相關的 state、computed、lifecycle 與 side‑effect」封裝成一個獨立的函式,然後在多個元件中直接引用。這不僅讓程式碼更乾淨,也提升了團隊協作的效率,因為每個功能模組都能像 npm 套件一樣被管理與維護。
本文將從概念說明、實作範例、常見陷阱到最佳實踐,完整闡述如何在 Vue 3 中打造高品質的 composable,協助 初學者到中級開發者 快速上手並在專案中落地。
核心概念
1. 什麼是 Composable?
- Composable 本質上是一個 普通的 JavaScript 函式,它在內部使用 Vue 的
reactive、ref、computed、watch、onMounted等 API,然後把需要對外提供的資料或功能返回。 - 它不依賴於任何特定的元件實例,因而可以在 任意組件、甚至 純 JavaScript 模組 中使用。
重點:Composable 只是一段「可組合」的邏輯,與元件的模板(template)是分離的。
2. 為什麼要使用 Composable?
| 傳統 Options API | 使用 Composable |
|---|---|
data、methods、computed、watch 混雜在同一個物件裡 |
邏輯依功能分離,易於重用 |
| 同類型的功能散落在多個生命週期鉤子中 | 相關的 state、side‑effect 集中在同一函式 |
| 測試困難,必須掛載整個元件 | 只測試純函式,無需 Vue 實例 |
| 大型元件會變成「巨型」的 Options 物件 | 小而專注的模組,提升可讀性 |
3. 建立 Composable 的基本步驟
- 建立檔案(慣例以
use開頭,例如useFetch.js)。 - 在函式內部使用 Vue API:
ref、reactive、computed、watch、onMounted… - 返回想要外部存取的值(可以是單一值、物件或函式)。
- 在元件中引入並呼叫,把返回的內容解構使用。
下面將透過多個實務範例說明每個步驟的細節。
程式碼範例
範例 1️⃣:useCounter — 最簡單的計數器
// src/composables/useCounter.js
import { ref } from 'vue'
/**
* 回傳一個計數器的 state 與操作函式
* @param {number} [initial=0] 初始值
*/
export function useCounter(initial = 0) {
const count = ref(initial)
const increment = () => count.value++
const decrement = () => count.value--
const reset = () => (count.value = initial)
// 只回傳需要的部分,保持 API 簡潔
return { count, increment, decrement, reset }
}
使用方式
<template>
<div>
<p>目前計數:{{ count }}</p>
<button @click="increment">+</button>
<button @click="decrement">-</button>
<button @click="reset">重設</button>
</div>
</template>
<script setup>
import { useCounter } from '@/composables/useCounter'
const { count, increment, decrement, reset } = useCounter(10)
</script>
說明:
useCounter完全不依賴於任何元件,僅返回ref與操作函式,讓多個元件可以共享相同的計數邏輯。
範例 2️⃣:useFetch — 簡易的資料抓取 (含錯誤處理 & loading)
// src/composables/useFetch.js
import { ref, watchEffect } from 'vue'
/**
* 依據傳入的 URL 取得資料
* @param {string|Ref<string>} url
*/
export function useFetch(url) {
const data = ref(null)
const error = ref(null)
const loading = ref(false)
const fetchData = async (target) => {
loading.value = true
error.value = null
try {
const res = await fetch(target)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.value = await res.json()
} catch (e) {
error.value = e
} finally {
loading.value = false
}
}
// 支援 url 為 Ref 時自動重新抓取
watchEffect(() => {
const target = typeof url === 'string' ? url : url.value
if (target) fetchData(target)
})
// 允許手動重新抓取
const refetch = () => fetchData(typeof url === 'string' ? url : url.value)
return { data, error, loading, refetch }
}
使用方式
<template>
<div v-if="loading">載入中…</div>
<div v-else-if="error">錯誤:{{ error.message }}</div>
<pre v-else>{{ data }}</pre>
<button @click="refetch">重新載入</button>
</template>
<script setup>
import { ref } from 'vue'
import { useFetch } from '@/composables/useFetch'
const api = ref('https://api.example.com/posts')
const { data, error, loading, refetch } = useFetch(api)
</script>
說明:
watchEffect讓url為ref時自動重新發送請求;refetch提供手動觸發的彈性。此 composable 可在任何需要資料抓取的元件中直接使用。
範例 3️⃣:useWindowSize — 監聽視窗尺寸(含防抖)
// src/composables/useWindowSize.js
import { ref, onMounted, onUnmounted } from 'vue'
/**
* 取得視窗寬高,支援防抖
* @param {number} [delay=200] 防抖毫秒
*/
export function useWindowSize(delay = 200) {
const width = ref(window.innerWidth)
const height = ref(window.innerHeight)
let timer = null
const onResize = () => {
clearTimeout(timer)
timer = setTimeout(() => {
width.value = window.innerWidth
height.value = window.innerHeight
}, delay)
}
onMounted(() => window.addEventListener('resize', onResize))
onUnmounted(() => {
window.removeEventListener('resize', onResize)
clearTimeout(timer)
})
return { width, height }
}
使用方式
<template>
<p>寬度:{{ width }}px, 高度:{{ height }}px</p>
</template>
<script setup>
import { useWindowSize } from '@/composables/useWindowSize'
const { width, height } = useWindowSize(100) // 100ms 防抖
</script>
說明:此 composable 把
addEventListener與removeEventListener的清理工作交給onMounted/onUnmounted,確保不會造成記憶體泄漏。
範例 4️⃣:useLocalStorage — 同步 Ref 到 localStorage
// src/composables/useLocalStorage.js
import { ref, watch } from 'vue'
/**
* 把一個 ref 同步到 localStorage
* @param {string} key localStorage 的鍵名
* @param {*} initial 初始值(若 localStorage 沒有則使用此值)
*/
export function useLocalStorage(key, initial) {
const stored = localStorage.getItem(key)
const state = ref(stored ? JSON.parse(stored) : initial)
// 任何時候 state 改變,都寫回 localStorage
watch(
state,
(val) => {
if (val === undefined) {
localStorage.removeItem(key)
} else {
localStorage.setItem(key, JSON.stringify(val))
}
},
{ deep: true }
)
return state
}
使用方式
<template>
<input v-model="name" placeholder="輸入名字" />
<p>持久化的名字:{{ name }}</p>
</template>
<script setup>
import { useLocalStorage } from '@/composables/useLocalStorage'
const name = useLocalStorage('user-name', '')
</script>
說明:只要
name被修改,就會自動同步到localStorage,頁面重新載入時仍能恢復先前的值。
範例 5️⃣:useFormValidator — 表單驗證的通用邏輯
// src/composables/useFormValidator.js
import { ref, computed } from 'vue'
/**
* 建立表單驗證器
* @param {Object} rules 欄位驗證規則,形如 { field: (value) => errorMessage|null }
* @param {Object} initialValues 初始表單值
*/
export function useFormValidator(rules, initialValues = {}) {
const values = ref({ ...initialValues })
const errors = ref({})
const validateField = (field) => {
const validator = rules[field]
if (!validator) return null
const error = validator(values.value[field])
errors.value[field] = error
return error
}
const validateAll = () => {
let isValid = true
Object.keys(rules).forEach((field) => {
const err = validateField(field)
if (err) isValid = false
})
return isValid
}
const isValid = computed(() => {
return Object.values(errors.value).every((e) => e === null)
})
const onSubmit = (callback) => {
if (validateAll()) callback({ ...values.value })
}
return {
values,
errors,
isValid,
validateField,
validateAll,
onSubmit,
}
}
使用方式
<template>
<form @submit.prevent="handleSubmit">
<input v-model="values.email" @blur="validateField('email')" placeholder="Email" />
<p v-if="errors.email" class="error">{{ errors.email }}</p>
<input type="password" v-model="values.password" @blur="validateField('password')" placeholder="Password" />
<p v-if="errors.password" class="error">{{ errors.password }}</p>
<button :disabled="!isValid">送出</button>
</form>
</template>
<script setup>
import { useFormValidator } from '@/composables/useFormValidator'
const rules = {
email: (v) => (!v ? '必填' : !/.+@.+\..+/.test(v) ? '格式錯誤' : null),
password: (v) => (!v ? '必填' : v.length < 6 ? '至少 6 個字元' : null),
}
const {
values,
errors,
isValid,
validateField,
onSubmit,
} = useFormValidator(rules, { email: '', password: '' })
const handleSubmit = () => {
onSubmit((formData) => {
// 送出 API
console.log('送出資料', formData)
})
}
</script>
<style scoped>
.error { color: red; font-size: 0.9em; }
</style>
說明:此 composable 把表單狀態、驗證規則與提交流程抽離,讓不同表單只需要提供自己的
rules與initialValues即可。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 / 最佳實踐 |
|---|---|---|
在 composable 中直接使用 this |
this 只在 Options API 中指向 Vue 實例,Composition API 裡沒有。 |
只使用 ref、reactive、computed 等返回的值;若需要存取全域屬性,使用 provide/inject 或 app.config.globalProperties。 |
| 返回非 reactive 的資料 | 若返回的物件屬性不是 ref/reactive,元件不會自動更新。 |
確保所有需要響應的值都包在 ref、reactive 或 computed 中;或在返回前使用 toRefs 轉換。 |
在 composable 中直接呼叫元件生命週期 (mounted、created 等) |
只能在 setup 或 setup 內部的函式中使用 onMounted、onUnmounted 等。 |
把生命週期掛鉤寫在 composable 本身(如 useWindowSize),或在元件中自行調用。 |
| 忘記清理副作用 (事件監聽、訂閱、定時器) | 會導致記憶體泄漏或重複觸發。 | 在 composable 裡使用 onMounted / onUnmounted 或 try…finally 來釋放資源。 |
| 過度抽象 | 把太多不相關的邏輯塞進同一個 composable,導致難以維護。 | 單一職責原則:每個 composable 應該只解決一類問題,例如「資料抓取」或「視窗尺寸」。 |
在 composable 內部直接存取 localStorage/sessionStorage |
SSR(Server‑Side Rendering)環境下會報錯,因為 window 不存在。 |
在使用前先檢查 typeof window !== 'undefined',或把存取封裝在 try…catch 中。 |
最佳實踐小結
- 檔案命名:以
use為前綴(useXxx),保持一致性。 - 返回值:盡量返回 最小化的 API,避免把不必要的內部變數暴露。
- 類型支援:若使用 TypeScript,為每個 composable 撰寫明確的型別定義,提升開發體驗。
- 文件化:為每個 composable 撰寫 JSDoc,說明參數、回傳值與使用範例。
- 測試:使用 Jest / Vitest 撰寫單元測試,只測試函式本身的行為,無需掛載 Vue 元件。
實際應用場景
| 場景 | 可使用的 Composable | 為什麼適合 |
|---|---|---|
| 多頁面共用的使用者認證狀態 | useAuth(封裝 token、login、logout、auto‑refresh) |
認證資訊在多個元件間共享,且需要在路由守衛中使用。 |
| 即時資料流(WebSocket) | useWebSocket(連線、重連、訊息分派) |
WebSocket 需要在多個畫面中保持單例,composable 能把連線抽離並自動管理生命週期。 |
| 表單驗證與提交 | useFormValidator、useSubmit |
不同表單只要提供規則即可,驗證邏輯不必重寫。 |
| 多語系切換 | useI18n(切換語系、載入語言檔) |
語系狀態是全局的,且在任意元件都需要即時反應。 |
| 動畫與過渡控制 | useTransition(返回狀態、觸發函式) |
把 CSS/JS 動畫的觸發與狀態抽離,讓 UI 元件更乾淨。 |
| SEO 友好的 SSR 資料預取 | useAsyncData(在 setup 中返回 Promise) |
在 Nuxt 或 Vite‑SSR 中,composable 能與框架的資料預取機制無縫結合。 |
案例說明:假設我們開發一個 Dashboard,裡面有多個圖表需要即時更新。可以建立
useChartData,內部透過fetch或WebSocket取得資料,並返回ref給圖表元件。所有圖表只要引用同一個 composable,就能共享同一筆資料,避免重複請求。
總結
- Composable 是 Vue 3 Composition API 的核心抽象,讓我們能把 state、computed、side‑effect 等邏輯封裝成可重用的函式。
- 透過 簡潔的命名規則、單一職責、適時的生命週期清理,我們可以寫出 可測試、易維護、可擴充 的程式碼。
- 本文提供了 五個實用範例(計數器、資料抓取、視窗尺寸、localStorage 同步、表單驗證),示範從 基本概念 到 進階應用 的全流程。
- 識別常見陷阱、遵守最佳實踐,能讓 composable 在大型專案中發揮最大效益,提升開發速度與團隊協作品質。
掌握了 composable 後,你將能在 Vue 3 中 像組裝 LEGO 一樣,把功能模組化、組合化,打造出更加彈性且可維護的前端應用。祝你寫程式愉快,持續探索 Vue 3 的無限可能! 🚀