编程 Vue3 Composables 的四种设计模式:状态封装、异步请求、副作用与组合

2026-10-15 21:31:41

Vue3 Composables 的四种设计模式:状态封装、异步请求、副作用与组合

同一段 onMounted + ref + watch 在第三个页面里再次出现时,就该停手了。

Composable 是带状态的逻辑复用单元。它和普通工具函数的区别不在写法,而在有没有响应式状态:formatPrice 是一进一出的纯函数,useCounter 里有 ref、有生命周期,后者才是 composable。

下面四种模式基本覆盖了日常会写的绝大多数场景。

模式一:状态封装 —— useCounter

最基础的一层:把一组状态和操作它的方法绑在一起。三个细节值得固定下来。

  • 参数用 options 对象,方便以后加字段而不破坏调用方;
  • 返回值用 readonly 包一层,防止外部直接改内部状态;
  • 返回具名对象而不是数组,调用方不用记顺序。
import { ref, readonly } from 'vue'

interface CounterOptions {
initial?: number
min?: number
max?: number
}

export function useCounter(options: CounterOptions = {}) {
const {
initial = 0,
min = 0,
max = Number.MAX_SAFE_INTEGER,
} = options

const count = ref(initial)

function inc(step = 1) {
count.value = Math.min(count.value + step, max)
}

function dec(step = 1) {
count.value = Math.max(count.value - step, min)
}

function reset() {
count.value = initial
}

return { count: readonly(count), inc, dec, reset }
}

参数校验放在边界(进入函数时)做,内部逻辑就不用反复判空。

模式二:异步状态 —— useFetch

异步 composable 要暴露的不只是数据,还有加载态和错误态。返回值至少包含 data / error / isLoading / execute 四项——少一个,调用方就得自己补一套状态管理,复用就没意义了。

import { ref, watchEffect, toValue, type MaybeRefOrGetter } from 'vue'

export function useFetch(url: MaybeRefOrGetter) {
const data = ref(null)
const error = ref(null)
const isLoading = ref(false)

async function execute() {
isLoading.value = true
error.value = null
try {
const res = await fetch(toValue(url))
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.value = await res.json()
} catch (e) {
error.value = e as Error
} finally {
isLoading.value = false
}
}

watchEffect(() => {
// 读取一次,把 url 的依赖收集进来
toValue(url)
execute()
})

return { data, error, isLoading, execute }
}

MaybeRefOrGetter 表示这个参数可以传普通字符串、ref,也可以传 getter 函数。toValue 统一取值,取值动作发生在 watchEffect 里,依赖就被收集了:url 一变,自动重新请求。

调用侧不需要写 watch:

const userId = ref(1)
const { data, isLoading, error } = useFetch(() => `/api/user/${userId.value}`)

模式三:副作用 —— useEventListener

副作用类 composable 的核心是「谁绑定、谁解绑」。事件目标同样用 MaybeRefOrGetter,因为目标元素常常是 ref 或者要等条件满足后才存在。

import {
watchEffect,
onScopeDispose,
toValue,
type MaybeRefOrGetter,
} from 'vue'

export function useEventListener(
target: MaybeRefOrGetter,
event: K,
handler: (ev: WindowEventMap[K]) => void,
) {
let cleanup: (() => void) | undefined

const stop = watchEffect(() => {
const el = toValue(target)

// 先解绑旧的,再绑新的
cleanup?.()
cleanup = undefined

if (!el) return

const listener = handler as EventListener
el.addEventListener(event, listener)
cleanup = () => el.removeEventListener(event, listener)
})

onScopeDispose(() => {
stop()
cleanup?.()
})
}

这里清理钩子用的是 onScopeDispose,不是 onUnmounted。onUnmounted 只在组件实例存在时才会触发;在 Pinia store 或手动创建的 effectScope 里调用这个 composable,组件早已不在,钩子就永远不会跑,监听器会一直挂着。onScopeDispose 跟着 effect scope 走,两种环境都能生效。

