Vue3 組合式函式(Composables)
主題:可重用邏輯封裝
簡介
在 Vue 2 時代,我們常透過 mixins、extend 或是 $emit / $on 來共享程式碼。這些方式雖然能達到「重用」的目的,但往往會產生 命名衝突、隱式依賴,以及 難以追蹤的副作用。
Vue 3 引入的 Composition API,讓開發者可以把「一段功能」抽離成 Composable(組合式函式),以 純函式 的方式封裝可重用邏輯。Composable 不僅解決了舊有技巧的缺點,還能讓程式碼更具可讀性、可測試性與可維護性,成為中大型專案的最佳實踐。
本篇文章將從 概念說明、實作範例、常見陷阱、最佳實踐,一步步帶你掌握如何在 Vue3 中建立與使用可重用的 Composable,讓你的專案從此更乾淨、更有彈性。
核心概念
1️⃣ 為什麼需要 Composable
- 邏輯分離:把 UI(template)與行為(logic)分離,讓同一段邏輯可以在多個元件間共享。
- 避免命名衝突:每個 composable 都是獨立的函式,返回的
ref、reactive、computed等變數都在自己的作用域內,不會與其他 composable 產生衝突。 - 提升可測試性:因為 composable 本質上是純函式,僅依賴參數與返回值,撰寫單元測試變得非常簡單。
Tip:如果你發現同一段程式碼在兩個以上的元件中出現,這通常就是抽成 composable 的好時機。
2️⃣ 基本寫法
一個最簡單的 composable 只需要一個普通的 JavaScript ,內部使用 Vue3 提供的 reactive API,最後把需要對外暴露的資料或方法回傳:
// src/composables/useCounter.js
import { ref } from 'vue'
export function useCounter(initial = 0) {
const count = ref(initial)
function inc(step = 1) {
count.value += step
}
function dec(step = 1) {
count.value -= step
}
// 回傳的物件會在使用的元件中解構
return { count, inc, dec }
}
在元件中使用方式:
<script setup>
import { useCounter } from '@/composables/useCounter'
const { count, inc, dec } = useCounter(10)
</script>
<template>
<div>
<p>目前值:{{ count }}</p>
<button @click="inc()">+1</button>
<button @click="dec()">-1</button>
</div>
</template>
重點:
useCounter只是一個普通函式,不會與 Vue 元件的生命週期產生耦合,因此可以在任何地方(包括 Pinia store、測試檔)直接呼叫。
3️⃣ 常見範例
下面提供 3~5 個實務中常見的 composable,每個範例都帶有完整註解,說明其設計思路與使用方式。
3.1 useFetch – 抽象化的資料取得
// src/composables/useFetch.js
import { ref, onMounted, watch } from 'vue'
/**
* @param {string|Ref<string>} url - 要抓取的 API 位址,支援 Ref 動態變化
* @param {object} [options] - fetch 的設定
* @returns {object} { data, error, loading, refetch }
*/
export function useFetch(url, options = {}) {
const data = ref(null)
const error = ref(null)
const loading = ref(false)
// 真正執行 fetch 的函式,允許外部手動呼叫
async function fetchData() {
loading.value = true
error.value = null
try {
const response = await fetch(unref(url), options)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
data.value = await response.json()
} catch (e) {
error.value = e
} finally {
loading.value = false
}
}
// 初始載入
onMounted(fetchData)
// 若 url 為 Ref,監聽變化自動重新抓取
if (isRef(url)) {
watch(url, () => {
fetchData()
})
}
return { data, error, loading, refetch: fetchData }
}
使用範例:
<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)
function changeApi() {
api.value = 'https://api.example.com/comments'
}
</script>
<template>
<div v-if="loading">載入中…</div>
<div v-else-if="error">錯誤:{{ error.message }}</div>
<pre v-else>{{ data }}</pre>
<button @click="refetch">重新載入</button>
<button @click="changeApi">切換 API</button>
</template>
說明:此 composable 把 API 請求、狀態管理、錯誤處理 完全封裝,元件只要關注 UI,保持單一職責。
3.2 useEventListener – 簡化全域或元素事件
// src/composables/useEventListener.js
import { onMounted, onBeforeUnmount } from 'vue'
/**
* 為指定目標 (window / element) 加上事件監聽
* @param {EventTarget|Ref<EventTarget>} target - 監聽對象
* @param {string} type - 事件名稱
* @param {Function} handler - 事件處理函式
* @param {Object} [options] - addEventListener 的選項
*/
export function useEventListener(target, type, handler, options) {
let cleanup = null
onMounted(() => {
const el = typeof target === 'function' ? target() : target
if (!el) return
el.addEventListener(type, handler, options)
cleanup = () => el.removeEventListener(type, handler, options)
})
onBeforeUnmount(() => {
if (cleanup) cleanup()
})
}
使用範例(監聽視窗捲動):
<script setup>
import { ref } from 'vue'
import { useEventListener } from '@/composables/useEventListener'
const scrollY = ref(0)
function onScroll() {
scrollY.value = window.scrollY
}
// 直接在 setup 裡呼叫即可
useEventListener(window, 'scroll', onScroll)
</script>
<template>
<p>目前捲動高度:{{ scrollY }} px</p>
</template>
技巧:
useEventListener能接受 Ref 作為目標,讓你在元素還未掛載時也能安全使用。
3.3 useLocalStorage – 同步狀態至瀏覽器儲存
// src/composables/useLocalStorage.js
import { ref, watch } from 'vue'
/**
* 把一個 ref 同步至 localStorage
* @param {string} key - localStorage 的鍵名
* @param {any} defaultValue - 若無資料時的預設值
* @returns {Ref<any>}
*/
export function useLocalStorage(key, defaultValue) {
const stored = localStorage.getItem(key)
const data = ref(stored ? JSON.parse(stored) : defaultValue)
// 當 data 改變時自動寫回 localStorage
watch(
data,
(val) => {
localStorage.setItem(key, JSON.stringify(val))
},
{ deep: true }
)
return data
}
使用範例(保存暗黑模式設定):
<script setup>
import { computed } from 'vue'
import { useLocalStorage } from '@/composables/useLocalStorage'
const isDark = useLocalStorage('dark-mode', false)
const themeClass = computed(() => (isDark.value ? 'dark' : 'light'))
function toggleTheme() {
isDark.value = !isDark.value
}
</script>
<template>
<div :class="themeClass">
<button @click="toggleTheme">
切換為 {{ isDark ? '亮色' : '暗色' }} 模式
</button>
</div>
</template>
<style scoped>
.dark { background:#222; color:#fff; }
.light { background:#fff; color:#222; }
</style>
說明:只要在任何元件內
import並呼叫useLocalStorage,即能在多個元件間共享同一筆持久化資料。
3.4 useFormValidator – 表單驗證的通用解決方案
// src/composables/useFormValidator.js
import { reactive, computed } from 'vue'
/**
* 建立一個簡易的表單驗證器
* @param {object} rules - { fieldName: (value) => string | null }
* @returns {object} { values, errors, isValid, validateField, validateAll }
*/
export function useFormValidator(rules) {
const values = reactive({})
const errors = reactive({})
// 為每個欄位建立 getter / setter
Object.keys(rules).forEach((field) => {
values[field] = ''
errors[field] = null
})
function validateField(field) {
const validator = rules[field]
const errorMsg = validator(values[field])
errors[field] = errorMsg
return !errorMsg
}
function validateAll() {
let ok = true
Object.keys(rules).forEach((field) => {
if (!validateField(field)) ok = false
})
return ok
}
const isValid = computed(() => {
return Object.values(errors).every((e) => e === null)
})
return { values, errors, isValid, validateField, validateAll }
}
使用範例(簡易註冊表單):
<script setup>
import { useFormValidator } from '@/composables/useFormValidator'
const { values, errors, isValid, validateAll } = useFormValidator({
email: (v) => (!v ? '必填' : !/^\S+@\S+\.\S+$/.test(v) ? '格式錯誤' : null),
password: (v) => (v.length < 6 ? '至少 6 個字元' : null),
})
function submit() {
if (validateAll()) {
alert('表單驗證通過!')
}
}
</script>
<template>
<form @submit.prevent="submit">
<div>
<label>Email:</label>
<input v-model="values.email" @blur="validateField('email')" />
<span class="err">{{ errors.email }}</span>
</div>
<div>
<label>Password:</label>
<input type="password" v-model="values.password" @blur="validateField('password')" />
<span class="err">{{ errors.password }}</span>
</div>
<button :disabled="!isValid">送出</button>
</form>
</template>
<style scoped>
.err { color: red; font-size: 0.9em; }
</style>
重點:
useFormValidator把 欄位狀態、驗證規則、錯誤訊息 全部封裝,讓表單元件只需要關心 UI,驗證邏輯可在多個表單間共用。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 / 最佳實踐 |
|---|---|---|
在 composable 中直接使用 this |
this 只在 Options API 中有意義,Composition API 的函式沒有 this 綁定。 |
直接使用 ref、reactive、computed,或把需要的參數以 參數 形式傳入。 |
| 返回的 reactive 物件被解構 | 解構會失去響應式(例如 const { count } = useCounter() 會變成普通值)。 |
保留整個物件,或使用 toRefs、storeToRefs,或在解構前使用 const { count } = toRefs(counter)。 |
在 composable 中直接使用 onMounted、onUnmounted 等生命週期 |
若 composable 被呼叫在 非元件上下文(如 Pinia store)會拋錯。 | 把生命週期掛鉤放在 使用端(元件)或使用 tryOnMounted(VueUse)等安全封裝。 |
| 過度抽象 | 把太小的功能抽成 composable 會導致檔案過多、維護成本上升。 | 只對 可重用且具體的 邏輯抽象;若僅在單一元件使用,保持在元件內即可。 |
| 共享狀態意外變更 | 多個元件同時使用同一個 composable 回傳的 reactive 物件,會產生全局共享的副作用。 |
若需要 獨立實例,在 composable 內部建立新的 ref/reactive(如 useCounter() 每次呼叫都返回新物件)。若需要 全局共享,可考慮使用 Pinia 或 provide/inject。 |
最佳實踐小結
- 命名慣例:所有 composable 函式統一以
use開頭(useXxx),方便辨識。 - 保持純函式:除非真的需要生命週期掛鉤,否則 composable 內部應避免副作用。
- 型別支援:使用 TypeScript 時,為每個返回值寫明確的型別,提升 IDE 補全與錯誤檢查。
- 文件化:每個 composable 建議在檔案頂部寫上 JSDoc,說明參數、回傳值與使用情境。
- 測試:把 composable 抽成 純函式 後,就能用 Jest / Vitest 輕鬆寫單元測試,確保邏輯正確。
實際應用場景
| 場景 | 為何使用 composable | 典型範例 |
|---|---|---|
| 多頁面共用的 API 請求 | 統一錯誤處理、loading 狀態、快取策略 | useFetch、useAxios |
| 全局 UI 互動(如彈窗、Toast) | 讓任何元件都能呼叫 showToast('訊息'),不必透過事件總線 |
useToast |
| 跨元件的表單驗證 | 把驗證規則與錯誤訊息抽離,保持表單 UI 輕量 | useFormValidator |
| 螢幕尺寸偵測 & 響應式斷點 | 在不同元件內部直接取得當前斷點,避免重複寫監聽 | useBreakpoints |
| 本地化 & 多語系切換 | 把 i18n 初始化與語言切換封裝,提供全局 t() 函式 |
useI18n(Vue I18n 官方提供) |
| 權限控制 | 依據使用者角色返回可執行的操作列表,元件只負責顯示 | useAuth、usePermission |
案例說明:假設你在一個電商平台,需要在商品列表與商品詳情頁都使用「加入購物車」的功能。透過
useCartcomposable,你可以在兩個頁面分別呼叫addItem(product),而所有的狀態(購物車內容、總金額)都集中管理,且只需要在一處修改即時更新 UI,避免重複寫localStorage、watch等邏輯。
總結
- Composable 是 Vue3 中最核心的可重用邏輯封裝機制,讓我們能把「資料取得、事件監聽、狀態同步、表單驗證」等功能抽離成獨立、可測試的函式。
- 正確的 命名、純函式設計、適度抽象,可以大幅提升專案的可維護性與開發效率。
- 透過本文的 實作範例(
useCounter、useFetch、useEventListener、useLocalStorage、useFormValidator),你已掌握從 簡單計數器 到 完整表單驗證 的全流程。 - 在實務開發中,務必留意 共享狀態與生命週期 的細節,遵守最佳實踐,才能避免常見陷阱,讓 composable 真正發揮「一次編寫、處處使用」的威力。
最後的建議:在新專案或既有專案的重構階段,先挑選 兩個以上元件共用的功能,試著寫成 composable,感受它帶來的程式碼乾淨度與可測試性提升,你會發現這是一條通往更高品質 Vue 應用的捷徑。祝開發順利! 🚀