文章

VueUse 常用组合式函数剖析:真正值得学的是四个套路

VueUse 有 200+ 个函数,背 API 是最低效的学法。它真正复用价值在四个套路上: 参数一律接受 MaybeRefOrGetter 用 toValue 归一化、清理永远挂 effectScope、 把"策略"和"函数"解耦(useDebounceFn 就是 createFilterWrapper 套一个 debounceFilter)、 用 watchImmediate 的 cleanup 做自动解绑。文章带源码与实测数据(含卸载后待执行调用不取消的坑、 useLocalStorage 的同文档同步机制)以及 v15 的破坏性变更清单。

VueUse 常用组合式函数剖析:真正值得学的是四个套路

一句话概括

VueUse 的价值不在”有 200 多个函数”,而在它把”怎么写一个能复用的组合式函数”这件事沉淀成了固定套路。

面试官问 VueUse,通常不是想听你报菜名,而是想看你有没有读懂它的实现。因为组合式函数的难点从来不是功能,而是这三个问题:

  1. 参数怎么收? 调用方想传普通值、想传 ref、想传 computed,你都该接。
  2. 副作用怎么收尾? 组件卸载、依赖变了、目标换了,监听器和 Observer 谁来解?
  3. 同一段逻辑套不同策略(防抖 / 节流 / 暂停)时,代码怎么不爆炸?

VueUse 对这三个问题的答案,就是下面四个套路。这四个套路比任何一个具体函数都值钱——因为你可以直接把它们搬进自己项目的 useXxx 里。

核心知识点

1. 套路一:参数一律接受”值 / ref / getter”,靠 toValue 归一化

VueUse 的函数签名里到处都是 MaybeRefOrGetter<T>——意思是这个参数你可以传 100、传 ref(100)、也可以传 () => props.delay。

自己写的时候别手动判类型,Vue 3.3+ 自带了 toValue,一句话收口:

1
2
3
4
5
import { toValue } from 'vue'

toValue(ref(1))             // 1        ref → 取 .value
toValue(() => n.value * 10) // 10       getter / computed → 调用它
toValue('abc')              // 'abc'    普通值 → 原样返回

但真正的关键不是”能传三种形态”,而是”什么时候取”。 toValue 取的是”此刻的值”,对 getter 来说每调用一次就重算一次。所以内部要在用到的那一刻才取,而不是在初始化时取一次:

1
2
3
4
5
// ❌ 初始化时取一次,之后调用方把 ref 改了也不生效
const ms = toValue(delay)

// ✅ 每次真正要用时再取,传值 / 传 ref / 传 getter 都能实时生效
const duration = toValue(ms)

debounceFilter 里就是这个写法——const duration = toValue(ms) 写在 handler 内部,所以延时可以随时改。实测(@vueuse/core@15.0.0):传入 ref(1000),改成 10 之后新的调用立刻按 10ms 走,函数不用重建。

顺带记住一个版本事实:MaybeRef / MaybeRefOrGetter 这两个类型从 v12.8 起已被废弃,改用 Vue 原生类型;@vueuse/shared 里的 toValue 也在 v12.3 被废弃,统一用 Vue 的。所以看到老代码 import { toValue } from '@vueuse/shared',可以直接改掉。

2. 套路二:清理永远挂在 scope 上,不要写死 onUnmounted

VueUse 里几乎每个要清理的函数,收尾那一行都是同一个:

1
2
3
4
5
6
7
8
9
import { getCurrentScope, onScopeDispose } from 'vue'

function tryOnScopeDispose(fn, failSilently) {
  if (getCurrentScope()) {     // 当前有活跃的 effect scope 才注册
    onScopeDispose(fn, failSilently)
    return true
  }
  return false
}

看着平平无奇,但它解决了一个很实际的问题:onUnmounted 只能在组件里用。而组合式函数可能被在组件外调用(比如在 Pinia store 里、在手动创建的 effectScope 里、在一个纯函数工厂里)。用 tryOnScopeDispose 就不挑环境:在组件里等价于卸载时执行,在手动 scope 里等价于 scope.stop() 时执行,都不在就静默返回 false。

实测验证(effectScope() + tryOnScopeDispose):手动 scope 里注册的回调,scope.stop() 后执行次数从 0 变 1;在 scope 外调用则返回 false 且什么都不做、也不报错。

