EventTarget 监听清理:函数长得一样,也不代表能移除同一个订阅

10-01 4阅读

一个面板打开三次后,同一条更新消息触发三次处理,常见原因是每次挂载都注册了新监听,卸载时却没有移除原来的函数。写两遍内容相同的箭头函数,并不会得到同一个函数对象。订阅关系认的是实际回调身份,而不是源码文字。

EventTarget 的移除需要匹配事件类型、回调以及捕获标记。最直接的办法是把函数保存下来,添加与移除都使用它。若一个组件有多项订阅,也可以让它们共用一个 AbortSignal,在结束生命周期时统一取消,避免漏掉其中一项。

不用页面也能验证三种生命周期

下面保存为 demo.mjs,用 Node.js 运行。代码使用独立 EventTarget,不会操作浏览器页面或系统事件。先故意用另一个函数移除订阅,观察失败后再正确移除;接着分别验证 once 和 signal 的行为,所有结果都由显式断言检查。

这里的事件是同步分发的,因此每次 dispatchEvent 返回后就能核对计数。实例故意把三个实验拆成三个目标,避免旧监听影响后续结果。真实页面应把目标、类型和处理函数作为一组信息保存,方便在卸载入口统一清理。

EventTarget 监听清理:函数长得一样,也不代表能移除同一个订阅

AI概念示意图:以抽象物件说明本文主题,不代表真实界面或运行结果。

import assert from 'node:assert/strict';
const target = new EventTarget();
let hits = 0;
const handler = () => { hits++; };
target.addEventListener('tick', handler);
target.removeEventListener('tick', () => { hits++; });
target.dispatchEvent(new Event('tick'));
assert.equal(hits, 1);
target.removeEventListener('tick', handler);
target.dispatchEvent(new Event('tick'));
assert.equal(hits, 1);
console.log(`identity hits=${hits}`);

const onceTarget = new EventTarget();
let onceHits = 0;
onceTarget.addEventListener('ready', () => { onceHits++; }, {once: true});
onceTarget.dispatchEvent(new Event('ready'));
onceTarget.dispatchEvent(new Event('ready'));
assert.equal(onceHits, 1);
console.log(`once hits=${onceHits}`);

const grouped = new EventTarget();
const controller = new AbortController();
let first = 0, second = 0;
grouped.addEventListener('a', () => { first++; }, {signal: controller.signal});
grouped.addEventListener('b', () => { second++; }, {signal: controller.signal});
grouped.dispatchEvent(new Event('a'));
controller.abort();
grouped.dispatchEvent(new Event('a'));
grouped.dispatchEvent(new Event('b'));
assert.deepEqual([first, second], [1, 0]);
console.log(`group hits=${first},${second}`);

清理的是订阅,不是已经开始的工作

输出依次为 identity hits=1、once hits=1 和 group hits=1,0。第一组中错误的移除没有生效,第二次正确移除才阻止后续事件。第三组里事件 b 一次也没有触发,却已在取消时被移除;批量清理不需要等到每个订阅都被使用。

once 表示最多处理一次匹配的事件。它适合等待初始化完成,却不能替代所有卸载清理:如果目标事件始终不出现,监听仍可能留在目标上。把 once 与组件自己的 signal 合用,才能同时表达“一次触发”与“组件离开后不再等待”。

abort 会移除使用这个信号注册的监听,但不会撤回已经执行的回调。若回调启动了异步操作,该操作需要自己的取消设计。尤其不能因为监听已删,就认为它先前发起的数据加载、计时器或其他资源也自动结束了。

让挂载和卸载成为可以配对的动作

每次新生命周期创建一个新的 AbortController。信号一旦取消就保持取消状态,不能通过再次注册让它恢复使用;拿旧信号添加监听会得到与预期不同的结果。把控制器放在对应实例上,通常比放在全局共享变量里更容易管理。

如果采用手动 removeEventListener,捕获标记也要与添加时一致。passive 等选项不构成同样的匹配条件,但不应依赖这个区别来省略订阅记录。更稳妥的做法是把添加时使用的关键参数保留,卸载时对照执行。

Node.js 的 EventTarget 与浏览器 DOM 存在环境差异,本例只验证共同的订阅行为。它没有构建父子节点树,也不能证明浏览器的捕获、冒泡或默认行为取消逻辑。页面实际使用这些能力时,需要在真实目标结构上另做相应验证。

排查重复处理时,可以先把一次挂载、一次触发、一次卸载、再次触发写成四步测试。最后一步应不再改变状态。再重复整个流程,确认每轮计数只增长一次;这比在处理函数里面加一个“已经执行过”的标志,更容易找到根本原因。

参考资料

  1. WHATWG DOM:EventTarget 与监听器移除

  2. Node.js:EventTarget 与 Event API

文章版权声明:除非注明,否则均为云鹊BLOG原创文章,转载或复制请以超链接形式并注明出处。