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 大错误排查清单
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,不关心组件,只关心状态和行为的组合。
本站提供浏览器本地工具,免注册即可试用 →