把这个套路用到极致的例子是 createSharedComposable——它让同一个组合式函数在多处调用时共享一份状态(比如跨多个组件的全局状态),实现只有十几行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function createSharedComposable(composable) {
  if (!isClient) return composable
  let subscribers = 0
  let state
  let scope
  const dispose = () => {
    subscribers -= 1
    if (scope && subscribers <= 0) {   // 最后一个"订阅者"走了才真清理
      scope.stop()
      state = undefined
      scope = undefined
    }
  }
  return (...args) => {
    subscribers += 1
    if (!scope) {
      scope = effectScope(true)        // true = detached,脱离父 scope
      state = scope.run(() => composable(...args))
    }
    tryOnScopeDispose(dispose)         // 每次调用都登记减引用
    return state
  }
}

三个设计点值得单独记:

  • effectScope(true) 是 detached 的——它不挂在调用方组件的 scope 下,所以组件卸载不会连带把全局状态清掉。
  • 引用计数:subscribers 减到 0 才 scope.stop()。
  • tryOnScopeDispose(dispose) 就是前面那个套路:谁用了就登记一份减引用,不用自己管组件卸载。

实测行为完全对得上:两个组件调用同一个 shared composable,拿到的是同一个 ref(工厂函数只执行 1 次);只卸载其中一个,状态保留(42 还在);两个都卸载后重新使用,状态被重置回初始值,工厂函数执行次数变成 2。这个”全走光了才销毁”的语义,正是全局状态和组件状态的分界线。

3. 套路三:useEventListener 的骨架是 watchImmediate + onCleanup

事件监听看起来简单(挂上、卸下),但真实需求是:目标元素是 ref、事件名会变、监听器会变——任一变化都得重绑。VueUse 的做法非常漂亮:它把”重绑”这件事外包给了 watch 的 cleanup。

下面这段是简化示意(省掉了参数形态判断,保留骨架):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function useEventListener(...args) {
  const register = (el, event, listener, options) => {
    el.addEventListener(event, listener, options)
    return () => el.removeEventListener(event, listener, options)   // 返回一个"解绑函数"
  }
  return watchImmediate(
    () => [ /* 目标(支持 ref / 数组)/ 事件名 / 监听器 / 选项,全是响应式的 */ ],
    ([targets, events, listeners, options], _, onCleanup) => {
      const cleanups = targets.flatMap(el =>
        events.flatMap(event => listeners.map(fn => register(el, event, fn, options))))
      onCleanup(() => cleanups.forEach(fn => fn()))   // 依赖变化或 stop 时自动执行
    },
    { flush: 'post' },
  )
}

关键在于 onCleanup 的语义:watch 的每次重新执行前、以及 watch 停止时(组件卸载),都会先跑一遍上一次注册的 cleanup。 所以:

  • 目标元素换了 → cleanup 先解绑旧的,cb 再绑新的;
  • 组件卸载 → watch 被停掉 → cleanup 自动解绑。

配套细节:flush: 'post' 是为了拿到已经渲染出来的真实 DOM(ref 的赋值时机);unrefElement() 负责”传 ref / 传组件实例 / 传元素”都能拿到真元素。

实测:把 useEventListener 的目标 ref 从 A 元素换成 B 元素,点击 A 不再触发、点击 B 触发;组件 unmount() 之后再点击 B,次数不再增长。不用手写一行 onUnmounted。

可迁移的写法:任何”需要观察某个响应式外部资源”的组合式函数(ResizeObserver、IntersectionObserver、EventSource、WebSocket)都可以套这个骨架——把”注册 + 返回解绑函数”写成一个内部函数,然后丢给 watch 的 onCleanup。

4. 套路四:把”策略”和”函数”解耦——防抖节流都是同一行代码

这是我认为 VueUse 里最值得抄的设计。useDebounceFn 的实现只有一行:

1
2
3
function useDebounceFn(fn, ms = 200, options = {}) {
  return createFilterWrapper(debounceFilter(ms, options), fn)
}

createFilterWrapper(filter, fn) 负责把”函数”和”过滤器(策略)”组装起来,debounceFilter / throttleFilter / pausableFilter 是可以互换的策略。换成节流,就换一个 filter,fn 一个字都不用改。

createFilterWrapper 本身还有个不太为人知的细节——它返回的包装函数是异步的:

