Vue3 Composable 設計模式:像資深工程師一樣構建可複用邏輯
為什麼 Composable 是 Vue3 架構的核心
2026年,Vue3 生態已全面擁抱 Composition API。Composable(組合式函數)不再只是「可選方案」,而是構建可維護、可測試、可複用邏輯的唯一正統方式。從 Pinia 到 VueUse,從 Nuxt 3 到 Vuetify 3,整個生態都在以 Composable 為基石運轉。
| 特性 | Composable | Mixin | Renderless Component | Custom Directive |
|---|---|---|---|---|
| 邏輯複用 | ✅ 靈活組合 | ⚠️ 隱式合併 | ✅ 顯式傳遞 | ❌ 僅DOM操作 |
| 型別推斷 | ✅ 完整TS支援 | ❌ 型別遺失 | ⚠️ 需手動標註 | ❌ 無型別 |
| 命名衝突 | ✅ 顯式解構 | ❌ 隱式覆蓋 | ✅ props隔離 | ❌ 全域風險 |
| 可測試性 | ✅ 純函數測試 | ❌ 上下文耦合 | ⚠️ 需mount | ❌ 需DOM |
| Tree-shaking | ✅ 天然支援 | ❌ 整體引入 | ⚠️ 元件粒度 | ❌ 全域註冊 |
| 學習曲線 | 中等 | 低 | 高 | 低 |
結論:在2026年,Composable 是邏輯複用的最優解,沒有之一。
模式一:useCounter — 狀態管理模式
解決的問題:元件間共享簡單計數邏輯,避免在每個元件中重複定義 ref 和操作方法。
import { ref, computed, type Ref, type ComputedRef } from 'vue'
interface UseCounterOptions {
min?: number
max?: number
step?: number
initialValue?: number
}
interface UseCounterReturn {
count: Ref<number>
isMin: ComputedRef<boolean>
isMax: ComputedRef<boolean>
increment: () => void
decrement: () => void
reset: () => void
set: (value: number) => void
}
export function useCounter(options: UseCounterOptions = {}): UseCounterReturn {
const { min = -Infinity, max = Infinity, step = 1, initialValue = 0 } = options
const count = ref(initialValue)
const isMin = computed(() => count.value <= min)
const isMax = computed(() => count.value >= max)
const clamp = (value: number): number => Math.min(max, Math.max(min, value))
const increment = (): void => {
count.value = clamp(count.value + step)
}
const decrement = (): void => {
count.value = clamp(count.value - step)
}
const reset = (): void => {
count.value = clamp(initialValue)
}
const set = (value: number): void => {
count.value = clamp(value)
}
return { count, isMin, isMax, increment, decrement, reset, set }
}
使用範例:
<script setup lang="ts">
import { useCounter } from '@/composables/useCounter'
const { count, isMin, isMax, increment, decrement } = useCounter({
min: 0,
max: 99,
step: 1,
initialValue: 0
})
</script>
<template>
<button :disabled="isMin" @click="decrement">-</button>
<span>{{ count }}</span>
<button :disabled="isMax" @click="increment">+</button>
</template>
模式二:useFetch / useAsyncData — 非同步資料模式
解決的問題:統一管理非同步請求的 loading、error、data 狀態,消除每個元件中重複的 try-catch-loading 樣板程式碼。
import { ref, shallowRef, toValue, type MaybeRefOrGetter, type Ref } from 'vue'
interface UseFetchOptions<T> {
immediate?: boolean
initialData?: T
refetch?: boolean
onSuccess?: (data: T) => void
onError?: (error: Error) => void
}
interface UseFetchReturn<T> {
data: Ref<T | null>
error: Ref<Error | null>
isLoading: Ref<boolean>
isReady: Ref<boolean>
execute: () => Promise<T | null>
refresh: () => Promise<T | null>
}
export function useFetch<T>(
url: MaybeRefOrGetter<string>,
options: UseFetchOptions<T> = {}
): UseFetchReturn<T> {
const { immediate = true, initialData, onSuccess, onError } = options
const data = shallowRef<T | null>(initialData ?? null)
const error = ref<Error | null>(null)
const isLoading = ref(false)
const isReady = ref(false)
const execute = async (): Promise<T | null> => {
isLoading.value = true
error.value = null
try {
const resolvedUrl = toValue(url)
const response = await fetch(resolvedUrl)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
data.value = await response.json()
isReady.value = true
onSuccess?.(data.value as T)
return data.value as T
} catch (err) {
const errorInstance = err instanceof Error ? err : new Error(String(err))
error.value = errorInstance
onError?.(errorInstance)
return null
} finally {
isLoading.value = false
}
}
if (immediate) {
execute()
}
return { data, error, isLoading, isReady, execute, refresh: execute }
}
使用範例:
<script setup lang="ts">
import { useFetch } from '@/composables/useFetch'
const { data: users, isLoading, error, refresh } = useFetch<User[]>('/api/users', {
immediate: true,
onSuccess: (data) => console.log(`Loaded ${data.length} users`)
})
</script>
<template>
<div v-if="isLoading">載入中...</div>
<div v-else-if="error">錯誤:{{ error.message }}</div>
<ul v-else>
<li v-for="user in users" :key="user.id">{{ user.name }}</li>
</ul>
<button @click="refresh">重新整理</button>
</template>
模式三:useFormValidation — 表單校驗模式
解決的問題:將表單校驗邏輯從元件中抽離,實現宣告式校驗規則與統一錯誤管理。
import { ref, reactive, computed, type Ref } from 'vue'
type Validator = (value: any) => string | true
type FieldValidators = Record<string, Validator[]>
interface FormState {
[key: string]: any
}
interface UseFormValidationReturn {
formState: FormState
errors: Ref<Record<string, string>>
isValid: Ref<boolean>
isDirty: Ref<boolean>
validate: () => boolean
validateField: (field: string) => boolean
reset: () => void
touched: Ref<Record<string, boolean>>
}
export function useFormValidation(
initialState: FormState,
validators: FieldValidators
): UseFormValidationReturn {
const formState = reactive<FormState>({ ...initialState })
const errors = ref<Record<string, string>>({})
const touched = ref<Record<string, boolean>>({})
const isDirty = computed(() => {
return Object.keys(initialState).some(
(key) => formState[key] !== initialState[key]
)
})
const isValid = computed(() => {
return Object.keys(errors.value).every(
(key) => !errors.value[key]
)
})
const validateField = (field: string): boolean => {
const fieldValidators = validators[field]
if (!fieldValidators) return true
touched.value[field] = true
for (const validator of fieldValidators) {
const result = validator(formState[field])
if (result !== true) {
errors.value[field] = result
return false
}
}
errors.value[field] = ''
return true
}
const validate = (): boolean => {
let allValid = true
for (const field of Object.keys(validators)) {
if (!validateField(field)) {
allValid = false
}
}
return allValid
}
const reset = (): void => {
Object.assign(formState, initialState)
errors.value = {}
touched.value = {}
}
return { formState, errors, isValid, isDirty, validate, validateField, reset, touched }
}
使用範例:
const { formState, errors, validate, isValid } = useFormValidation(
{ email: '', password: '' },
{
email: [
(v: string) => !!v || '信箱不能為空',
(v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || '信箱格式不正確'
],
password: [
(v: string) => !!v || '密碼不能為空',
(v: string) => v.length >= 8 || '密碼至少8位'
]
}
)
模式四:useEventBus — 事件匯流排模式
解決的問題:在非父子元件間實現型別安全的事件通訊,替代 Vue2 的 $on/$off 全域事件匯流排。
import { onUnmounted, type Ref } from 'vue'
type EventMap = Record<string, any>
interface EventBus<T extends EventMap> {
on: <K extends keyof T & string>(event: K, handler: (payload: T[K]) => void) => void
off: <K extends keyof T & string>(event: K, handler: (payload: T[K]) => void) => void
emit: <K extends keyof T & string>(event: K, payload: T[K]) => void
once: <K extends keyof T & string>(event: K, handler: (payload: T[K]) => void) => void
}
export function createEventBus<T extends EventMap>(): EventBus<T> {
const listeners = new Map<string, Set<Function>>()
const on = <K extends keyof T & string>(event: K, handler: (payload: T[K]) => void): void => {
if (!listeners.has(event)) {
listeners.set(event, new Set())
}
listeners.get(event)!.add(handler)
}
const off = <K extends keyof T & string>(event: K, handler: (payload: T[K]) => void): void => {
listeners.get(event)?.delete(handler)
}
const emit = <K extends keyof T & string>(event: K, payload: T[K]): void => {
listeners.get(event)?.forEach((handler) => handler(payload))
}
const once = <K extends keyof T & string>(event: K, handler: (payload: T[K]) => void): void => {
const wrappedHandler = (payload: T[K]) => {
handler(payload)
off(event, wrappedHandler as any)
}
on(event, wrappedHandler as any)
}
return { on, off, emit, once }
}
export function useEventBus<T extends EventMap>(bus: EventBus<T>): EventBus<T> {
const handlers = new Map<string, Function>()
const scopedOn = <K extends keyof T & string>(event: K, handler: (payload: T[K]) => void): void => {
handlers.set(event, handler)
bus.on(event, handler)
}
onUnmounted(() => {
handlers.forEach((handler, event) => {
bus.off(event as any, handler as any)
})
})
return { ...bus, on: scopedOn as any, off: bus.off, emit: bus.emit, once: bus.once }
}
使用範例:
interface AppEvents {
'user:login': { userId: string; token: string }
'cart:update': { itemId: string; quantity: number }
'notification:show': { message: string; type: 'success' | 'error' }
}
const bus = createEventBus<AppEvents>()
const { on, emit } = useEventBus(bus)
on('user:login', ({ userId }) => {
console.log(`User ${userId} logged in`)
})
emit('user:login', { userId: '123', token: 'abc' })
模式五:useDebounce — 防抖模式
解決的問題:統一防抖邏輯,支援響應式延遲時間和自動清理計時器。
import { ref, unref, onUnmounted, type MaybeRef } from 'vue'
interface UseDebounceOptions {
immediate?: boolean
}
interface UseDebounceReturn<T extends (...args: any[]) => any> {
debouncedFn: T
isPending: Ref<boolean>
cancel: () => void
flush: () => void
}
export function useDebounce<T extends (...args: any[]) => any>(
fn: T,
delay: MaybeRef<number>,
options: UseDebounceOptions = {}
): UseDebounceReturn<T> {
const { immediate = false } = options
let timeoutId: ReturnType<typeof setTimeout> | null = null
let lastArgs: any[] | null = null
const isPending = ref(false)
const cancel = (): void => {
if (timeoutId !== null) {
clearTimeout(timeoutId)
timeoutId = null
isPending.value = false
lastArgs = null
}
}
const flush = (): void => {
if (lastArgs !== null) {
fn(...lastArgs)
cancel()
}
}
const debouncedFn = ((...args: any[]) => {
lastArgs = args
cancel()
if (immediate && !timeoutId) {
fn(...args)
isPending.value = true
}
timeoutId = setTimeout(() => {
if (!immediate) {
fn(...args)
}
timeoutId = null
isPending.value = false
lastArgs = null
}, unref(delay))
}) as T
onUnmounted(cancel)
return { debouncedFn, isPending, cancel, flush }
}
使用範例:
const { debouncedFn: debouncedSearch } = useDebounce(
(keyword: string) => {
fetchResults(keyword)
},
300
)
debouncedSearch(inputValue)
模式六:usePagination — 分頁模式
解決的問題:統一分頁邏輯,支援頁碼計算、邊界判斷和頁碼跳轉。
import { ref, computed, toValue, type Ref, type ComputedRef, type MaybeRefOrGetter } from 'vue'
interface UsePaginationOptions {
total: MaybeRefOrGetter<number>
pageSize?: MaybeRefOrGetter<number>
initialPage?: number
}
interface UsePaginationReturn {
currentPage: Ref<number>
totalPages: ComputedRef<number>
isFirstPage: ComputedRef<boolean>
isLastPage: ComputedRef<boolean>
offset: ComputedRef<number>
nextPage: () => void
prevPage: () => void
goToPage: (page: number) => void
pageSize: ComputedRef<number>
}
export function usePagination(options: UsePaginationOptions): UsePaginationReturn {
const { total, pageSize: pageSizeRef = 10, initialPage = 1 } = options
const currentPage = ref(initialPage)
const pageSize = computed(() => toValue(pageSizeRef))
const totalPages = computed(() => {
const totalItems = toValue(total)
return Math.max(1, Math.ceil(totalItems / pageSize.value))
})
const isFirstPage = computed(() => currentPage.value <= 1)
const isLastPage = computed(() => currentPage.value >= totalPages.value)
const offset = computed(() => (currentPage.value - 1) * pageSize.value)
const nextPage = (): void => {
if (!isLastPage.value) {
currentPage.value++
}
}
const prevPage = (): void => {
if (!isFirstPage.value) {
currentPage.value--
}
}
const goToPage = (page: number): void => {
currentPage.value = Math.min(Math.max(1, page), totalPages.value)
}
return { currentPage, totalPages, isFirstPage, isLastPage, offset, nextPage, prevPage, goToPage, pageSize }
}
使用範例:
const { currentPage, totalPages, nextPage, prevPage, offset, isFirstPage, isLastPage } = usePagination({
total: totalItems,
pageSize: 20
})
const { data } = useFetch(() => `/api/items?offset=${offset.value}&limit=20`)
模式七:useInfiniteScroll — 無限滾動模式
解決的問題:統一無限滾動的 IntersectionObserver 邏輯,自動載入更多資料。
import { ref, onMounted, onUnmounted, type Ref } from 'vue'
interface UseInfiniteScrollOptions {
target: Ref<HTMLElement | null>
onLoadMore: () => Promise<void>
threshold?: number
rootMargin?: string
disabled?: Ref<boolean>
}
interface UseInfiniteScrollReturn {
isLoadingMore: Ref<boolean>
isFinished: Ref<boolean>
reset: () => void
}
export function useInfiniteScroll(options: UseInfiniteScrollOptions): UseInfiniteScrollReturn {
const {
target,
onLoadMore,
threshold = 0.1,
rootMargin = '100px',
disabled = ref(false)
} = options
const isLoadingMore = ref(false)
const isFinished = ref(false)
let observer: IntersectionObserver | null = null
const handleIntersect = async (entries: IntersectionObserverEntry[]): Promise<void> => {
const [entry] = entries
if (!entry.isIntersecting || isLoadingMore.value || isFinished.value || disabled.value) {
return
}
isLoadingMore.value = true
try {
await onLoadMore()
} catch {
isFinished.value = true
} finally {
isLoadingMore.value = false
}
}
const setupObserver = (): void => {
if (!target.value) return
observer = new IntersectionObserver(handleIntersect, {
threshold,
rootMargin
})
observer.observe(target.value)
}
const reset = (): void => {
isFinished.value = false
observer?.disconnect()
setupObserver()
}
onMounted(setupObserver)
onUnmounted(() => observer?.disconnect())
return { isLoadingMore, isFinished, reset }
}
使用範例:
<script setup lang="ts">
import { ref } from 'vue'
import { useInfiniteScroll } from '@/composables/useInfiniteScroll'
const sentinel = ref<HTMLElement | null>(null)
const items = ref<Item[]>([])
const { isLoadingMore } = useInfiniteScroll({
target: sentinel,
onLoadMore: async () => {
const newItems = await fetchMoreItems(items.value.length)
if (newItems.length === 0) return
items.value.push(...newItems)
}
})
</script>
<template>
<div v-for="item in items" :key="item.id">{{ item.name }}</div>
<div ref="sentinel" />
<div v-if="isLoadingMore">載入更多...</div>
</template>
使用 Vitest 測試 Composable
測試 Composable 的核心原則:在 setup 上下文中執行。使用 @vue/test-utils 的 withSetup 模式或手動封裝。
import { describe, it, expect, vi } from 'vitest'
import { createApp } from 'vue'
import { useCounter } from '@/composables/useCounter'
import { useFetch } from '@/composables/useFetch'
import { useFormValidation } from '@/composables/useFormValidation'
export function withSetup<T>(composable: () => T): [T, () => void] {
let result: T
const app = createApp({
setup() {
result = composable()
return () => null
}
})
const mountPoint = document.createElement('div')
app.mount(mountPoint)
const unmount = () => app.unmount()
return [result!, unmount]
}
describe('useCounter', () => {
it('應正確初始化', () => {
const [counter, unmount] = withSetup(() => useCounter({ initialValue: 5 }))
expect(counter.count.value).toBe(5)
unmount()
})
it('應支援 increment 和 decrement', () => {
const [counter, unmount] = withSetup(() => useCounter({ min: 0, max: 10 }))
counter.increment()
expect(counter.count.value).toBe(1)
counter.decrement()
expect(counter.count.value).toBe(0)
counter.decrement()
expect(counter.count.value).toBe(0)
unmount()
})
})
describe('useFetch', () => {
it('應正確處理載入狀態', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
new Response(JSON.stringify({ id: 1 }), { status: 200 })
)
const [fetchResult, unmount] = withSetup(() =>
useFetch<{ id: number }>('/api/test', { immediate: false })
)
const promise = fetchResult.execute()
expect(fetchResult.isLoading.value).toBe(true)
await promise
expect(fetchResult.isLoading.value).toBe(false)
expect(fetchResult.data.value).toEqual({ id: 1 })
unmount()
})
})
describe('useFormValidation', () => {
it('應檢測無效輸入', () => {
const [form, unmount] = withSetup(() =>
useFormValidation(
{ email: '' },
{ email: [(v: string) => !!v || '必填'] }
)
)
form.validate()
expect(form.errors.value.email).toBe('必填')
unmount()
})
})
反模式:這些寫法必須避免
反模式一:在 Composable 中直接修改 props
// ❌ 錯誤:直接修改 props
export function useBadExample(props: { value: number }) {
const doubled = computed(() => props.value * 2)
const setDoubled = (val: number) => {
props.value = val / 2 // 執行時警告!Props 是唯讀的
}
return { doubled, setDoubled }
}
// ✅ 正確:透過 emit 通知父元件
export function useGoodExample(props: { value: number }, emit: (e: 'update:value', val: number) => void) {
const doubled = computed(() => props.value * 2)
const setDoubled = (val: number) => {
emit('update:value', val / 2)
}
return { doubled, setDoubled }
}
反模式二:在 Composable 中註冊全域副作用但不清理
// ❌ 錯誤:不清理副作用
export function useBadListener() {
window.addEventListener('resize', handleResize) // 元件卸載後仍執行
}
// ✅ 正確:使用 onUnmounted 清理
export function useGoodListener() {
const handleResize = () => { /* ... */ }
window.addEventListener('resize', handleResize)
onUnmounted(() => window.removeEventListener('resize', handleResize))
}
反模式三:回傳 ref 的 .value 而非 ref 本身
// ❌ 錯誤:回傳 .value,遺失響應性
export function useBadReturn() {
const count = ref(0)
return { count: count.value } // 不是響應式的!
}
// ✅ 正確:回傳 ref 本身
export function useGoodReturn() {
const count = ref(0)
return { count } // 保持響應性
}
反模式四:在 Composable 中使用 this
// ❌ 錯誤:Composable 中沒有 this
export function useBadThis() {
this.count++ // TypeError: Cannot read property 'count' of undefined
}
// ✅ 正確:使用閉包變數
export function useGoodClosure() {
const count = ref(0)
const increment = () => count.value++
return { count, increment }
}
5 大常見陷阱及解決方案
| 陷阱 | 症狀 | 解決方案 |
|---|---|---|
在 setup 外呼叫 Composable |
onUnmounted is not called 錯誤 |
確保在 setup() 或 <script setup> 中呼叫,或使用 withSetup 測試輔助 |
| 解構遺失響應性 | 資料更新但視圖不重新整理 | 使用 toRefs() 解構回傳值:const { count } = toRefs(useCounter()) |
| 共享狀態意外汙染 | 多個元件共享同一份 ref | 每次呼叫 Composable 都建立新的 ref,或使用工廠模式 |
| 非同步操作未取消 | 元件卸載後仍執行回呼 | 使用 AbortController 並在 onUnmounted 中取消 |
| watch 中存取舊 DOM | nextTick 前操作 DOM |
在 watch 回呼中使用 await nextTick() 後再操作 DOM |
10 大錯誤排查清單
isRef(x) is false:檢查是否在 Composable 回傳時誤用了.value,確保回傳 ref 本身Cannot read property of nullin onMounted:Composable 在 SSR 中執行時 DOM 不存在,使用onMounted而非setup中直接存取 DOM- 記憶體洩漏:檢查
setInterval、addEventListener、watch是否在onUnmounted中清理 Uncaught TypeError: composable is not a function:檢查匯入路徑和預設/命名匯出是否匹配onUnmounted is not called:Composable 在非 setup 上下文呼叫,需確保在元件 setup 中執行- computed 不更新:依賴的 ref 未正確傳遞,或誤用
shallowRef導致深層屬性不觸發更新 - watch 不觸發:檢查是否使用了
{ immediate: true },或 watch 源是否為 getter 函數 - SSR hydration mismatch:Composable 中使用了
window/document等瀏覽器 API,需用onMounted延遲或import.meta.env.SSR判斷 - TypeScript 型別推斷失敗:Composable 回傳型別未顯式宣告,使用
interface UseXxxReturn明確定義回傳型別 - 循環依賴:Composable A 呼叫 B,B 又呼叫 A,需重構為單向依賴或提取共享邏輯到第三個 Composable
Vue3 Composable vs React Hooks 對比
| 對比維度 | Vue3 Composable | React Hooks |
|---|---|---|
| 呼叫時機 | setup 中僅執行一次 | 每次渲染都執行 |
| 依賴宣告 | 自動追蹤,無需宣告 | 手動宣告 deps 陣列 |
| 閉包陷阱 | 無(setup 僅執行一次) | 有(stale closure 問題) |
| 條件呼叫 | ✅ 允許 | ❌ 不允許(Hooks 規則) |
| 回傳值 | ref/reactive(響應式) | 普通值(需 useState) |
| 生命週期 | onMounted/onUnmounted | useEffect 清理函數 |
| 效能最佳化 | computed 自動快取 | useMemo 手動快取 |
| 學習曲線 | 中等(響應式系統) | 高(閉包+deps+規則) |
| 型別安全 | ✅ 天然支援 | ✅ 天然支援 |
| 生態工具 | VueUse / Pinia | ahooks / SWR |
核心差異:Vue3 Composable 不存在 React Hooks 的閉包陷阱和依賴陣列問題,這是 Vue3 響應式系統的天然優勢。
推薦工具
在開發 Composable 時,以下線上工具能大幅提升效率:
- JSON 格式化工具 — 格式化 Composable 回傳的 JSON 資料,除錯響應式狀態
- Base64 編碼工具 — 跨碼 API 請求參數,處理 useFetch 中的認證 token
- 雜湊計算工具 — 計算資料雜湊值,實現快取 key 生成和變更檢測
總結:Vue3 Composable 是 2026 年前端邏輯複用的終極方案。掌握這 7 大設計模式(狀態管理、非同步資料、表單校驗、事件匯流排、防抖、分頁、無限滾動),配合 Vitest 測試和反模式避坑,你就能像資深工程師一樣構建可維護、可測試、可複用的 Vue3 應用。記住:好的 Composable 是純邏輯單元,不關心 UI,不關心元件,只關心狀態和行為的組合。
本站提供瀏覽器本地工具,免註冊即可試用 →