副作用清理与 onUnmounted
组合式 API 与生命周期 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
副作用清理是「能写对」和「写得稳」的分水岭。这一节掌握三类必须清理的资源(定时器、事件监听、进行中的请求)、onUnmounted 与 watch 的 onCleanup 的分工,以及一个容易忽略的坑:清理函数里不能依赖已经失效的响应式状态。
问题场景
一个聊天组件每 3 秒轮询一次新消息。你在 onMounted 里启动定时器,但忘了清理。切到别的页面后,定时器还在跑,控制台报「Cannot read properties of null」——组件已卸载,回调里访问的状态没了。更隐蔽的是:用户快速切换两次,旧定时器没清、新定时器又起,同一时刻有两个轮询在跑,消息重复、接口压力翻倍。
核心解释
Vue 的规则很简单:在组件生命周期内创建的外部资源,必须在卸载时释放。外部资源包括:
<script setup>
import { onMounted, onUnmounted, ref } from "vue";
const online = ref(true);
let timer = null;
let controller = null;
function onOnline() {
online.value = true;
}
function onOffline() {
online.value = false;
}
onMounted(() => {
// 1. 定时器
timer = setInterval(() => {
console.log("轮询中", Date.now());
}, 3000);
// 2. 全局事件监听
window.addEventListener("online", onOnline);
window.addEventListener("offline", onOffline);
// 3. 进行中的请求
controller = new AbortController();
fetch("/api/notifications", { signal: controller.signal }).catch(() => {});
});
onUnmounted(() => {
clearInterval(timer);
window.removeEventListener("online", onOnline);
window.removeEventListener("offline", onOffline);
controller?.abort();
});
</script>
<template>
<p>网络状态:{{ online ? "在线" : "离线" }}</p>
</template>
三条规则对应三类资源,缺一不可。removeEventListener 必须传同一个函数引用——如果监听时写匿名函数,卸载时无法移除。所以命名函数或用 AbortController 统一取消:
// 现代写法:AbortController 一次取消所有监听
const ac = new AbortController();
window.addEventListener("online", onOnline, { signal: ac.signal });
window.addEventListener("offline", onOffline, { signal: ac.signal });
// 卸载时
ac.abort();
watch 的清理:watch 回调里创建的临时资源(比如防抖定时器、竞态请求),用第三个参数 onCleanup 清理,它会在「下一次回调执行前」和「组件卸载时」各触发一次:
import { ref, watch } from "vue";
const keyword = ref("");
const results = ref([]);
watch(keyword, async (value, _old, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort()); // 新输入到来时取消上一次请求
const res = await fetch(`/api/search?q=${encodeURIComponent(value)}`, {
signal: controller.signal,
});
results.value = await res.json();
});
onUnmounted 与 onCleanup 的分工:组件级的长期资源(定时器、全局监听、第三方实例)放 onUnmounted;随每次回调重建的短命资源(每次请求的 controller、每次的防抖 timer)放 onCleanup。
一个隐蔽的坑:清理函数里不要读「已经不可用」的响应式状态。卸载后读 ref 一般没问题(对象还在),但依赖 DOM 的操作会失败。清理逻辑要写成「不依赖组件状态」的纯释放动作。
示例拆解
一个完整的「实时时钟 + 在线状态」组件,把所有清理点标出来:
<script setup>
import { onMounted, onUnmounted, ref } from "vue";
const now = ref(new Date());
const online = ref(navigator.onLine);
let timer = null;
const ac = new AbortController();
function tick() {
now.value = new Date();
}
function syncOnline() {
online.value = navigator.onLine;
}
onMounted(() => {
tick();
timer = setInterval(tick, 1000);
window.addEventListener("online", syncOnline, { signal: ac.signal });
window.addEventListener("offline", syncOnline, { signal: ac.signal });
});
onUnmounted(() => {
clearInterval(timer);
timer = null;
ac.abort(); // 一次移除两个监听
});
</script>
<template>
<p>{{ now.toLocaleTimeString() }}</p>
<p>{{ online ? "在线" : "离线" }}</p>
</template>
验证方法:把这个组件放进一个可切换显示/隐藏的父组件(v-if),反复切换 20 次,观察控制台——如果没有清理,控制台会累积多条日志、时间跳动速度变快。
动手任务
- 完成上面的时钟组件,用
v-if反复挂载卸载,验证清理生效(控制台日志不累积、秒数不乱跳)。 - 把
setInterval改成requestAnimationFrame循环版本,写出对应的取消逻辑(cancelAnimationFrame)。 - 给 V03 的搜索例子加上
onCleanup,用 Network 面板确认快速输入时旧请求被 abort(状态显示(canceled))。
常见错误
- 监听时用匿名函数,卸载时无法移除:
addEventListener("resize", () => {...})后无法removeEventListener。用具名函数或 AbortController。 - 只清定时器不清请求:请求返回后回调还在跑,访问已卸载组件的状态。用 AbortController。
- 在 onUnmounted 里访问 DOM:此时 DOM 已移除,
ref.value为 null。清理逻辑不要依赖 DOM。 - 把 onCleanup 当 onUnmounted 用:onCleanup 只在「下一次回调」和「卸载」时触发,如果回调只执行一次,它和卸载清理等价;但语义上仍建议组件级资源放 onUnmounted。
检查与复盘
- 能列出三类必须清理的资源,并各写出清理代码
- 能说清 onCleanup 的触发时机与适用场景
- 能解释「匿名监听函数无法移除」的原因
参考资料
- Vue 官方文档「生命周期钩子」:onUnmounted 的用途
- Vue 官方文档「侦听器」:onCleanup 参数说明
- MDN:AbortController 与事件监听的 signal 选项
读完这一课了?
标记完成后,工作台会安排接下来的学习。