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-utilswithSetup 模式或手動封裝。

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 大錯誤排查清單

  1. isRef(x) is false:檢查是否在 Composable 回傳時誤用了 .value,確保回傳 ref 本身
  2. Cannot read property of null in onMounted:Composable 在 SSR 中執行時 DOM 不存在,使用 onMounted 而非 setup 中直接存取 DOM
  3. 記憶體洩漏:檢查 setIntervaladdEventListenerwatch 是否在 onUnmounted 中清理
  4. Uncaught TypeError: composable is not a function:檢查匯入路徑和預設/命名匯出是否匹配
  5. onUnmounted is not called:Composable 在非 setup 上下文呼叫,需確保在元件 setup 中執行
  6. computed 不更新:依賴的 ref 未正確傳遞,或誤用 shallowRef 導致深層屬性不觸發更新
  7. watch 不觸發:檢查是否使用了 { immediate: true },或 watch 源是否為 getter 函數
  8. SSR hydration mismatch:Composable 中使用了 window/document 等瀏覽器 API,需用 onMounted 延遲或 import.meta.env.SSR 判斷
  9. TypeScript 型別推斷失敗:Composable 回傳型別未顯式宣告,使用 interface UseXxxReturn 明確定義回傳型別
  10. 循環依賴: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 時,以下線上工具能大幅提升效率:


總結:Vue3 Composable 是 2026 年前端邏輯複用的終極方案。掌握這 7 大設計模式(狀態管理、非同步資料、表單校驗、事件匯流排、防抖、分頁、無限滾動),配合 Vitest 測試和反模式避坑,你就能像資深工程師一樣構建可維護、可測試、可複用的 Vue3 應用。記住:好的 Composable 是純邏輯單元,不關心 UI,不關心元件,只關心狀態和行為的組合。

本站提供瀏覽器本地工具,免註冊即可試用 →

#Vue3 Composable#组合式函数#逻辑复用#设计模式#2026