Skip to content

07|组合式函数的资源清理 ​

第四章概览 / 07。目标:把请求、监听器、计时器、watcher 与订阅统一成“创建时拿到句柄,失效时调用释放动作”的合同。

资源表先于代码 ​

创建动作句柄或身份释放动作典型失效时刻
fetch(..., { signal })AbortControllercontroller.abort()新请求取代旧请求、scope 结束
addEventListenertarget + type + 同一 listener + captureremoveEventListenertarget 改变、组件卸载
setInterval / setTimeouttimer handleclearInterval / clearTimeout暂停、卸载、重设间隔
watch / watchEffectstop handlestop()提前停止;同步创建且归属实例时卸载会自动停止
第三方 subscribeunsubscribe / connectionunsubscribe() / close()参数变化、scope 结束

垃圾回收不能替代协议清理。只要外部事件源仍持有 listener,或计时器队列仍持有回调,组件就仍可能被间接引用并继续工作。

最小可运行 composable:自动刷新 ​

ts
import { onMounted, onScopeDispose, readonly, ref, shallowRef } from 'vue'

type Fetcher<T> = (signal: AbortSignal) => Promise<T>

interface AutoRefreshOptions {
  intervalMs?: number
  target?: Window
}

export function useAutoRefresh<T>(
  fetcher: Fetcher<T>,
  options: AutoRefreshOptions = {}
) {
  const intervalMs = options.intervalMs ?? 30_000
  const target = options.target ??
    (typeof window === 'undefined' ? undefined : window)

  if (intervalMs <= 0) throw new RangeError('intervalMs 必须大于 0')

  const data = shallowRef<T | null>(null)
  const error = ref<Error | null>(null)
  const loading = ref(false)
  let timer: ReturnType<typeof setInterval> | undefined
  let controller: AbortController | undefined
  let runId = 0

  async function run() {
    controller?.abort()
    const currentController = new AbortController()
    const currentRun = ++runId
    controller = currentController
    loading.value = true
    error.value = null

    try {
      const result = await fetcher(currentController.signal)
      if (currentRun === runId && !currentController.signal.aborted) {
        data.value = result
      }
    } catch (cause) {
      if (!currentController.signal.aborted && currentRun === runId) {
        error.value = cause instanceof Error ? cause : new Error(String(cause))
      }
    } finally {
      if (currentRun === runId) {
        loading.value = false
        controller = undefined
      }
    }
  }

  function onFocus() {
    void run()
  }

  function start() {
    if (!target || timer !== undefined) return
    target.addEventListener('focus', onFocus)
    timer = setInterval(() => void run(), intervalMs)
    void run()
  }

  function stop() {
    controller?.abort()
    controller = undefined
    runId += 1
    if (timer !== undefined) {
      clearInterval(timer)
      timer = undefined
    }
    target?.removeEventListener('focus', onFocus)
    loading.value = false
  }

  onMounted(start)
  onScopeDispose(stop)

  return {
    data: readonly(data),
    error: readonly(error),
    loading: readonly(loading),
    refresh: run,
    stop
  }
}

每次调用创建自己的 controller、timer 和 refs。start() 幂等,监听器 add/remove 使用同一个函数。浏览器 target 采用惰性、可注入的选择:SSR 中是 undefined,测试中可以传 fake target。

runId 处理一个容易漏掉的失败路径:旧请求 Abort 后,它的 finally 仍会执行;若直接写 loading = false,会把新请求仍在进行的状态抹掉。只有当前轮次能完成当前状态。

把两次刷新排成时间线 ​

假定第一次请求 A 比第二次 B 慢,而且测试 fetcher 忽略 Abort:

时刻当前轮次A / B 的状态允许写入
启动 A1A 等待A
启动 B2A signal 被取消,B 等待B
B 成功2得到 B 结果B 写 data,loading 变 false
A 晚到2A 得到结果,但自己的轮次是 1A 不写 data/error/loading
调用 stop3剩余 signal 取消以前各轮均不得写入

