唯杰地图 logo唯杰地图
VJMAP
EN

listener-leak

vjmap/references/trouble/listener-leak.md 4.8 KB210 行
原文

本文回答:监听器未 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

  1. 打开页面,Memory → Take heap snapshot(快照 1)
  2. 做操作(进出页面 5~10 次)
  3. 点"垃圾桶"强制 GC
  4. 再 Take heap snapshot(快照 2)
  5. 选 Snapshot 2 → Comparison 快照 1
  6. 搜 Map / MapCadLayer / Marker → 增量 > 0 说明泄漏

方法 2:showRetainers

  1. 在 Snapshot 找到泄漏对象
  2. 右下 "Retainers" 面板
  3. 看这个对象被哪条引用链持有
  4. 一般是 Window → MyComponent → handler → Closure → map / Marker
  5. 定位到 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();

十一、常见坑

  1. 箭头函数 inline on:无法 off;必须保存引用。
  2. Vue reactive map:强引用死链;用 shallowRef。
  3. 定时器 ID 被覆盖:多次 setInterval 最后只能 clear 最后一个;集中管理。
  4. 组件卸载 API 选错:keepAlive 下用 onDeactivated,不是 onBeforeUnmount。
  5. Worker 未 terminate:独立线程继续跑,持有 postMessage 的引用链。

相关