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-utilswithSetup 模式或手动封装。

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. 内存泄漏:检查 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