VueUse v15 升级笔记:templateRef 被移除、timer 换成 scheduler,还有 6 处会改行为的变更
面向已经在生产里用 Vue 3 + VueUse 的项目。下面按「v14 里怎么写 → v15 改了什么 → 升级后看到什么现象 → 怎么改」组织。
项目地址:https://github.com/vueuse/vueuse
文档:https://vueuse.org
v15.0.0 Release(2026-09-16):https://github.com/vueuse/vueuse/releases/tag/v15.0.0
环境门槛:Vue 3.5、Node 22、ESM-only
@vueuse/core@15.0.0 的 package.json 里:
vue要求^3.5.0- Node.js
>= 22(v15 起放弃 Node 20,PR #5553) - 自 v13 起 ESM-only(v13.0.0 唯一的破坏性变更就是 drop CJS build,PR #4581),
require()会失败 exports只有"."和"./metadata",构建产物都在dist/下
现象:Node 20 的 CI 镜像装包或跑构建直接失败;老式 CommonJS 服务端做 SSR 时 require('@vueuse/core') 报错。
改法:CI 镜像与本地 .nvmrc 一起升到 22;SSR 侧改成 ESM 或走打包器转译。直接引 dist 子路径的代码,位置在 v14 就变过一次(见下),一并核对。
v15 的六项破坏性变更
1. templateRef 移除(PR #4883)
// v14
import { templateRef } from '@vueuse/core'
const el = templateRef('el')
// v15
import { useTemplateRef } from 'vue'
const el = useTemplateRef('el')
现象:templateRef 导入为 undefined,或在类型检查阶段报「模块没有导出成员」。
改法:换成 Vue 内置的 useTemplateRef()。这也是 v15 把 Vue 下限提到 3.5 的直接原因之一。
2. 放弃 Node.js 20(PR #5553)
见上一节。
3. core:移除废弃的 timer 选项,统一为 scheduler(PR #5564)
受影响 API:useCountdown、useNow、useTimestamp、useTimeAgo、useTimeAgoIntl、useTemporalNow、useElementByPoint、useMemory、useVibrate,以及 useWebSocket 的 heartbeat 选项。
// v14
useNow({ interval: 1000 })
useWebSocket(url, { heartbeat: { interval: 30000 } })
// v15:timer 选项被删,改成 scheduler
// scheduler 的类型是 (cb: Fn) => Pausable,
// useRafFn / useIntervalFn 的返回值可以直接塞进去
// 具体字段名与传参按自己装的版本核对 d.ts
现象:传 interval / immediate 之类的旧选项不报错但完全失效,定时刷新停摆或退化成默认频率;heartbeat 不再发送。
改法:全局搜 interval、immediate、heartbeat 三个关键词,逐个换成 scheduler。这类失效没有运行时告警,靠类型检查兜不住,只能搜。
4. useEventSource:'message' 类型两条路径都会触发(PR #5331)
v15 起,处理 SSE 的 'message' 类型时,onmessage 与 addEventListener('message') 两条路径都会触发。
现象:本来就同时挂了 onmessage 回调、又手动 addEventListener('message', ...) 的代码,会收到两份消息,列表重复追加、计数器翻倍。
const es = useEventSource('/api/sse')
// v15:如果这里再挂一次,与 onmessage 路径叠加
es.value?.addEventListener('message', handler)
改法:二选一,删掉手动监听的重复路径。
5. useIDBKeyval:跨标签页同步变更(PR #5338)
现象:多个标签页之间会互相同步 IndexedDB 里的值。原本假设「只有本标签页写入才触发更新」的逻辑会多跑一轮。
改法:多标签页应用回归一遍写入路径,确认重复渲染、重复提交无副作用。
6. useThrottleFn:trailing 默认值从 false 改为 true(PR #5249)
v15 的签名:
useThrottleFn(fn, ms = 200, trailing = true, leading = true, rejectOnCancel = false)
现象见下文「时序差异」一节。
改法:保留 v14 行为就在第 3 个参数显式传 false。
useThrottleFn(save, 500, false)
v14.0.0(2025-10-22):从 v13 直升会一起撞上的变更
- 迁移到 tsdown,
dist文件位置变化。直接引dist子路径的会断 - 废弃别名导出,改用原始函数名。具体清单以包内
d.ts的@deprecated标注为准 - 要求 Vue 3.5
computedAsync默认flush: 'sync'createSharedComposable在客户端只返回sharedComposable- firebase 集成升到 firebase v12,nuxt 集成用 Nuxt v4 kit
useThrottleFn对齐传统 throttle 语义(#4923)useClipboard改用readonly()而不是类型断言
// computedAsync:v14 起默认 flush: 'sync'
const state = computedAsync(fn, initial, { flush: 'sync' })
// useClipboard:value 是 readonly 的,直接赋值不再生效
const { copy, copied, text } = useClipboard()
await copy('...') // 走 copy()
// text.value = '...' // 不要这么写
现象:dist 子路径导入报模块找不到;别名导入在类型检查时报废弃;computedAsync 的求值时机变化,依赖副作用的链路提前执行;createSharedComposable 的解构取值变成 undefined;对 useClipboard().value 赋值静默无效。返回值与 flush 合法取值按自己版本的 d.ts 核对。
顺手修掉的静默 bug:这几处行为会变,值得回归
useFetch:更新的请求发出后忽略旧的成功响应;refetch成功后忽略旧的 abort 错误(#5574)useWebSocket:忽略被替换掉的旧 socket 消息,显式close()后状态同步为CLOSED(#5573)useResizeObserver:observe()加了instanceof Element判断,Vue 注释节点不再让它崩(#5593)- pointer 相关:
useDraggable/usePointerSwipe在pointercancel时结束,usePointer重置isInside,onLongPress清理待触发的长按(#5555 / #5587) - v15 新增:
useWebMCP、useLiveAnnouncer、useTemporalNow(#4793)
前三项属于「以前会出错的路径现在不出了」,如果业务里有兜底代码(比如手动比对请求序号、手动清 isInside),可能变成重复处理。
useThrottleFn 的时序差异
leading 与 trailing 都为 true 时:一段连续事件里,第一次立即执行,节流窗口结束时再补一次。
对搜索输入、自动保存、埋点这类「每次调用都有副作用」的地方,等于末尾多打一次请求。v15 默认就是这个组合,v14 不是。
下一步:useCached 的 deepRefs
当前默认 true,官方在类型注释里写明下个大版本改为 false。介意的地方现在就显式传值。
升级顺序
- 先锁 Vue 3.5 + Node 22
- 改 import:
templateRef→useTemplateRef - 全局搜
interval/immediate/heartbeat选项 - 搜
useThrottleFn调用,按需补第三个参数 - 检查
useEventSource是否自己又挂了onmessage监听 - 多标签页应用回归
useIDBKeyval - 最后跑一遍 stale response 相关回归:
useFetch/useWebSocket