Vue watch 的 cleanup 到底清理了什么
有一类前端 bug 很难从截图里看出来:页面已经切到新用户了,旧用户的异步结果才慢悠悠回来,然后把新页面上的一小块状态覆盖掉。
这次遇到的是个人主页里的距离展示。组件会监听用户身份、在线状态和经纬度,条件满足时读取浏览器已有定位权限,再计算“离我多远”。真正麻烦的是定位 API 本身:它不像 fetch 一样天然能用 AbortController 取消。用户快速切换个人主页时,旧页面发起的定位读取仍然可能在新页面渲染后完成。
这时就需要 Vue watcher 的 cleanup。
先回答最容易混淆的问题:watch 回调参数里的 onCleanup 不是新版 Vue 才加的。Vue 3.5 新增的是另一个 API:onWatcherCleanup()。两者解决的是同一类问题,但调用形态和限制不同。
cleanup 不是组件卸载钩子
Vue 官方文档在 watch() 的类型定义里写得很直接:watch 的 callback 会收到三个参数,分别是新值、旧值和 onCleanup。文档随后解释,cleanup 会在 watcher 下一次重新执行前调用,用来清理已经失效的副作用,比如一个还没完成的异步请求。
所以它不是“组件卸载时才执行”的钩子。它的触发点更细:
- 监听源变化,当前这一轮 watcher 即将失效。
- 当前 watcher 被停止。
- 组件卸载,和组件绑定的 watcher 随之停止。
如果把一次 watcher callback 当成“一轮任务”,cleanup 的意思就是:这一轮任务已经不再代表最新状态了,赶紧把它留下的副作用处理掉。
最小例子长这样:
watch(userId, async (id, _oldId, onCleanup) => {
let stale = false
onCleanup(() => {
stale = true
})
const profile = await fetchProfile(id)
if (stale) return
userProfile.value = profile
})stale 的作用是阻止旧结果回写。userId 变了以后,Vue 会在下一轮 callback 执行前调用上一轮注册的 cleanup;等上一轮异步终于回来时,stale 已经变成 true,它就不再写入 userProfile。
先从官方契约看两种写法
官方资料可以分成三层看:
watch()API 文档说明了 callback 的第三个参数是onCleanup,并且 cleanup 会在下一次重新执行前触发。- Watcher 指南的 Side Effect Cleanup用过期请求举例,说明为什么需要在 watcher 失效时清理副作用。
- Vue 3.5 发布说明明确写到,3.5 新增的是全局导入的
onWatcherCleanup()。
这三个来源拼起来,结论就很清楚:onCleanup 是 watcher callback 的既有参数;onWatcherCleanup() 是 3.5 新增的全局 API。它们都注册 cleanup,但注册时找“当前 watcher”的方式不一样。
onWatcherCleanup() 更像一个“当前上下文 API”。它必须在 watcher callback 或 watchEffect 的同步执行阶段调用,因为它要依赖“当前正在运行的是哪个 watcher”。官方文档也直接提醒:它不能放到 await 后面。
onCleanup 参数更像 Vue 提前递给 callback 的“绑定好 watcher 的注册器”。即使 callback 是 async,你依然可以在函数一开始同步注册 cleanup,然后在后面的 await 之后检查状态。
从 Vue 3.5 源码看注册路径
当前项目使用的是 Vue 3.5.35。对照 vuejs/core@v3.5.35 的 packages/reactivity/src/watch.ts,watcher cleanup 的核心结构大概是这样:
// 阅读版伪代码,来自 vuejs/core@v3.5.35 packages/reactivity/src/watch.ts
cleanupMap: WeakMap<effect, cleanup[]>
activeWatcher: effect | undefined
onWatcherCleanup(fn, owner = activeWatcher) {
cleanupMap.get(owner).push(fn)
}这里有两个关键对象:
cleanupMap:按 watcher 对应的ReactiveEffect保存一组 cleanup。activeWatcher:记录当前同步执行中的 watcher。
当 watcher 重新运行时,源码会先执行上一轮 cleanup,再把 activeWatcher 临时切成当前 effect,接着调用用户传入的 callback。阅读版流程可以写成这样:
// 阅读版伪代码,省略 deep、multi source、scheduler 等分支
if (valueChanged) {
cleanup?.()
const previous = activeWatcher
activeWatcher = effect
try {
callback(newValue, oldValue, boundCleanup)
} finally {
activeWatcher = previous
}
}这段源码解释了两个表象。
第一,为什么 cleanup 会在下一次 callback 前执行:因为源码在调用用户 callback 前先跑了上一轮的 cleanup。
第二,为什么 onWatcherCleanup() 有同步限制:它默认靠 activeWatcher 找 owner。同步阶段还在 try 包着的 callback 里,activeWatcher 是当前 effect;一旦越过 await,这次同步调用栈已经结束,源码也已经把 activeWatcher 恢复回去了。
onCleanup 参数为什么限制更少?同一个文件里还有一行非常关键的绑定逻辑:
// 阅读版伪代码,真实逻辑是把 effect 显式传给 onWatcherCleanup
boundCleanup = fn => registerCleanup(fn, effect)也就是说,传给 callback 的第三个参数已经绑定了当前 effect。调用它时不需要再从全局的 activeWatcher 猜“当前是谁”,所以官方指南才会说,作为函数参数传入的 onCleanup 绑定在 watcher 实例上,不受 onWatcherCleanup() 的同步调用约束。
再往上一层看,packages/runtime-core/src/apiWatch.ts 在 Vue 3.5 里主要负责把组件实例、SSR、调度器和错误处理接到 reactivity 的基础 watch 上。换句话说,3.5 以后 cleanup 的核心容器和注册逻辑已经下沉到 @vue/reactivity,runtime-core 负责把它放进组件运行时。
这也是为什么源码证据要看两个文件:reactivity/src/watch.ts 告诉我们 cleanup 如何注册和执行,runtime-core/src/apiWatch.ts 告诉我们组件里的 watch 如何包到这套基础实现上。
旧版本已经有 onCleanup
如果只看 Vue 3.5 的 onWatcherCleanup(),很容易误以为“cleanup 是 3.5 才有的新东西”。对照 vuejs/core@v3.4.38 的 packages/runtime-core/src/apiWatch.ts,会看到旧版本的 WatchCallback 类型里已经有第三个参数 onCleanup。
旧实现大概是这样:
// 阅读版伪代码,来自 vuejs/core@v3.4.38 packages/runtime-core/src/apiWatch.ts
type WatchCallback = (value, oldValue, onCleanup) => unknown
onCleanup(fn) {
cleanup = effect.onStop = () => run(fn)
}
if (valueChanged) {
cleanup?.()
callback(newValue, oldValue, onCleanup)
}这和 3.5 的外观很像:变更前先 cleanup,callback 里仍然能拿到 onCleanup。差别在内部归属:3.4 的实现主要还在 runtime-core 里;3.5 把基础 watch 实现抽到 reactivity,并在这套基础实现上补了全局 onWatcherCleanup()。
所以版本边界应该这么记:
- Vue 3.4 以及更早的 Vue 3 版本,
watchcallback 参数里的onCleanup已经存在。 - Vue 3.5 新增
onWatcherCleanup(),让 cleanup 注册不必依赖 callback 参数,但要求同步调用。 - 如果代码本身就是
async watch(...),第三个参数依然是更稳的默认选择。
能取消就取消,不能取消就防回写
如果副作用本身支持取消,cleanup 里应该优先做真正的取消。请求是最典型的场景:
watch(userId, async (id, _oldId, onCleanup) => {
const controller = new AbortController()
onCleanup(() => {
controller.abort()
})
const response = await fetch(`/api/users/${id}`, {
signal: controller.signal,
})
userProfile.value = await response.json()
})这样做有两个好处:旧请求不会继续占网络和服务端资源,旧结果也不会再进入后续逻辑。
但不是所有浏览器 API 都能取消。navigator.geolocation.getCurrentPosition() 就是这种类型。它发出去以后,没有一个标准的 abort() 可以调用。Promise 封装只能让调用方式更顺,不能把底层能力变成可取消。
这种场景就回到 stale flag:
watch(
() => [userId.value, onlineInfo.value?.latitude, onlineInfo.value?.longitude],
async (_state, _oldState, onCleanup) => {
let stale = false
onCleanup(() => {
stale = true
})
distanceText.value = ''
const currentPoint = await getBrowserGeoPointIfAlreadyGranted()
if (stale || !currentPoint) return
distanceText.value = formatDistance(currentPoint)
},
{ immediate: true },
)这个写法的重点是:cleanup 注册在 await 前面,回写发生在 await 后面。只要中间发生了路由切换、用户切换或组件卸载,旧 callback 就会被标记为过期。
Vue 3.5 新增的是 onWatcherCleanup()
Vue 3.5 加了一个可直接 import 的 API:onWatcherCleanup()。在 Watcher 指南里,官方给的例子是用它配合 AbortController 取消过期请求:
import { onWatcherCleanup, watch } from 'vue'
watch(id, (newId) => {
const controller = new AbortController()
fetch(`/api/${newId}`, {
signal: controller.signal,
})
onWatcherCleanup(() => {
controller.abort()
})
})这段代码里,onWatcherCleanup() 的使用方式其实只有三步:
- 在 callback 这一轮里创建一个副作用,例如请求、定时器或事件监听。
- 立刻调用
onWatcherCleanup(),把清理函数注册给当前 watcher。 - 当 watcher 下一轮重新执行或停止时,Vue 自动调用这个清理函数。
它最适合的场景,是副作用本身在同步阶段就能创建出来,而且清理函数也能同步注册。
比如请求可以这样写:
import { onWatcherCleanup, watch } from 'vue'
watch(userId, async (id) => {
const controller = new AbortController()
onWatcherCleanup(() => {
controller.abort()
})
try {
const response = await fetch(`/api/users/${id}`, {
signal: controller.signal,
})
userProfile.value = await response.json()
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return
throw error
}
})这里虽然 callback 是 async,但 onWatcherCleanup() 仍然是合法的,因为它在第一个 await 之前调用。后面的 await fetch(...) 只是等待请求结果,不影响前面已经完成的 cleanup 注册。
搜索输入的 debounce 也很适合:
import { onWatcherCleanup, watch } from 'vue'
watch(searchText, (keyword) => {
const timer = window.setTimeout(() => {
runSearch(keyword)
}, 300)
onWatcherCleanup(() => {
window.clearTimeout(timer)
})
})每次 searchText 变化,上一轮的 timer 都会在下一轮 callback 执行前被清掉。这样用户连续输入时,只会保留最后一次搜索。
事件监听也是同一类:
import { onWatcherCleanup, watch } from 'vue'
watch(panelOpen, (open) => {
if (!open) return
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
panelOpen.value = false
}
}
window.addEventListener('keydown', handleKeydown)
onWatcherCleanup(() => {
window.removeEventListener('keydown', handleKeydown)
})
})当弹窗关闭、重新打开,或者组件卸载时,旧的 keydown 监听会被移除,不会在全局堆出一串残留 listener。
watchEffect 里也可以用同样写法:
import { onWatcherCleanup, watchEffect } from 'vue'
watchEffect(() => {
const controller = new AbortController()
onWatcherCleanup(() => {
controller.abort()
})
fetch(`/api/users/${userId.value}`, {
signal: controller.signal,
}).then(async (response) => {
userProfile.value = await response.json()
})
})不过这类代码要记住 watchEffect 的另一个边界:它只会自动追踪同步执行阶段读到的响应式依赖。上面这个例子里,userId.value 是在同步阶段读到的,所以它会触发重新执行;如果响应式值只在后面的 then 或 await 之后才读到,就不会被这次 watchEffect 自动追踪。
onWatcherCleanup() 和回调参数里的 onCleanup 很像,但有一个关键限制:onWatcherCleanup() 必须在 watch callback 或 watchEffect effect 的同步执行阶段调用,不能放在 await 后面。
下面这种写法就不适合:
import { onWatcherCleanup, watch } from 'vue'
watch(id, async (newId) => {
const response = await fetch(`/api/${newId}`)
onWatcherCleanup(() => {
// 这里已经越过 await,不再处于当前 watcher 的同步注册阶段。
})
data.value = await response.json()
})如果要写 async watcher,最稳的做法仍然是在函数开头同步注册 cleanup:
watch(id, async (newId, _oldId, onCleanup) => {
let stale = false
onCleanup(() => {
stale = true
})
const response = await fetch(`/api/${newId}`)
if (stale) return
data.value = await response.json()
})官方文档也明确写到,作为函数参数传进来的 onCleanup 绑定在 watcher 实例上,不受 onWatcherCleanup() 那个同步调用限制。日常业务代码里,如果 callback 本身已经是 async,第三个参数反而更顺手,也更不容易误用。
为什么这个案例没有用 onWatcherCleanup()
回到个人主页距离展示这个案例,理论上它也能写成 onWatcherCleanup(),只要在第一个 await 前注册 cleanup:
import { onWatcherCleanup, watch } from 'vue'
watch(source, async () => {
let stale = false
onWatcherCleanup(() => {
stale = true
})
const currentPoint = await getBrowserGeoPointIfAlreadyGranted()
if (stale || !currentPoint) return
distanceText.value = formatDistance(currentPoint)
})这段代码能工作,只是第三个参数更适合当前场景。
第一,当前代码已经在 watch callback 里,Vue 已经把 onCleanup 作为第三个参数递进来了。直接使用这个参数,读者一眼就能看出 cleanup 绑定在这一轮 watcher 上,不需要额外理解 activeWatcher 这类隐式上下文。
第二,这个 watcher 是 async 的,真正需要防的是 await getBrowserGeoPointIfAlreadyGranted() 之后的旧结果回写。onCleanup 参数和局部的 stale 变量写在一起,语义更贴近:“这一轮 callback 过期以后,把这一轮自己的 stale 改掉”。
第三,onWatcherCleanup() 对调用时机更敏感。它必须在同步阶段调用,后续如果有人把 cleanup 注册逻辑挪进一个 await 后的 helper,或者为了整理代码把它放到异步分支深处,就会踩到限制。onCleanup 参数虽然也应该尽早注册,但它不是靠全局当前 watcher 推断 owner,代码维护时更直观。
所以这个案例的选择可以概括成一句话:async watch callback 里已经拿到了 onCleanup,并且这里只是给本轮异步打过期标记,第三个参数更直接,也更不容易让后续维护者误用。
我的选型建议是这样:
- 已经在
watch(source, callback)的 callback 里,尤其 callback 是async:优先用第三个参数onCleanup。 - 使用
watchEffect,或者写了一个只能在 watcher 同步阶段调用的同步 helper,想在 helper 内部注册 cleanup:可以用onWatcherCleanup()。 - 副作用能取消,比如
fetch、timer、事件监听:两种写法都可以,重点是 cleanup 要在副作用创建后立刻注册。 - 副作用不能取消,比如 geolocation、某些旧 SDK callback:cleanup 里通常只能写 stale flag、request id 或 token,重点是
await后必须检查是否过期。 - 清理逻辑和 watcher 失效无关,只和组件 / composable 生命周期有关:不要用 watcher cleanup,改用
onScopeDispose()或组件生命周期钩子。
用表格看会更清楚:
| 场景 | 更推荐 | 原因 |
|---|---|---|
watch(id, async (..., onCleanup) => {}) |
onCleanup 参数 |
callback 已经拿到绑定好的 cleanup 注册器,适合异步回写保护 |
watchEffect(() => { ... }) |
onWatcherCleanup() 或 effect 参数里的 onCleanup |
两者都能用;如果不想把参数往下传,同步 helper 里可以用 onWatcherCleanup() |
同步创建 AbortController / timer / listener |
两者都可以 | 只要在 await 前注册,核心效果一致 |
cleanup 注册点可能被移到 await 后 |
onCleanup 参数更稳 |
onWatcherCleanup() 离开同步阶段后就找不到当前 watcher |
| 只想在组件卸载时清理 | onScopeDispose() |
这不是 watcher 每轮失效问题 |
它清理的是副作用,不是响应式值
cleanup 容易被误解成“把 ref 清空”。这只是一种可能,但不是它的核心价值。
watcher 的副作用通常有几类:
- 网络请求:用
AbortController取消,或者至少防止旧结果回写。 - 定时器:
clearTimeout/clearInterval。 - 事件监听:
removeEventListener。 - 外部 SDK 订阅:调用 SDK 提供的 unsubscribe。
- 不可取消的异步 API:用 stale flag、递增 token 或 request id 防止旧结果写入。
响应式值要不要清空,是业务状态决定的。比如距离展示里,监听条件变化后先把 distanceText 清空,是因为旧距离已经不可信;cleanup 只负责标记“上一轮异步已经过期”。
可以把两件事分开:
watch(source, async (_value, _oldValue, onCleanup) => {
let stale = false
onCleanup(() => {
stale = true
})
result.value = ''
const nextResult = await doAsyncWork()
if (stale) return
result.value = nextResult
})result.value = '' 是当前这一轮 watcher 的业务初始化;stale = true 是上一轮 watcher 的失效标记。它们刚好写在同一个 callback 里,但语义不是一回事。
watchEffect 也有同类问题
watchEffect 的第一个参数也会收到 onCleanup:
watchEffect((onCleanup) => {
const timer = window.setInterval(() => {
refresh()
}, 1000)
onCleanup(() => {
window.clearInterval(timer)
})
})不过 watchEffect 还有一个额外边界:它只会自动追踪同步执行阶段读取到的响应式依赖。官方指南里也提醒过,async callback 里只有第一个 await 前访问到的依赖会被追踪。
所以涉及异步请求、路由参数、用户 id、权限状态这类明确来源时,我通常更偏向 watch。watch 把“谁触发重新执行”和“重新执行以后做什么”拆开了,依赖更清楚,也更适合写注释。
watch(
() => [route.params.uid, session.value?.uid],
async (_state, _oldState, onCleanup) => {
// 这里的触发源已经被明确列出来,后续维护时不用猜 callback 里读了哪些响应式值。
},
)一个真实案例:定位距离为什么要 cleanup
回到开头的距离展示。它有几个业务约束:
- 只在客态展示,自己看自己不用算距离。
- 官方号不展示距离。
- 对方隐藏位置时不展示距离。
- Web 不主动弹浏览器定位授权,只在权限已经允许时读取位置。
- 定位 API 不能像请求一样取消。
这些约束会让 watcher 很容易变成“异步状态机”。如果仍然只写:
watch(source, async () => {
const currentPoint = await getBrowserGeoPointIfAlreadyGranted()
distanceText.value = formatDistance(currentPoint)
})页面切换时就有旧结果回写的风险。修正后的结构应该把“本轮是否过期”写清楚:
watch(
() => [
isSelf.value,
isOfficial.value,
onlineInfo.value?.hideSelfLocate,
onlineInfo.value?.latitude,
onlineInfo.value?.longitude,
],
async (_currentState, _previousState, onCleanup) => {
let stale = false
// 定位 API 不能像 fetch 一样取消;watch cleanup 会在依赖变化或组件卸载时标记本次异步过期,避免旧距离回写新页面。
onCleanup(() => {
stale = true
})
distanceText.value = ''
if (isSelf.value || isOfficial.value || profileOnlineInfoHidesDistance(onlineInfo.value)) return
const targetPoint = resolveProfileOnlineInfoPoint(onlineInfo.value)
if (!targetPoint) return
const currentPoint = await getBrowserGeoPointIfAlreadyGranted()
if (stale || !currentPoint) return
distanceText.value = formatProfileDistance(distanceBetweenGeoPoints(targetPoint, currentPoint))
},
{ immediate: true },
)这里的 cleanup 不会取消浏览器定位,但能保证旧定位结果不会污染新页面。对这类不可取消异步 API 来说,这已经是最关键的安全边界。
什么时候不用 cleanup
不是所有 watcher 都要写 cleanup。它适合处理“这一轮 callback 会留下后续才完成的事情”的场景。
如果只是同步派生状态,就不需要:
watch(name, (nextName) => {
displayName.value = nextName.trim()
})如果只是触发一次同步埋点,也通常不需要:
watch(activeTab, (tab) => {
trackTabView(tab)
})如果项目用了数据请求库,并且库本身已经处理了请求去重、取消和过期结果丢弃,也可以优先复用库的机制。不要为了“看起来安全”再叠一层局部 stale flag,最后反而让真实状态来源变复杂。
总结
watchcallback 的第三个参数onCleanup不是 Vue 3.5 新增能力;Vue 3.5 新增的是onWatcherCleanup()。- cleanup 会在 watcher 当前这一轮失效时执行,常见触发点是依赖变化、watcher 停止或组件卸载。
- Vue 3.5 的源码里,
onWatcherCleanup()默认依赖activeWatcher;onCleanup参数则已经绑定到当前 watcher effect。 - 能取消的副作用优先取消,比如
fetch配AbortController。 - 不能取消的异步 API 用 stale flag 或 token 防止旧结果回写,比如 geolocation。
onWatcherCleanup()必须同步调用,不能放在await后;onCleanup参数更适合已经写成async的 callback。- cleanup 清理的是副作用,不等于自动重置业务状态。业务状态要不要清空,仍然应该按当前场景单独判断。
我现在对这类 watcher 的判断很简单:只要 callback 里出现了 await、timer、事件监听、外部订阅或不可取消浏览器 API,就先问一句:这一次执行过期以后,旧结果还会不会回来改页面?如果答案是会,cleanup 就应该上场。
