DOM 删掉了监听还在:用 AbortController 管理组件挂载期事件
- 发布时间:2026-10-23 23:03:31
- 本文热度:浏览 1 赞 0 评论 0
- 文章标签: JavaScript 事件处理 组件生命周期
- 全文共1字,阅读约需1分钟
组件从页面删掉了,document上的监听器却还在运行。再次挂载时又注册一份,点一次按钮触发两次逻辑,问题看起来像重复请求,实际是上一次生命周期没有结束。
removeChild只处理DOM关系,不会自动替全局目标上的监听完成清理。依靠垃圾回收也不足以表达何时停止响应事件:只要document仍持有回调,回调捕获的组件状态就仍可能可达。
原生addEventListener支持signal,可以用一个AbortController管理同一次挂载注册的多份监听。它适合表达“这些监听在这次卸载时一起结束”,不需要为每个目标保留一组容易弄错的removeEventListener参数。
一个可以反复挂载的原生组件
保存listener-lifecycle.html,在支持事件监听signal选项的现代浏览器打开。发送按钮派发本地自定义事件,不访问网络,也不代表服务端消息。
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<title>事件监听生命周期</title>
<style>
body { max-width: 44rem; margin: 2rem auto; padding: 1rem; font: 16px sans-serif; }
button { margin: 4px; }
#host { margin-top: 16px; }
section { padding: 16px; background: #e5eef9; }
pre { white-space: pre-wrap; }
</style>
<button id="mount" type="button">挂载</button>
<button id="unmount" type="button">卸载</button>
<button id="ping" type="button">发送本地 ping</button>
<div id="host"></div>
<pre id="report" role="status"></pre>
<script>
const host = document.querySelector('#host');
const report = document.querySelector('#report');
let disposeCurrent = null;
let deliveries = 0;
let generation = 0;
function show(message) {
report.textContent = `${message}\n总接收次数=${deliveries};已创建代数=${generation}`;
}
function mount() {
if (disposeCurrent) { show('组件已经挂载,没有重复注册'); return; }
const lifetime = new AbortController();
const signal = lifetime.signal;
const id = ++generation;
const panel = document.createElement('section');
const status = document.createElement('p');
const local = document.createElement('button');
local.type = 'button';
local.textContent = '组件自己的按钮';
panel.append(status, local);
host.replaceChildren(panel);
let received = 0;
function render() { status.textContent = `第${id}代:接收${received}次,视口宽${window.innerWidth}`; }
document.addEventListener('demo:ping', () => {
received++;
deliveries++;
render();
show(`第${id}代收到本地事件`);
}, { signal });
window.addEventListener('resize', render, { signal });
local.addEventListener('click', () => show(`第${id}代局部按钮`), { signal });
disposeCurrent = () => {
lifetime.abort();
panel.remove();
};
render();
show(`第${id}代已挂载`);
}
function unmount() {
if (!disposeCurrent) { show('当前没有组件'); return; }
const cleanup = disposeCurrent;
disposeCurrent = null;
cleanup();
show('组件已卸载,挂载期监听已中止');
}
document.querySelector('#mount').addEventListener('click', mount);
document.querySelector('#unmount').addEventListener('click', unmount);
document.querySelector('#ping').addEventListener('click', () => {
document.dispatchEvent(new Event('demo:ping'));
if (!disposeCurrent) show('已发送,当前没有组件接收');
});
show('等待挂载');
</script>
</html>
先挂载,发送一次ping,总接收次数应为1。卸载后再发送,次数保持1;重新挂载再发送,变为2,当前第二代只接收1次。连续点两次挂载不会增加代数,也不会重复注册。
测试重点是卸载后的行为,而不是只看组件是否从屏幕消失。一个已经看不见的面板仍能响应全局事件,正是这类问题容易漏掉的地方。
每次挂载拥有一份新控制器
AbortController一旦中止,signal不会恢复为未中止状态。把控制器缓存到模块级,卸载时abort,下一次挂载继续使用同一signal,新监听可能根本不会加入。正确的是每次生命周期新建,由本次dispose持有清理权。
本例将文档事件、窗口resize和局部按钮监听放在同一signal下,因为三者都属于该组件本次挂载。页面顶层的挂载、卸载、发送按钮则属于整个页面,不跟随某个组件实例一起结束。
控制器的归属应与生命周期一致。若把整个应用共用一份signal,一个小组件卸载可能移除其他组件的监听;若每个监听都新建控制器,又失去了统一结束的意义。先定义谁拥有这些回调,再选择清理范围。
函数引用与监听去重不是一回事
removeEventListener要求匹配相应类型、回调和捕获模式。用一个看起来相同的新箭头函数执行删除,并没有提供原来的函数对象,因此通常删不掉。
addEventListener对同一目标上的相同监听有去重规则,但每次挂载创建的新闭包是不同对象,不能指望浏览器替组件识别“这段源码其实相同”。上次闭包还保留旧状态,新闭包又收到新事件,就会出现同一个业务动作被多个代处理。
signal统一清理可以减少这类参数管理,不过仍然需要防止重复挂载。本例在disposeCurrent非空时直接返回,让同一组件实例只进入一次挂载流程;清理技术不能替代组件状态机。
如果需要在保留同一回调的情况下改变监听选项,应核对注册与删除规则,再明确重新注册。不能每次用不同options对象调用add,就假定获得了一份独立、配置已更新的监听。
once选项只表示监听成功触发一次后移除,不等于组件卸载清理。如果事件始终没有发生,那份监听仍可能留着;如果组件需要接收多次事件,once又会提前改变行为。次数约束与生命周期结束是两条不同规则,不能互相替代。
中止监听,不等于取消所有异步工作
事件已经调用回调以后,回调中启动的Promise、定时器或其他任务不会因为监听被移除而自动结束。它们如果也属于组件,应另行使用支持取消的接口或明确清理函数。
例如一个回调启动网络请求,可以把同一生命周期信号传给支持它的请求接口;定时器则仍需要clearTimeout或clearInterval。不能只在addEventListener里传一次signal,就宣称组件所有资源都已回收。
状态更新也要防止过期结果。即使某个下游没有取消能力,返回以后仍应检查当前代数或生命周期状态,避免第一代慢结果覆盖第二代界面。停止监听只阻止以后事件进入,不撤销已经开始的业务副作用。
用行为测试固定卸载合同
可以把挂载、发送、卸载、发送、再挂载、再发送写成固定顺序,核对总次数和当前代次数。再补连续卸载、未挂载先发送、连续挂载,确认每条路径都不会额外注册或访问已经移除的DOM。
本例没有使用框架,真实组件应把dispose放到框架规定的卸载或效果清理入口,而不是仅在某个关闭按钮中调用。路由跳转、错误恢复或父组件替换,都可能绕过那个按钮。
有些开发环境会额外重复执行挂载与清理检查,这反而能暴露生命周期假设。只在一次顺滑打开和关闭中看起来正常,不代表资源边界已经正确。用第二次挂载验证,通常比盯着内存曲线猜泄漏更直接。