Vue3 組合式函式(Composables)── 何謂 Composable?
簡介
在 Vue 3 推出 Composition API 後,開發者可以把「屬性、方法、生命週期」等邏輯抽離成可重用的函式,這類函式被稱為 Composable。
Composable 不只是程式碼的「工具箱」,更是一種思考與組織的方式:將功能相近的邏輯聚合在一起,讓組件保持簡潔、易讀、易測。
對於從 Options API 轉換到 Composition API 的開發者,或是剛踏入 Vue 生態系的新手,掌握 Composable 的概念與寫作技巧,等同於掌握了 Vue 應用的模組化基礎。本文將從概念說明、實作範例、常見陷阱與最佳實踐,逐步帶你建立對 Composable 的完整認知,並提供實務上可直接套用的範例。
核心概念
1. Composable 是什麼?
- Composable 本質上是一個 純函式(pure function),它接受參數,返回一組 reactive(響應式)狀態或方法。
- 它遵循 「以功能為單位」 的設計原則,類似於 React 的 Hook。
- 命名慣例上,必須以
use為前綴(例如useFetch、useMouse),以便在程式碼中一眼辨識。
重點:Composable 不會直接改變組件的
setup內部結構,而是把需要的邏輯抽出,讓setup只負責「組合」這些函式。
2. 為什麼要使用 Composable?
| 目的 | 好處 |
|---|---|
| 重用邏輯 | 多個組件可以共用同一套資料取得、表單驗證或動畫控制的程式碼。 |
| 分離關注點 | 把「狀態管理」與「UI 表現」分開,程式碼更易維護。 |
| 提升測試性 | 純函式易於單元測試,無需掛載整個 Vue 實例。 |
| 支援 TypeScript | 以函式簽名為基礎,TypeScript 推斷更精確。 |
3. Composable 的基本結構
import { ref, computed, onMounted, onUnmounted } from 'vue'
export function useExample(initialValue) {
// 1. 定義 reactive 狀態
const count = ref(initialValue)
// 2. 定義計算屬性
const double = computed(() => count.value * 2)
// 3. 定義行為(方法)
function increment(step = 1) {
count.value += step
}
// 4. 使用生命週期(如有需要)
onMounted(() => {
console.log('useExample 已掛載')
})
// 5. 回傳需要在組件中使用的項目
return {
count,
double,
increment
}
}
說明:以上範例展示了
ref、computed、onMounted的基本使用,並將它們封裝在useExample函式中,供外部setup直接解構取得。
程式碼範例
以下提供 5 個實務常見的 Composable,每個範例均附上註解與使用方式。
1️⃣ useFetch – 簡易資料請求
// useFetch.js
import { ref, onMounted } from 'vue'
import axios from 'axios'
/**
* @param {string} url - 要請求的 API 位址
* @param {object} [options] - axios 設定 (optional)
* @returns {object} { data, error, loading, refetch }
*/
export function useFetch(url, options = {}) {
const data = ref(null)
const error = ref(null)
const loading = ref(false)
async function fetchData() {
loading.value = true
error.value = null
try {
const response = await axios.get(url, options)
data.value = response.data
} catch (err) {
error.value = err
} finally {
loading.value = false
}
}
// 初始載入
onMounted(fetchData)
// 允許外部重新呼叫
function refetch(newUrl) {
if (newUrl) url = newUrl
fetchData()
}
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 { useFetch } from '@/composables/useFetch'
const { data, error, loading, refetch } = useFetch('https://api.example.com/posts')
</script>
技巧:
useFetch內部使用ref包裝回傳值,使得組件在資料變化時自動重新渲染。
2️⃣ useWindowSize – 監聽視窗尺寸
// useWindowSize.js
import { ref, onMounted, onUnmounted } from 'vue'
export function useWindowSize() {
const width = ref(window.innerWidth)
const height = ref(window.innerHeight)
function onResize() {
width.value = window.innerWidth
height.value = window.innerHeight
}
onMounted(() => window.addEventListener('resize', onResize))
onUnmounted(() => window.removeEventListener('resize', onResize))
return { width, height }
}
使用方式:
<template>
<p>螢幕寬度:{{ width }}px, 高度:{{ height }}px</p>
</template>
<script setup>
import { useWindowSize } from '@/composables/useWindowSize'
const { width, height } = useWindowSize()
</script>
重點:透過
onMounted/onUnmounted自動註冊與移除事件,避免記憶體泄漏。
3️⃣ useLocalStorage – 同步到 LocalStorage
// useLocalStorage.js
import { ref, watch } from 'vue'
/**
* 讓一個 reactive 變數自動同步到 localStorage
* @param {string} key - localStorage 的鍵名
* @param {*} defaultValue - 初始值
*/
export function useLocalStorage(key, defaultValue) {
const stored = localStorage.getItem(key)
const data = ref(stored ? JSON.parse(stored) : defaultValue)
// 當 data 改變時自動寫回 localStorage
watch(
data,
(newVal) => {
localStorage.setItem(key, JSON.stringify(newVal))
},
{ deep: true }
)
return data
}
使用方式:
<template>
<input v-model="name" placeholder="輸入姓名" />
<p>儲存的姓名:{{ name }}</p>
</template>
<script setup>
import { useLocalStorage } from '@/composables/useLocalStorage'
const name = useLocalStorage('user-name', '')
</script>
說明:
watch內使用{ deep: true },確保物件或陣列的深層變更也會同步。
4️⃣ useDebounce – 防抖函式
// useDebounce.js
import { ref, watch } from 'vue'
/**
* 防抖一個值,只有在指定延遲時間內沒有變化時才更新
* @param {*} source - 要防抖的原始值(ref、reactive、或普通值)
* @param {number} delay - 延遲毫秒數
* @returns {ref} - 防抖後的值
*/
export function useDebounce(source, delay = 300) {
const debounced = ref(source.value ?? source)
let timeout
watch(
() => (source.value !== undefined ? source.value : source),
(newVal) => {
clearTimeout(timeout)
timeout = setTimeout(() => {
debounced.value = newVal
}, delay)
}
)
return debounced
}
使用方式:
<template>
<input v-model="keyword" placeholder="搜尋關鍵字" />
<p>防抖後的關鍵字:{{ debouncedKeyword }}</p>
</template>
<script setup>
import { ref } from 'vue'
import { useDebounce } from '@/composables/useDebounce'
const keyword = ref('')
const debouncedKeyword = useDebounce(keyword, 500) // 0.5 秒防抖
</script>
應用:在搜尋框、表單驗證等需要減少過度觸發的情境非常實用。
5️⃣ usePermission – 瀏覽器權限檢測
// usePermission.js
import { ref, onMounted } from 'vue'
/**
* 監聽瀏覽器權限(如 geolocation、notifications)
* @param {PermissionName} name - 權限名稱
*/
export function usePermission(name) {
const status = ref('prompt') // default
async function queryPermission() {
if (!navigator.permissions) return
const result = await navigator.permissions.query({ name })
status.value = result.state
result.onchange = () => {
status.value = result.state
}
}
onMounted(queryPermission)
return { status }
}
使用方式:
<template>
<p>定位權限狀態:{{ status }}</p>
<button @click="requestLocation">取得位置</button>
</template>
<script setup>
import { usePermission } from '@/composables/usePermission'
const { status } = usePermission('geolocation')
function requestLocation() {
navigator.geolocation.getCurrentPosition(
(pos) => console.log('位置', pos),
(err) => console.error(err)
)
}
</script>
提示:
navigator.permissions不是所有瀏覽器都支援,使用前可先檢查if (navigator.permissions)。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 最佳實踐 |
|---|---|---|
| 忘記返回 reactive 物件 | 直接回傳普通變數,組件不會自動更新。 | 始終使用 ref、reactive 或 computed,並在 return 時暴露它們。 |
| 在 Composable 中直接操作 DOM | 會破壞 SSR(Server‑Side Rendering)或測試環境。 | 使用 onMounted / onBeforeUnmount 包裹 DOM 操作,或改用 Vue 的指令。 |
| 依賴外部全域變數 | 使 Composable 難以測試且耦合度高。 | 將依賴作為參數注入(DI),如 useFetch(url, { client: axios })。 |
| 過度抽象 | 把太小的功能拆成單獨的 Composable,導致檔案過多、閱讀成本提升。 | 遵循「功能完整」的粒度:一個 Composable 應該解決同一類型的需求(如所有表單驗證)。 |
| 忘記清理副作用 | 事件監聽、計時器等若未在 onUnmounted 清除,會造成記憶體泄漏。 |
在 Composable 中使用 onUnmounted 釋放資源,或使用 watchEffect 的返回值作清理。 |
其他最佳實踐
- 命名規則:所有 Composable 必須以
use開頭,且檔案名稱同樣使用useXxx.js,方便 IDE 自動補全與搜尋。 - 類型安全:若使用 TypeScript,為每個返回值加上明確的型別,並在函式參數加上
interface或type。 - 文件說明:在每個 Composable 頂部加入 JSDoc 註解,說明用途、參數與回傳值,提升團隊協作效率。
- 測試:以 單元測試 為主,利用
@vue/test-utils或vitest測試ref、computed的變化與副作用。 - 避免循環依賴:如果兩個 Composable 互相引用,請抽出共用邏輯到第三個更底層的 Composable,保持單向依賴樹。
實際應用場景
| 場景 | 可能使用的 Composable | 為何適合 |
|---|---|---|
| 表單驗證 | useForm, useValidator |
集中管理欄位狀態、錯誤訊息與提交流程。 |
| 即時搜尋 | useDebounce, useFetch |
防抖減少請求次數,統一處理資料取得與錯誤。 |
| 響應式佈局 | useWindowSize, useMediaQuery |
依螢幕尺寸切換 UI,避免在每個組件重複寫監聽程式。 |
| 使用者偏好設定 | useLocalStorage, useCookie |
把設定自動同步到瀏覽器儲存,保持跨頁面一致性。 |
| 權限與安全 | usePermission, useAuth |
集中管理瀏覽器權限或登入狀態,方便在全局守衛使用。 |
| 動畫與過渡 | useTransition, useMotion |
把動畫參數與生命週期抽離,讓組件只關注 UI 結構。 |
案例:一個「商品列表」頁面
- 使用
useFetch取得商品資料。- 使用
useWindowSize調整每列顯示的商品數量。- 使用
useLocalStorage把使用者的排序偏好存起來。- 使用
useDebounce為搜尋框加入防抖,避免每次鍵入都發送請求。
這樣的組合讓每段功能都能獨立測試、重用,且程式碼結構清晰。
總結
- Composable 是 Vue 3 中 以函式為單位 的可重用邏輯封裝,遵守
useXxx命名慣例。 - 透過
ref、reactive、computed、生命週期鉤子等 API,Composable 能夠提供 完整的響應式狀態與行為,而不會與組件的模板耦合。 - 正確的 抽象粒度、資源清理、依賴注入 以及 類型安全,是寫好 Composable 的關鍵。
- 在實務開發中,從表單驗證、資料抓取、視窗尺寸偵測、到權限管理,都能透過 Composable 讓程式碼更 模組化、可測、易維護。
掌握了 Composable,等於掌握了 Vue 3 中最核心的組件組合方式。未來在大型專案或團隊協作時,你將能夠快速建立 可共享、可擴充 的功能模組,讓開發效率與程式品質同步提升。祝你在 Vue 的世界裡玩得開心、寫得更好! 🚀