Vue watch 的 cleanup 到底清理了什么

有一类前端 bug 很难从截图里看出来:页面已经切到新用户了,旧用户的异步结果才慢悠悠回来,然后把新页面上的一小块状态覆盖掉。

这次遇到的是个人主页里的距离展示。组件会监听用户身份、在线状态和经纬度,条件满足时读取浏览器已有定位权限,再计算“离我多远”。真正麻烦的是定位 API 本身:它不像 fetch 一样天然能用 AbortController 取消。用户快速切换个人主页时,旧页面发起的定位读取仍然可能在新页面渲染后完成。

这时就需要 Vue watcher 的 cleanup。

先回答最容易混淆的问题:watch 回调参数里的 onCleanup 不是新版 Vue 才加的。Vue 3.5 新增的是另一个 API:onWatcherCleanup()。两者解决的是同一类问题,但调用形态和限制不同。

一次 watcher callback 的生命周期

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

先从官方契约看两种写法

官方资料可以分成三层看:

这三个来源拼起来,结论就很清楚:onCleanup 是 watcher callback 的既有参数;onWatcherCleanup() 是 3.5 新增的全局 API。它们都注册 cleanup,但注册时找“当前 watcher”的方式不一样。

onCleanup 和 onWatcherCleanup 的注册路径

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.35packages/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/reactivityruntime-core 负责把它放进组件运行时。

这也是为什么源码证据要看两个文件:reactivity/src/watch.ts 告诉我们 cleanup 如何注册和执行,runtime-core/src/apiWatch.ts 告诉我们组件里的 watch 如何包到这套基础实现上。

旧版本已经有 onCleanup

如果只看 Vue 3.5 的 onWatcherCleanup(),很容易误以为“cleanup 是 3.5 才有的新东西”。对照 vuejs/core@v3.4.38packages/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 版本,watch callback 参数里的 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() 的使用方式其实只有三步:

  1. 在 callback 这一轮里创建一个副作用,例如请求、定时器或事件监听。
  2. 立刻调用 onWatcherCleanup(),把清理函数注册给当前 watcher。
  3. 当 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 是在同步阶段读到的,所以它会触发重新执行;如果响应式值只在后面的 thenawait 之后才读到,就不会被这次 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、权限状态这类明确来源时,我通常更偏向 watchwatch 把“谁触发重新执行”和“重新执行以后做什么”拆开了,依赖更清楚,也更适合写注释。

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,最后反而让真实状态来源变复杂。

总结

  • watch callback 的第三个参数 onCleanup 不是 Vue 3.5 新增能力;Vue 3.5 新增的是 onWatcherCleanup()
  • cleanup 会在 watcher 当前这一轮失效时执行,常见触发点是依赖变化、watcher 停止或组件卸载。
  • Vue 3.5 的源码里,onWatcherCleanup() 默认依赖 activeWatcheronCleanup 参数则已经绑定到当前 watcher effect。
  • 能取消的副作用优先取消,比如 fetchAbortController
  • 不能取消的异步 API 用 stale flag 或 token 防止旧结果回写,比如 geolocation。
  • onWatcherCleanup() 必须同步调用,不能放在 await 后;onCleanup 参数更适合已经写成 async 的 callback。
  • cleanup 清理的是副作用,不等于自动重置业务状态。业务状态要不要清空,仍然应该按当前场景单独判断。

我现在对这类 watcher 的判断很简单:只要 callback 里出现了 await、timer、事件监听、外部订阅或不可取消浏览器 API,就先问一句:这一次执行过期以后,旧结果还会不会回来改页面?如果答案是会,cleanup 就应该上场。