模式四:组合的组合 —— useMouse / useMouseInElement

Composable 可以调用别的 composable,这是它和普通工具函数拉开的第二层差距。

import { ref, watchEffect, toValue, type MaybeRefOrGetter } from 'vue'

// 第二层:基于 useEventListener 拿坐标
export function useMouse() {
const x = ref(0)
const y = ref(0)

useEventListener(window, 'mousemove', (e) => {
x.value = e.pageX
y.value = e.pageY
})

return { x, y }
}

// 第三层:在坐标基础上判断是否落在元素内
export function useMouseInElement(element: MaybeRefOrGetter) {
const { x, y } = useMouse()
const isOutside = ref(true)

watchEffect(() => {
const el = toValue(element)
const rect = el?.getBoundingClientRect()
if (!rect) return

isOutside.value =
x.value  rect.right ||
y.value  rect.bottom
})

return { x, y, isOutside }
}

三层各管一件事:绑定事件、记录坐标、判断区域。每层单独拿出来都能用,合起来也能拼。

几处需要权衡的地方

粒度。 超过 80 行的 composable 考虑拆;不到 10 行、且全项目只有一处用到,就别抽——抽出来只是多一层跳转。判断要不要抽,看三点:会不会被复用、是不是一个独立概念、抽完之后读起来是不是更清楚。

返回值用 ref 对象,不用 reactive。 reactive 一旦被解构,响应式就丢了,而解构是调用方最常见的写法。

异步必须暴露 isLoading 和 error。 只返回 data 的用法,调用方最后一定会自己再写一遍这两个状态。

三个容易踩的坑

生命周期钩子的调用时机。 composable 内部用了 onMounted、onScopeDispose 之类的钩子,就必须在组件的 setup 同步执行栈里调用。写在模块顶层,或者 await 之后再调用,当前实例已经取不到了,钩子会静默失效。

effectScope 里的 watch。 在 scope 内手动创建的 watch 需要显式 scope.stop() 才会停止;依赖 scope 自动收集的场景,注意别把它遗留在外面。

SSR 守卫。 服务端没有 window、document、localStorage。在 composable 里访问这些对象前先判断,或者把访问推迟到 onMounted 里。

export function useLocalStorage(key: string, defaultValue = '') {
const value = ref(defaultValue)

if (typeof window !== 'undefined') {
value.value = window.localStorage.getItem(key) ?? defaultValue
}

watch(value, (v) => {
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, v)
}
})

return value
}

目录分层、类型与测试

按抽象层级分三层目录,找起来最省事:

composables/
├── base/       # 与业务无关,如 useEventListener、useLocalStorage
├── common/     # 通用组合,如 useFetch、usePagination
└── business/   # 业务专用,如 useOrderDetail

类型上直接用泛型把参数透出去,useFetch 比 any 加断言省事得多。

测试 composable 需要在真实的 setup 上下文里运行,否则 onMounted、onScopeDispose 拿不到实例。一个常用的辅助函数:

import { createApp, type App } from 'vue'

export function withSetup(composable: () => T): [T, App] {
let result!: T

const app = createApp({
setup() {
result = composable()
return () => null
},
})

app.mount(document.createElement('div'))
return [result, app]
}

拿到 result 之后就能像测普通函数一样断言状态变化,测完记得 app.unmount()。

收尾

Composable 做三件事:封装状态、管理副作用、保持可组合性。useEventListener 能被 useMouse 用,useMouse 能被 useMouseInElement 用,复用才真正成立。

要不要把一段逻辑抽成 composable,问三个问题就够了:会不会被第二处用到、它是不是一个独立概念、抽完之后代码是不是更好读。

参考:Vue3 组合式函数 Composables 的设计模式

复制全文 生成海报 Vue3 Composables 组合式函数 前端 TypeScript

推荐文章

程序员茄子在线接单