Abort 是向外部执行者发送停止请求;轮次判断是本地写入许可。两者分开才能处理“不支持取消”“取消时结果已返回”以及卸载后的晚到结果。成功、失败与 finally 都要检查许可,不能只保护 loading。

watcher 的“每一轮清理” ​

组件卸载清理只解决最后一轮。响应式输入变化时,上一轮副作用应在下一轮开始前失效:

ts
watch(query, async (value, _oldValue, onCleanup) => {
  const controller = new AbortController()
  let active = true
  onCleanup(() => {
    active = false
    controller.abort()
  })

  try {
    const response = await fetch(`/api/search?q=${encodeURIComponent(value)}`, {
      signal: controller.signal
    })
    if (!response.ok) throw new Error(`HTTP ${response.status}`)
    const nextResults = await response.json()
    if (active) results.value = nextResults
  } catch (cause) {
    if (active) error.value = cause instanceof Error ? cause : new Error(String(cause))
  }
})

这是组件 setup 内的局部片段,query 是输入 ref,results 与 error 是该组件的结果和错误 ref。每轮拥有自己的 active,清理后永远变成 false;网络完成、JSON 解析完成与 watcher 失效可能交错,所以只在最后写入前检查一次 HTTP 请求是否结束是不够的。这里把“这一轮还能不能写”与请求取消分开,即使替换成忽略 signal 的测试实现,也不会接受已失效的结果。

回调参数 onCleanup 在 Vue 3 各版本可用,并绑定到当前 watcher。Vue 3.5+ 可改用 onWatcherCleanup(() => controller.abort()),但它必须在 watcher 回调的同步阶段、任何 await 之前调用。两种方式都不是“自动取消请求”:真正的取消能力仍来自 AbortController。

同步创建在 setup() / <script setup> 中的 watcher 会在所属组件卸载时自动停止,并执行当前轮清理。异步回调中才创建的 watcher 不会自动归属原实例:

ts
setTimeout(() => {
  const stop = watchEffect(syncExternalState)
  // 现在必须把 stop 保存到外部可达位置并手动调用。
}, 100)

更好的写法是同步创建 watcher,在回调内等待数据条件。

onUnmounted、onScopeDispose 与显式 stop ​

  • 只服务组件 DOM 的逻辑用 onUnmounted 容易理解。
  • 可在组件或手动 effectScope 中使用的 composable,优先 onScopeDispose,让资源归属当前 effect scope。
  • 调用方需要“组件仍在但先暂停”时,额外返回幂等 stop();自动清理仍保留为兜底。

这些注册都要同步发生在有效 scope 中。Vue 3.5 为 onScopeDispose(fn, true) 增加了静默失败参数,但库代码不应靠静默掩盖缺少 scope;若支持无 scope 调用,应显式返回清理句柄并写清责任。

失败路径清单 ​

  • catch 所有异常并忽略:会把网络失败、解析失败与 Abort 混成“没事”。应只忽略已确认的取消。
  • add/remove 使用不同匿名函数:监听器无法移除。
  • 每次 refresh 新建 interval:轮询倍增。
  • 只 Abort 不做轮次保护:旧 finally 仍可能覆盖新状态。
  • 在 setup 根部启动浏览器资源:SSR 会报错或留下无法卸载的服务端计时器。
  • 只依赖 watcher 自动停止:外部订阅和计时器仍需自己的释放动作。

练习:实现 useAutoRefresh ​

按主例接口重新实现,合同如下:

  • fetcher(signal) 成功写 data;普通失败写 error;Abort 不显示错误。
  • 调用即不启动,客户端 mounted 后自动启动;refresh() 会取消上一请求。
  • 只存在一个 interval 和一个 focus listener;stop() 可重复调用。
  • scope 结束后请求、interval、listener 全部释放,之后 fake target 发 focus 不再请求。
  • SSR 调用返回确定的空状态,不访问 window,每次调用互相隔离。
  • 用受控 Promise 制造“第一次后完成、第二次先完成”,断言旧结果和旧 finally 都不能覆盖当前状态。

参考答案:查看第 07 题。

用心记录每一步成长