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>()

// 组件A中监听
const { on, emit } = useEventBus(bus)
on('user:login', ({ userId }) => {
  console.log(`User ${userId} logged in`)
})

// 组件B中触发
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
)

// 在 input 事件中调用
debouncedSearch(inputValue)

模式六:usePagination — 分页模式

解决的问题:统一分页逻辑,支持页码计算、边界判断和页码跳转。

import { ref, computed, type Ref, type ComputedRef } 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>
}

import { toValue, type MaybeRefOrGetter } from 'vue'

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
})

// 配合 useFetch
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 { withSetup } from '@/test/utils/withSetup'
import { useCounter } from '@/composables/useCounter'
import { useFetch } from '@/composables/useFetch'
import { useFormValidation } from '@/composables/useFormValidation'

// withSetup 辅助函数
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()
  })

  it('应正确计算 isMin 和 isMax', () => {
    const [counter, unmount] = withSetup(() => useCounter({ min: 0, max: 2, initialValue: 0 }))
    expect(counter.isMin.value).toBe(true)
    counter.increment()
    counter.increment()
    expect(counter.isMax.value).toBe(true)
    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. 内存泄漏:检查 setInterval、addEventListener、watch 是否在 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