1
2
3
4
5
6
7
8
9
10
11
12
function createFilterWrapper(filter, fn) {
  function wrapper(...args) {
    return new Promise((resolve, reject) => {
      Promise.resolve(filter(() => fn.apply(this, args), { fn, thisArg: this, args }))
        .then(resolve).catch(reject)
    })
  }
  if ('cancel' in filter) Object.assign(wrapper, {
    cancel: filter.cancel, flush: filter.flush, isPending: filter.isPending,
  })
  return wrapper
}

也就是说:const debounced = useDebounceFn(fn) 之后,await debounced() 拿到的是原函数的返回值——因为它真的返回 Promise。同时 cancel / flush / isPending 是从 filter 上”复制”到包装函数上的(isPending 是一个 shallowReadonly 的 ref)。

实测行为(@vueuse/core@15.0.0,用真定时器跑):

操作实测结果
连续调用 3 次(等待 50ms)只执行 1 次,await 最后一次拿到返回值 6
前两次调用返回的 Promise都 resolve 成 undefined(默认不 reject)
isPending调用后为 true,执行完 / flush 后为 false
flush()同步立刻执行。注意它自身返回 undefined;被调函数的返回值是通过之前那个 pending Promise 交付的
延时传 ref改成更小的值后,新的调用立刻按新延时走
maxWait一直连续调用时到点强制执行(实测每 10ms 调一次、wait=30/maxWait=60,150ms 内执行了 3 次)

debounceFilter 里还有两个容易忽略的点:

  • rejectOnCancel: true 时,被”取消”的那次调用会 reject 而不是 resolve——更坑的是,reject 的 reason 是 undefined(源码里 lastRejector() 不带参数调用),所以 catch (e) 拿到的 e 不是 Error,e.message 会直接抛错。实测确认。
  • flush() 执行的是”最后一次调用时传的参数”(源码里存的 lastInvoker),不是最早那次。

⚠️ 一个真实的坑(实测):useDebounceFn 的定时器不会因为组件卸载而自动取消。我在组件里调用一次防抖函数,然后立刻 unmount(),等 90ms 后原函数照样执行了 1 次。原因就是外层没有任何 tryOnScopeDispose——它只有 cancel(),得你自己在合适的时机调。这一点和事件监听、Observer 类函数的行为不一样,别想当然。

5. useLocalStorage:一个”响应式存储”要处理多少脏活

useLocalStorage 只有一行,真正的实现在 useStorage:

1
2
3
4
function useLocalStorage(key, initialValue, options = {}) {
  const { window = defaultWindow } = options
  return useStorage(key, initialValue, window?.localStorage, options)
}

而 useStorage 要处理的”脏活”正好是这道题的全部价值:

① 序列化策略要猜,也要能换。 guessSerializerType(rawInit) 按默认值的类型选 StorageSerializers(boolean / number / object / string / set / map / date / any)。注意 object 走的是 JSON,set / map / date 有专门的读写函数——所以 useLocalStorage('k', new Set()) 是能正确还原的,但如果你换成 useLocalStorage('k', null) 再塞 Set 进去,就只剩 any 序列化器了。 拿不准就显式传 serializer。

② 默认值默认会写回存储。 writeDefaults = true 是默认值,所以挂载的一瞬间 localStorage 里就会出现这个 key(实测:挂载后 localStorage.getItem('theme') 直接是 'light')。不想污染存储就把它关掉。

③ 要防”回写循环”。 数据变了写存储、存储变了更新数据,很容易 A 写触发 B 更新、B 更新又写回 A。它的做法是 watchPausable + pauseWatch() / resumeWatch():update() 里先暂停 watcher,改完值再恢复,并且先比较 serializer.write(data.value) 和事件里的 newValue 是否相同,相同就直接跳过。

④ 要处理”同文档不同步”这个问题。 浏览器的规矩是在同一个文档里写 localStorage,不会给自己触发 storage 事件——所以两个 useLocalStorage('theme') 会各玩各的。VueUse 的补法是:write() 里主动派发一个:

1
2
3
4
5
6
7
8
function dispatchWriteEvent(oldValue, newValue) {
  const payload = { key, oldValue, newValue, storageArea: storage }
  window.dispatchEvent(
    storage instanceof Storage
      ? new StorageEvent('storage', payload)      // 真 Storage:手动补一个原生 storage 事件
      : new CustomEvent(customStorageEventName, { detail: payload }),
  )
}
  • 用的是原生 Storage 时监听 window 的 'storage',靠上面这个手动事件补齐同文档同步;
  • 用的是自定义后端(比如内存 Map、加密存储)时改走自定义事件。

