07|组合式函数的资源清理
第四章概览 / 07。目标:把请求、监听器、计时器、watcher 与订阅统一成“创建时拿到句柄,失效时调用释放动作”的合同。
资源表先于代码
| 创建动作 | 句柄或身份 | 释放动作 | 典型失效时刻 |
|---|---|---|---|
fetch(..., { signal }) | AbortController | controller.abort() | 新请求取代旧请求、scope 结束 |
addEventListener | target + type + 同一 listener + capture | removeEventListener | target 改变、组件卸载 |
setInterval / setTimeout | timer handle | clearInterval / clearTimeout | 暂停、卸载、重设间隔 |
watch / watchEffect | stop handle | stop() | 提前停止;同步创建且归属实例时卸载会自动停止 |
第三方 subscribe | unsubscribe / connection | unsubscribe() / close() | 参数变化、scope 结束 |
垃圾回收不能替代协议清理。只要外部事件源仍持有 listener,或计时器队列仍持有回调,组件就仍可能被间接引用并继续工作。
最小可运行 composable:自动刷新
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 的状态 | 允许写入 |
|---|---|---|---|
| 启动 A | 1 | A 等待 | A |
| 启动 B | 2 | A signal 被取消,B 等待 | B |
| B 成功 | 2 | 得到 B 结果 | B 写 data,loading 变 false |
| A 晚到 | 2 | A 得到结果,但自己的轮次是 1 | A 不写 data/error/loading |
| 调用 stop | 3 | 剩余 signal 取消 | 以前各轮均不得写入 |
Abort 是向外部执行者发送停止请求;轮次判断是本地写入许可。两者分开才能处理“不支持取消”“取消时结果已返回”以及卸载后的晚到结果。成功、失败与 finally 都要检查许可,不能只保护 loading。
watcher 的“每一轮清理”
组件卸载清理只解决最后一轮。响应式输入变化时,上一轮副作用应在下一轮开始前失效:
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 不会自动归属原实例:
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 题。