listener-leak
本文回答:监听器未 off 导致内存泄漏 —— 现象、定位、修法。
一、症状
- 长时间使用后内存持续上涨,不会回落
- 切换页面后点击依旧 fire 回调(你以为已经卸载了)
- 控制台长时间挂着后明显变慢
二、根因
map.on(...) 或 eventBus.on(...) 注册后,回调函数会被内部长期持有;回调的闭包又持有外层变量 / DOM / 组件实例 → 整条链无法 GC。
三、典型泄漏模式
1 匿名函数不 off
map.on('click', () => { ... }); // 永远 off 不掉
2 重复 setup
function setup() {
map.on('click', onClick);
}
setup(); setup(); setup(); // 一次 click fire 3 次
3 忘 off
const h = () => updatePanel();
map.on('moveend', h);
// 组件卸载时:没 off
四、修法
A. 保留 handler 引用
const onClick = e => { ... };
map.on('click', onClick);
// 卸载
map.off('click', onClick);
B. 统一集合管理
const subs = [];
function on(target, event, h) {
target.on(event, h);
subs.push(() => target.off(event, h));
}
on(map, 'click', onClick);
on(map, 'moveend', onMoveEnd);
on(mapcadLayer.eventBus, VjmapextEvents.EntityAdded, onEntityAdded);
// 卸载
subs.forEach(fn => fn());
subs.length = 0;
C. eventBus.clear
mapcadLayer.eventBus.clear(); // 清所有订阅(慎用)
注意:会清掉 vjmapext 内部自己的监听;仅在 mapcadLayer 被整体销毁时用。
D. once
map.once('load', () => { ... });
自动解绑,避免泄漏。
五、定位泄漏
方法 1:Chrome Memory
- 打开页面,Memory → Take heap snapshot(快照 1)
- 做操作(进出页面 5~10 次)
- 点"垃圾桶"强制 GC
- 再 Take heap snapshot(快照 2)
- 选 Snapshot 2 → Comparison 快照 1
- 搜
Map/MapCadLayer/Marker→ 增量 > 0 说明泄漏
方法 2:showRetainers
- 在 Snapshot 找到泄漏对象
- 右下 "Retainers" 面板
- 看这个对象被哪条引用链持有
- 一般是
Window → MyComponent → handler → Closure → map / Marker - 定位到 handler → 代码里找对应
on调用 → 补 off
六、Vue 3 响应式 map 变量
const map = reactive(null); // ❌ 不要 reactive wrap map
Vue 响应式代理会持有 map 的强引用;组件卸载后可能因为其它 watch 仍然引用 map 造成额外泄漏。
const map = shallowRef(null); // ✅ 浅引用
七、SetInterval / RAF 泄漏
setInterval(() => updateMarkers(), 1000); // 保存 id
必须保存 id 并在卸载时 clear:
const id = setInterval(() => updateMarkers(), 1000);
// 卸载
clearInterval(id);
RAF 同理:
let rafId;
function tick() {
update();
rafId = requestAnimationFrame(tick);
}
tick();
// 卸载
cancelAnimationFrame(rafId);
八、跨组件的 eventBus 泄漏
组件 A 订阅 eventBus 事件;组件 A 卸载但没 off → eventBus 仍持有 A 的 handler → A 的 render 函数 / 整个组件实例 无法回收:
onBeforeUnmount(() => {
mapcadLayer.eventBus.off(VjmapextEvents.SelectionChanged, onSelChanged);
});
九、DOM 事件泄漏
container.addEventListener('contextmenu', onCtx);
// 卸载
container.removeEventListener('contextmenu', onCtx);
十、批量事件 off helper
function Listenable() {
this.subs = [];
}
Listenable.prototype.on = function(t, e, h) {
t.on(e, h);
this.subs.push(() => t.off(e, h));
};
Listenable.prototype.onDom = function(el, e, h) {
el.addEventListener(e, h);
this.subs.push(() => el.removeEventListener(e, h));
};
Listenable.prototype.timeout = function(fn, ms) {
const id = setTimeout(fn, ms);
this.subs.push(() => clearTimeout(id));
};
Listenable.prototype.interval = function(fn, ms) {
const id = setInterval(fn, ms);
this.subs.push(() => clearInterval(id));
};
Listenable.prototype.off = function() {
this.subs.forEach(fn => fn());
this.subs.length = 0;
};
const L = new Listenable();
L.on(map, 'click', onClick);
L.on(mapcadLayer.eventBus, 'entity:added', onAdded);
L.onDom(container, 'contextmenu', onCtx);
L.interval(refresh, 1000);
// 卸载
L.off();
十一、常见坑
- 箭头函数 inline on:无法 off;必须保存引用。
- Vue reactive map:强引用死链;用 shallowRef。
- 定时器 ID 被覆盖:多次 setInterval 最后只能 clear 最后一个;集中管理。
- 组件卸载 API 选错:keepAlive 下用
onDeactivated,不是onBeforeUnmount。 - Worker 未 terminate:独立线程继续跑,持有 postMessage 的引用链。
相关
- 事件清理 → event/cleanup
- 内存泄漏总览 → perf/memory-leaks
- Vue / React 卸载 → vue-react-dispose