源码里还留了一句话很诚实:自定义事件”在跨文档时不工作,TODO 考虑用 BroadcastChannel 实现”。所以正确的说法是:useLocalStorage 的跨标签页同步靠的是 storage 事件,不是 BroadcastChannel。

实测:同一个 key 调两次 useLocalStorage,拿到的是两个不同的 ref(a === b 为 false),但改 a 之后 b 也跟着变了——靠的就是上面这条事件链;从外部手动 setItem 再派发一个 StorageEvent,两个 ref 也同步更新。

⑤ 拿不到 storage 时要能降级。 useStorage 里有一句 if (!storage) return data——SSR / 无 window 环境下直接返回一个普通 ref,不报错(这也是为什么 VueUse 在 Nuxt 里能开箱用,只是服务端读写不了)。

6. 不写死浏览器环境:defaultWindow 与 isClient

几乎所有 DOM 相关的组合式函数,第一行都是同一件事:

1
2
const defaultWindow = isClient ? window : undefined
const defaultDocument = isClient ? window.document : undefined

然后在函数内部:

1
2
3
4
5
function useSomething(target, options = {}) {
  const { window = defaultWindow } = options      // ① 环境和参数都能覆盖
  if (!window) return /* 各种 noop */             // ② 没有环境就返回空实现,不抛错
  // ...
}

两个点:

  • window / document 是可通过 options 注入的。这不是为了好看——是为了测试和 SSR 里能塞一个假的进去,也是为什么 VueUse 自己能被很好地单测。
  • isClient 是在模块加载时求值的常量(typeof window !== 'undefined')。所以如果你在 Node 里先 import 再挂 globalThis.window,VueUse 会认为自己在服务端——顺序错了,行为就差很远(同理,vue 的 runtime-dom 也在模块加载时捕获 document)。

7. v15 的版本红线(升级前必须知道的几条)

@vueuse/core v15 是一次 major 升级,清理了不少历史包袱,面试里聊到”你怎么评估依赖升级”时可以直接举这几条:

变更影响
删除 templateRef改用 Vue 自带的 useTemplateRef(能力重复了)
不再支持 Node.js 20老 CI 镜像要注意
timer 相关废弃配置被移除,统一用 scheduler用了 setTimeout 类参数的旧写法会失效
useThrottleFn 的 trailing 默认值从 false 改成 true实测:50ms 节流内连调 3 次,执行次数从 1 变成 2(leading + trailing)。高频调用的地方行为会变,升级后要重点回归
新增 useWebMCP / useLiveAnnouncer / useTemporalNow分别对应 AI Agent、无障碍播报、Temporal 时间 API

另外两个更早但仍在踩的:useDebounce 已废弃,改用 refDebounced(返回的是 shallowReadonly 的只读 ref,想写就写源 ref);useFetch 在 v15 修了竞态问题(新请求发出后,忽略迟到返回的旧成功响应)——这个改动本身就是”请求竞态必须处理”的官方背书。

8. 面试怎么答

30 秒版:

VueUse 与其说是工具库,不如说是组合式函数的写法范例。我读源码主要是学它的几个套路:参数统一用 MaybeRefOrGetter、靠 toValue 归一化;副作用清理统一走 tryOnScopeDispose,所以组件里和手动 effectScope 里都能用;useEventListener 那种”注册返回解绑函数、交给 watch 的 onCleanup“的骨架,我在自己的 Observer 封装里直接复用了;还有就是策略和函数解耦,useDebounceFn 和 useThrottleFn 只是换了不同的 filter。

被追问”具体讲一个”时,挑 useLocalStorage:

它包装成 useStorage,除了序列化策略、默认值回写、暂停 watcher 防循环之外,最麻烦的是同步——浏览器里同文档写 localStorage 不会触发自己的 storage 事件,所以它写完会手动 dispatchEvent 一个 StorageEvent 让同文档的其它实例同步;自定义存储后端则走自定义事件。所以它的跨标签页同步靠的是 storage 事件而不是 BroadcastChannel,源码注释里还写着”以后再考虑 BroadcastChannel”。

