复用史是一条剥离的线:mixin 把选项混进组件,renderless 组件把逻辑藏进插槽,组合式函数把逻辑变成"能被普通函数调用的东西"。前两者都依附组件实例,第三个不需要——能在 setup 里调,也能在 .ts 里被单测直接调。
这篇按四步走:三条复用路线 → VueUse 高频函数 → 组合式函数的契约 → 副作用清理与测试,末尾列坑与边界。
1. 三条复用路线
| 维度 | mixin | renderless 组件 | 组合式函数 |
|---|---|---|---|
| 数据来源 | this 隐式合并 |
作用域插槽 | 返回值,显式解构 |
| 命名冲突 | 静默覆盖,无提示 | 无 | 解构时自己起名 |
| 类型推导 | 差(this 上什么都没有) |
中(插槽 props 手写) | 好 |
| 脱离组件单测 | 不能 | 不能(要 mount) | 能,直接调 |
| Vue 3 支持 | 保留不推荐 | 保留 | 官方推荐 |
mixin 最致命的问题不是难维护,而是冲突不可见:两个 mixin 都定义 loading,后注册的静默覆盖,编译器一个字不说。renderless 组件解决了冲突,代价是每个复用点多一个实例。
组合式函数把这两件事都绕开:它就是函数,输入参数、输出 ref。
2. VueUse 的四个高频函数
2.1 useLocalStorage:带类型与默认值的持久化 ref
import { useLocalStorage } from '@vueuse/core'
interface Prefs { theme: 'light' | 'dark'; fontSize: number }
const prefs = useLocalStorage<Prefs>('app:prefs', { theme: 'light', fontSize: 14 }, {
mergeDefaults: true, // 新字段并入旧存储,而不是整份覆盖
writeDefaults: false,
})
prefs.value.theme = 'dark' // 写 ref 即写存储
mergeDefaults 是升级场景的关键:不加它,用户本地的旧对象(缺 fontSize)原样读出,新增字段永远拿不到值。
2.2 useDebounceFn:拿到带 cancel 的包装函数
import { useDebounceFn } from '@vueuse/core'
const runSearch = useDebounceFn((kw: string) =>
fetch(`/api/search?q=${encodeURIComponent(kw)}`), 300)
function onEnter(kw: string) { runSearch.cancel(); runSearch(kw) } // 回车立即触发
和手写 setTimeout 的差别:返回值挂了 cancel() / flush(),且组件卸载时自动清理(内部是 tryOnScopeDispose)。
2.3 useIntersectionObserver:懒加载与曝光
const target = ref<HTMLElement | null>(null)
const { stop, isSupported } = useIntersectionObserver(target, ([e]) => {
if (e.isIntersecting) stop() // 只统计一次曝光
}, { rootMargin: '0px 0px 200px 0px', threshold: 0.1 })
rootMargin 提前 200px 触发是图片懒加载的标准做法;stop() 用于只关心首次进入视口。isSupported 在 SSR 与老浏览器为 false,据此降级。
2.4 useEventListener 与 useFetch
useEventListener(window, 'keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape') closeDialog() // 卸载时自动 removeEventListener
})
const { data, isFetching, execute } = useFetch<Item[]>('/api/items', {
immediate: false, refetch: true,
}).json()
useFetch 是"带响应式参数的 fetch 包装",不是缓存层:没有 staleTime、不去重。列表与详情页交给 TanStack Query,它只适合"参数一变就重发"。
3. 契约一:返回 ref,不返回 reactive
// ✅ 调用方可自由解构并重命名
export function useCounter(initial = 0) {
const count = ref(initial)
const double = computed(() => count.value * 2)
const inc = () => count.value++
return { count, double, inc }
}
// ⚠️ reactive 解构即丢响应性,除非调用方知道要 toRefs
export function useCounterBad(initial = 0) {
return reactive({ count: initial })
}
const { count: pageCount, inc: nextPage } = useCounter(1)
返回 ref 的收益在调用方:解构、重命名、再传给别的 composable 都不断链。reactive 等于把 toRefs 推给使用者。
4. 契约二:参数走 MaybeRefOrGetter
这是能否当积木拼的分水岭。只收原始值,调用方得自己写 watch;收 MaybeRefOrGetter,内部 toValue() 取值,外层 computed 自动追踪。
import { toValue, computed, type MaybeRefOrGetter } from 'vue'
export function useFullName(
first: MaybeRefOrGetter<string>,
last: MaybeRefOrGetter<string>,
) {
return computed(() => `${toValue(first)} ${toValue(last)}`)
}
useFullName('张', nameRef) // 原始值
useFullName(firstRef, () => store.last) // ref 与 getter 混用
| 参数签名 | 调用方体验 | 响应式 |
|---|---|---|
n: number |
传 page.value,变了要重调 |
无 |
n: Ref<number> |
传 page,但常量要 ref(1) 包一层 |
有 |
n: MaybeRefOrGetter<number> |
1、page、() => store.page 都行 |
有 |
toValue 是 Vue 3.3 起的官方 API,取代早期 isRef + unref 的写法。
5. 契约三:副作用清理
组合式函数里注册的监听、定时器、观察器必须在作用域销毁时清掉。tryOnScopeDispose 是安全版 onScopeDispose:没有活跃 effect scope 时返回 false 而非告警。
import { ref, toValue, watch, tryOnScopeDispose, type MaybeRefOrGetter } from 'vue'
export function useInterval(cb: () => void, ms: MaybeRefOrGetter<number>) {
const active = ref(true)
let timer: ReturnType<typeof setInterval> | null = null
const start = () => { if (!timer) timer = setInterval(cb, toValue(ms)) }
const stop = () => { if (timer) { clearInterval(timer); timer = null } }
watch(() => toValue(ms), () => { stop(); if (active.value) start() }, { immediate: true })
tryOnScopeDispose(() => { active.value = false; stop() })
return { active, start, stop }
}
对比 onUnmounted:它只能在组件 setup 同步期调用,在组合式函数里用它等于绑死"必须被组件调用",单测直接调就报 "no active component instance"。tryOnScopeDispose 绑 effect scope,松得多。
6. 契约四:SSR 安全
const width = ref(window.innerWidth) // ❌ SSR 直接崩
const w = ref(0); onMounted(() => { w.value = window.innerWidth }) // ✅
标准一句话:window/document/localStorage 只能出现在 onMounted、事件回调、或 isClient 判断之后。VueUse 的 useLocalStorage 在服务端退化为内存 ref,这就是它能安全用于 SSR 的原因。
7. 自己写:最小骨架
骨架只有五段:参数归一 → 状态 → 派生 → 动作 → 清理与返回。
// composables/useAsyncTask.ts
export interface AsyncTaskOptions { immediate?: boolean; onError?: (e: unknown) => void }
export function useAsyncTask<T>(task: (signal: AbortSignal) => Promise<T>, opts: AsyncTaskOptions = {}) {
const data = shallowRef<T | null>(null) // 大数组不做深代理
const error = ref<unknown>(null)
const loading = ref(false)
const ctrl = shallowRef<AbortController | null>(null)
async function run() {
ctrl.value?.abort() // 取消上一次,防竞态
const ac = new AbortController(); ctrl.value = ac
loading.value = true; error.value = null
try {
const res = await task(ac.signal)
if (!ac.signal.aborted) data.value = res
} catch (e) {
if ((e as Error).name === 'AbortError') return
error.value = e; opts.onError?.(e)
} finally {
if (!ac.signal.aborted) loading.value = false
}
}
tryOnScopeDispose(() => ctrl.value?.abort())
if (opts.immediate) void run()
return { data, error, loading, run }
}
| 决策 | 选择 | 理由 |
|---|---|---|
data 用 shallowRef |
是 | 大数组深代理无意义,还拖慢渲染 |
错误用 ref 不 throw |
是 | 抛错会打断 setup,调用方无法局部降级 |
内置 AbortController |
是 | 竞态是异步复用头号 bug,必须内建 |
8. 可测试性:比组件好测
import { describe, it, expect } from 'vitest'
import { effectScope, nextTick } from 'vue'
it('成功后写入 data 并复位 loading', async () => {
const scope = effectScope()
const task = scope.run(() => useAsyncTask(async () => [1, 2, 3]))!
const p = task.run()
expect(task.loading.value).toBe(true)
await p
expect(task.data.value).toEqual([1, 2, 3])
expect(task.loading.value).toBe(false)
scope.stop()
})
关键是 effectScope():手动造作用域,让 tryOnScopeDispose 有地方挂。测试组合式函数不需要 mount、不需要 jsdom,纯 Node 即可——这是它相对 renderless 组件最实际的优势。
9. 排错:五个现象
| 现象 | 原因 | 处理 |
|---|---|---|
| 告警 "onUnmounted is called when there is no active component instance" | 组合式函数里用生命周期钩子,但被非组件环境调用 | 改 tryOnScopeDispose |
SSR 报 window is not defined |
模块顶层或 setup 同步段访问了 window |
挪进 onMounted,或用 VueUse 的 SSR 安全版本 |
| 请求结果错乱(后发先至) | 并发请求未取消 | 内建 AbortController,run 开头先 abort() |
watch 回调晚于预期 |
默认 flush: 'pre',在组件更新前跑 |
要读最新 DOM 用 post;要同步立刻跑用 sync |
| 数据变了视图不动 | 返回了 reactive 并被解构 |
返回 ref 对象 |
两条要展开:
flush有语义。pre(默认)在 re-render 前,DOM 还是旧的;post在之后,适合量尺寸、读滚动位置;sync同 tick 跑,触发次数暴涨,只在必须严格同步时用。watch不会自己停。它绑在调它的 scope 上,scope 停才停。被长期存活的普通对象调用时会一直活着——保留stop()显式清理,或确保调用方在 scope 内。
10. 边界:什么时候别写组合式函数
| 场景 | 更合适 | 理由 |
|---|---|---|
| 纯计算、无状态 | 普通函数 | 套一层 ref 无收益 |
| 需要固定 DOM 结构 | 组件 | 组合式函数返回不了模板 |
| 需要按实例隔离 | provide/inject + 组合式函数 |
单靠函数没法隔离 |
| 一次性请求 | 组件内直接 fetch / useFetch |
抽成 composable 是过度设计 |
| 状态要跨路由 | Pinia store | 组合式函数的状态随组件销毁 |
最后一条最常被误用:把返回的 ref 当全局状态用,路由一换就丢。它管"这段逻辑的状态",不管"应用的状态"。
11. 自测
- mixin 的命名冲突为什么"不可见",组合式函数为何不会有?
useLocalStorage的mergeDefaults不开会怎样?MaybeRefOrGetter相比"只收Ref"多解决了哪种调用形态?- 组合式函数里为什么推荐
tryOnScopeDispose而不是onUnmounted? - 内建
AbortController是为了防哪类 bug? - 返回
reactive给调用方埋了什么雷?