2324 字
约 7 分钟
17
S14-X04 · VueUse 与组合式函数:把复用做成积木

复用史是一条剥离的线: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. 自测

  1. mixin 的命名冲突为什么"不可见",组合式函数为何不会有?
  2. useLocalStorage 的 mergeDefaults 不开会怎样?
  3. MaybeRefOrGetter 相比"只收 Ref"多解决了哪种调用形态?
  4. 组合式函数里为什么推荐 tryOnScopeDispose 而不是 onUnmounted?
  5. 内建 AbortController 是为了防哪类 bug?
  6. 返回 reactive 给调用方埋了什么雷?
S14-X04 · VueUse 与组合式函数:把复用做成积木
https://www.clxhxhhr.top/posts/5714/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。