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,问三个问题就够了:会不会被第二处用到、它是不是一个独立概念、抽完之后代码是不是更好读。