其实你每天都在用

  • 你项目里那个”防抖搜索”大概率是手写的 setTimeout + clearTimeout,useDebounceFn 多做的事是:返回 Promise 能 await、自带 isPending 给 loading 用、flush() 应付”用户点提交时立刻执行”。
  • 你封装的 useXxx 里如果写了 onUnmounted,它在”被 store 调用”“被手动 effectScope 调用”时就静默失效了——这就是 tryOnScopeDispose 存在的理由。
  • 你写 ResizeObserver / IntersectionObserver 时手动 disconnect 的那段代码,可以整段换成”register 返回解绑函数 + watch 的 onCleanup“。
  • 你从 localStorage 读出来的东西,如果从来没关心过类型(塞的是对象、读出来是字符串),那就是没做序列化策略——useLocalStorage 是按默认值类型自动猜的。
  • 你两个组件各写一份 useLocalStorage('token'),以为它们是同一份状态——实测是两个独立 ref,同步是靠 storage 事件兜的。
  • 你在 Nuxt/SSR 项目里 import 了 VueUse 却没崩,那是因为它在拿不到 window 时返回的是 noop / 普通 ref,而不是抛错。
  • 你项目里 Node 版本还停在 20,那 v15 一升级 CI 就会先红——这类”隐性运行时要求”是依赖升级最容易漏的一条。

常见误解(FAQ)

❌ 误区1:”VueUse 就是一堆工具函数的集合,学它等于背 API。”

200 多个函数你不可能也不用全记。真正可迁移的是四个套路:toValue 归一化参数、tryOnScopeDispose 收口清理、watch + onCleanup 做自动重绑、策略与函数解耦。这四个能直接搬进你自己的组合式函数,而具体某个函数忘了查文档就行。

❌ 误区2:”useDebounceFn 返回的就是原来的函数,await 直接拿到返回值。”

它返回的是一个总是返回 Promise 的包装函数(createFilterWrapper 里 new Promise(...)),await 拿到的是原函数返回值没错——但要注意被丢弃的那几次调用返回的 Promise 会 resolve 成 undefined,而不是 reject;只有显式设了 rejectOnCancel: true 才会 reject,那时你就得自己 try/catch。

❌ 误区3:”VueUse 会帮我在组件卸载时清理一切。”

不是。分两类:事件监听、Observer、定时刷新这类”注册型”副作用才会挂 scope 自动清理(走 tryOnScopeDispose);而 useDebounceFn 内部那个待执行的定时器,组件卸载后照样会把函数执行掉——实测确认。它是策略函数,不是资源订阅,需要清理时得自己调 cancel()。

❌ 误区4:”useLocalStorage 在 SSR 里会报错,得加判断。”

不会。useStorage 里 if (!storage) return data,拿不到存储就返回一个普通 ref。服务端只是读写不到值(localStorage 此时是默认值),不是崩。

❌ 误区5:”同一个 key 调两次 useLocalStorage 拿到的是同一份状态。”

实测 a === b 为 false,它们是两个独立 ref。能”看起来同步”是因为写入时主动派发了 StorageEvent,另一个实例收到事件后更新自己。要真的共享一份状态,用 createSharedComposable 包一层,或者上 Pinia。

❌ 误区6:”跨标签页同步是用 BroadcastChannel 做的。”

useStorage 用的是 storage 事件(原生 Storage 时监听 window 的 'storage');自定义存储后端走的是自定义事件,源码注释明确写着这种自定义事件”跨文档不工作”,BroadcastChannel 只是写在 TODO 里。除非你自己包了 BroadcastChannel,否则别这么说。

❌ 误区7:”useLocalStorage 只会在你赋值的时候才写存储。”

writeDefaults 默认为 true:挂载时就会把默认值写进 localStorage(实测确认)。有些场景(比如只在用户显式设置过之后才想留痕)必须把它关掉。

❌ 误区8:”防抖节流这类函数 v15 升级不影响。”

useThrottleFn 的 trailing 默认值在 v15 从 false 变成了 true——同样是 50ms 内连调 3 次,实测执行次数从 1 变成 2。节流函数的”最后一次要不要执行”变了,属于典型的行为回归点,升级时需要专门测。

一句话总结

VueUse 值得学的不是它有多少个函数,而是它把”参数归一化、清理挂 scope、策略与函数解耦、注册与解绑成对”这四件事写成了固定套路——记住套路,你自己也能写出同样好用的组合式函数。

本文由作者按照 CC BY 4.0 进行授权