Pointer Events、passive 与 CustomEvent:DOM 事件里被忽略的三个现代能力

事件传播、事件委托这套东西,团队里但凡入职满一年的同事基本都能讲清楚。这一年真正让我停下来重新查文档的,反而是三个平时不太被当回事的角落:一个是移动端组件同时要接鼠标和触摸事件时代码写得又臭又长;一个是长列表滚动明明没干什么重活却还是发涩;还有一个是给一个第三方图表库套壳时发现它想跟外部通信却完全绕开了 Vue 的事件系统。这三件事看起来不相关,但都指向同一类问题:addEventListener 能做的事情,比"绑个 click 判断冒泡"要多得多。

Pointer Events:不用再分别写 mouse 和 touch

我们有个自定义的图片裁剪组件,PC 端用鼠标拖拽选区,平板端用手指拖拽。历史代码是这么写的:

1box.addEventListener('mousedown', onDragStart);
2box.addEventListener('mousemove', onDragMove);
3box.addEventListener('mouseup', onDragEnd);
4
5box.addEventListener('touchstart', onDragStart);
6box.addEventListener('touchmove', onDragMove);
7box.addEventListener('touchend', onDragEnd);

onDragStart 内部还得再判断一层,因为 MouseEventevent.clientXevent.clientY 直接拿坐标,TouchEvent 却要从 event.touches[0] 里取:

1function getPoint(event) {
2  if (event.touches && event.touches.length) {
3    var touch = event.touches[0];
4    return { x: touch.clientX, y: touch.clientY };
5  }
6  return { x: event.clientX, y: event.clientY };
7}

同一份拖拽逻辑要维护两套事件绑定、一层坐标兼容代码,还没算上触屏笔(Surface、部分安卓平板)这类既不是纯手指也不是纯鼠标的输入设备。今年查资料时发现主流浏览器已经把 Pointer Events 补齐了,思路是把鼠标、触摸、触控笔这三种输入统一抽象成同一套事件:pointerdownpointermovepointeruppointercancel,坐标一律走 clientX/clientY,不用再分支判断:

1box.addEventListener('pointerdown', onDragStart);
2box.addEventListener('pointermove', onDragMove);
3box.addEventListener('pointerup', onDragEnd);
4
5function onDragStart(event) {
6  startX = event.clientX;
7  startY = event.clientY;
8  box.setPointerCapture(event.pointerId);
9}

三组监听变成一组,坐标兼容代码直接删掉。裁剪组件改完之后,代码量少了将近一半,而且顺带修掉了一个老 bug:以前手指拖到裁剪框边界再稍微移出去一点,touchmove 就收不到了,因为触摸事件默认只在起始元素上追踪;换成 Pointer Events 后配合 setPointerCapture,即使指针移出了元素范围,后续的 pointermovepointerup 依然会稳定地派发给发起捕获的那个元素,不会半途丢事件。

event.pointerType 是另一个之前没有对应能力的字段,它的值是 'mouse''touch''pen' 之一,可以按需要区分处理:

1box.addEventListener('pointerdown', function (event) {
2  if (event.pointerType === 'touch') {
3    // 触屏点选区域给大一点的容错范围,手指比鼠标指针粗得多
4    hitTolerance = 12;
5  } else {
6    hitTolerance = 4;
7  }
8});

还有一个 Pointer Events 独有、mouse/touch 事件都没有的字段是 event.pressure,触控笔按压力度会反映在这个 0~1 的浮点数上,普通鼠标点击这个值固定是 0.5。我们设计那边一直想要一个"手写签名笔锋粗细"的效果,以前只能靠 touch 的坐标变化速度去估算,现在用支持压感的触控笔可以直接读 pressure 控制线宽,不支持压感的设备退化成固定线宽,体验上是纯粹的增量,不会破坏原有逻辑。

多点触控场景(双指缩放、旋转)目前我们还是用 TouchEventevent.touches 数组处理,因为 Pointer Events 里每根手指是独立的一个指针(有各自的 pointerId),要还原"两指之间距离"这类关系得自己拿多个 pointerId 拼起来,反而更绕;单指拖拽、点击、悬浮这类场景换 Pointer Events 收益最大,双指手势暂时没必要硬切。

兼容性上,2021 年这个时间点,Chrome、Firefox、Edge 都已经稳定支持多年,Safari 是从 13 起支持的,实际项目里如果还要兼容个别老旧安卓 webview,可以保留一份 mouse/touch 的降级分支,但主逻辑没必要再为它们单独维护。

裁剪组件之外,我们后台还有一个更常见的场景:一个可拖拽排序的表格行列表,行数动态增删,用户既可能用鼠标拖,也可能在触屏笔记本上直接用手指拖。这种动态列表如果按老写法维护 mouse/touch 两套事件,绑定和解绑的时机还要跟着行的增删同步,代码复杂度是成倍增加的。这里我们把 Pointer Events 和事件委托结合起来用,监听器只挂在表格容器上,不逐行绑定:

1tableBody.addEventListener('pointerdown', function (event) {
2  var row = event.target.closest('tr[data-row-id]');
3  if (!row) return;
4  startDragRow(row, event);
5});
6
7tableBody.addEventListener('pointermove', function (event) {
8  if (!draggingRow) return;
9  updateDragPosition(event.clientX, event.clientY);
10});
11
12tableBody.addEventListener('pointerup', function (event) {
13  if (!draggingRow) return;
14  endDragRow();
15});

行怎么增删都不需要重新绑定监听器,因为判断的入口始终是容器上的这一组事件,具体命中哪一行靠 event.target.closest() 现算。这其实就是老早就有的事件委托思路,只是把过去分别处理 mousedown/touchstart 的两套委托逻辑合并成了一套。委托和 Pointer Events 放在一起用有个额外的好处:setPointerCapture 可以直接对 event.target(也就是被点中的那一行)调用,不需要事先知道具体是哪个 DOM 节点,因为这个节点就是委托回调里现算出来的那个 row

还有一个之前完全没意识到的细节,是 Pointer Events 天然解决了"鼠标模拟点击"和"触摸模拟点击"这两套事件同时触发导致逻辑跑两遍的老问题。以前一个既绑了 mousedown 又绑了 touchstart 的元素,在支持触摸的笔记本触摸屏上会先后收到两套事件,很多团队的土办法是在 touchstart 里记一个时间戳,mousedown 里判断"如果 300 毫秒内刚触发过 touch 就跳过",这种时间戳兜底本质是在猜设备行为。Pointer Events 里同一次物理点击只会对应一套 pointerdown/pointerupevent.pointerType 直接告诉你这次输入的真实来源,不需要用时间差去猜。裁剪组件切过去之后,这条时间戳兜底逻辑整段删掉了,是这次改造里我最没想到的收益——本来只是想合并代码,顺带把一个隐藏了一年多的时序判断也一起消灭掉了。

setPointerCapture 还有一个配套的 releasePointerCapture,多数场景浏览器会在 pointeruppointercancel 时自动释放捕获,不需要手动调用;但如果拖拽中途需要主动放弃控制权(比如判断到拖拽方向不对,想把后续事件交还给默认处理),可以显式调用。另外 pointercancel 是 Pointer Events 独有的事件类型,触发场景包括:浏览器判断这是一次页面级别的滚动手势而接管了它、系统弹出了权限提示框打断当前交互、触控笔离开检测范围等。这类事件对应的是"这次交互被外部打断了,不会再有后续的 pointermove/pointerup",拖拽逻辑里必须监听它来清理状态,否则会出现"手指已经抬起但组件还以为在拖拽中"的卡死现象:

1box.addEventListener('pointercancel', function () {
2  isDragging = false;
3  box.classList.remove('dragging');
4});

我们裁剪组件最初就没接这个事件,测试里发现在拖拽过程中如果系统突然弹出一个输入法候选框之类的东西打断触摸,选区就会卡在"正在拖拽"的视觉状态出不来,后来补上 pointercancel 才彻底解决。

passive:滚动为什么会等 JS 跑完

长列表页面滚动发涩这件事,排查过程比想象中绕。用 Performance 面板录制一段滚动,火焰图里能看到每一帧滚动前都夹着一段脚本执行,即使这段脚本什么都没干、纯粹是个空函数:

1window.addEventListener('scroll', function () {
2  // 这里几乎是空的,也没有明显耗时逻辑
3  updateScrollIndicator();
4});

问题不在这个函数本身跑了多久,而在于浏览器合成滚动画面之前,必须先把这一帧里注册的 scroll/touchmove/wheel 监听器全部同步跑完,因为它理论上不知道你会不会在里面调用 event.preventDefault() 去取消这次滚动。只要有这个可能性存在,浏览器就必须"先等 JS 说了算,再决定滚不滚",这一等就是掉帧的来源,尤其在触摸设备上体感非常明显。

passive 选项就是用来打破这层等待的:

1window.addEventListener('scroll', updateScrollIndicator, { passive: true });
2container.addEventListener('touchmove', onTouchMove, { passive: true });

passive: true 相当于提前向浏览器承诺"这个监听器绝不会调用 preventDefault",浏览器拿到这个承诺后就能把合成线程和主线程的执行解耦,滚动动画不用再等 JS 跑完,可以立即开始,scroll/touchmove 回调则并行或稍后执行。承诺一旦作出就要遵守,如果在 passive: true 的监听器里仍然调用 preventDefault(),调用本身不会报错,但不会产生任何效果,控制台会打一条警告提示这次调用被忽略了。

有意思的是浏览器这两年已经悄悄改了默认值:从 Chrome 56(2017 年初)起,documentwindowdocument.body 上注册的 touchstarttouchmove 默认就是 passive: true,不用你显式写。这个改动当年是直接从 Chrome 团队的统计数据出发的——他们发现绝大多数页面的 touch 监听压根没调用过 preventDefault,与其让每个页面自己配置,不如把默认值改掉,历史包袱交给少数真正需要阻止默认行为的场景去显式声明 { passive: false }

这条默认值反过来也是一个坑。如果你的页面需要自定义手势(比如一个可以左右拖拽切换的卡片轮播),需要在 touchmove 里调用 preventDefault() 来阻止页面跟着一起滚动,而你的监听器绑在 documentwindow 上,就必须显式传 { passive: false },否则默认的 passive: true 会让你的 preventDefault() 静默失效,卡片是拖动了,但页面也跟着一起晃了两下:

1document.addEventListener(
2  'touchmove',
3  function (event) {
4    if (isDraggingCard) {
5      event.preventDefault();
6    }
7  },
8  { passive: false }
9);

要检测当前浏览器是否支持配置对象写法(老旧浏览器只认第三个参数的布尔值,会把配置对象当真值处理,等价于 capture: true),可以用一个一次性的探测:

1var supportsPassive = false;
2try {
3  var opts = Object.defineProperty({}, 'passive', {
4    get: function () {
5      supportsPassive = true;
6    },
7  });
8  window.addEventListener('test', null, opts);
9  window.removeEventListener('test', null, opts);
10} catch (e) {}

这段代码的技巧在于用 Object.definePropertypassive 属性挂了个 getter,只有浏览器真的去读取这个配置对象的 passive 字段时,supportsPassive 才会被置成 true——老浏览器压根不认识配置对象这个参数形态,自然也不会去读它的 passive 属性。DevTools 里也能直接看效果,Lighthouse 的性能审计有一项专门叫"未将事件监听器标记为 passive 以提升滚动性能",跑一次报告就知道具体是哪几行代码需要补上这个选项。

wheel 事件是另一个容易被忽略的 passive 场景。我们有个后台页面在表格区域自定义了鼠标滚轮缩放列宽的交互,一开始这样写:

1table.addEventListener('wheel', function (event) {
2  if (event.ctrlKey) {
3    event.preventDefault();
4    zoomColumns(event.deltaY);
5  }
6});

这里没有传第三个参数,wheel 事件在普通元素(不是 window/document/body)上的默认值其实是 passive: false,所以 preventDefault 能正常生效,页面不会跟着这个手势一起缩放。但代码评审时同事提了个问题:这个监听器里大多数滚动(没按 ctrl 的那些)根本不会走到 preventDefault,却每次都要经历"浏览器等这个函数跑完才能决定滚不滚"的开销。改法是把"是否要阻止默认行为"这件事提前到最外层判断,把不需要拦截的路径显式交还给被动模式处理:

1table.addEventListener(
2  'wheel',
3  function (event) {
4    if (event.ctrlKey) {
5      event.preventDefault();
6      zoomColumns(event.deltaY);
7    }
8    // 没按 ctrl 的滚动完全不碰这个 handler 的阻塞路径
9  },
10  { passive: false }
11);

这个例子说明一件容易被误解的事:passive 不是"这个监听器完全不能调用 preventDefault",而是"你保证不调用就能获得性能收益,一旦真的调用了就会被忽略"。如果一个场景确实需要偶尔调用 preventDefault(哪怕只是极少数分支),就只能老实用 passive: false,代价是浏览器仍然要在每次事件里等这个 handler 同步跑完。真正能从 passive: true 里获益的,只有那些确定百分之百不会调用 preventDefault 的监听器,为了蹭性能而在监听器里悄悄放弃某个分支的 preventDefault 逻辑,是本末倒置。

排查这类问题时还发现一个容易被忽视的细节:addEventListener 的第三个参数除了布尔值、{ passive },其实是一整个配置对象,captureoncepassive 三个字段可以随意组合。once: true 之前在我们代码里几乎没人用过,全靠手动在回调里调用一次 removeEventListener 收尾:

1function handleFirstScroll() {
2  console.log('用户开始滚动了');
3  window.removeEventListener('scroll', handleFirstScroll);
4}
5window.addEventListener('scroll', handleFirstScroll);

换成 once 之后这两行合并成一句,浏览器自己保证回调只执行一次、执行完就自动解绑,不需要在回调体内部留一句自我了断的代码:

1window.addEventListener(
2  'scroll',
3  function () {
4    console.log('用户开始滚动了');
5  },
6  { once: true }
7);

这类"只关心第一次发生"的场景其实很常见:图片懒加载库里监听一次性的 load/error 完成初始化、新手引导只需要在用户第一次点击某个按钮时弹提示、埋点里记录"首次交互时间"。oncepassive 可以叠加使用,两者互不冲突:

1document.addEventListener(
2  'touchstart',
3  function () {
4    reportFirstInteraction(Date.now());
5  },
6  { once: true, passive: true }
7);

这行代码比之前那种"手动 remove + 手动配置被动模式"的组合写法要短很多,评审时看一眼配置对象就知道这个监听器的全部生命周期约束,不用再去回调体内部找有没有藏着一句 removeEventListener

CustomEvent:跳出框架看事件通信

年底接的一个需求是把一个第三方地图组件(原生 JS 写的,不认识 Vue)套进现有的 Vue 2.6 页面里。地图内部会在用户拖拽结束、缩放结束时触发一些内部回调,我们想让外层的 Vue 组件感知到这些变化,但地图库本身没有跟 Vue 有关的任何概念,它只认识原生 DOM 事件。

这类场景 CustomEvent 是最合适的工具,本质是把业务数据包装成一个真正的 DOM 事件,让它按标准的事件流被派发和监听:

1var mapContainer = document.getElementById('map');
2
3function notifyBoundsChanged(bounds) {
4  var event = new CustomEvent('map:bounds-change', {
5    detail: { bounds: bounds },
6    bubbles: true,
7  });
8  mapContainer.dispatchEvent(event);
9}

自定义数据放在 event.detail 里,这是 CustomEvent 和普通 Event 唯一的结构差异。外层随便什么代码监听都能拿到:

1mapContainer.addEventListener('map:bounds-change', function (event) {
2  console.log(event.detail.bounds);
3  syncFilterPanel(event.detail.bounds);
4});

在 Vue 组件里接这个事件也很自然,因为它就是个原生事件,直接在模板里挂原生监听器就行:

1<div id="map" @map:bounds-change.native="handleBoundsChange"></div>
1methods: {
2  handleBoundsChange(event) {
3    this.currentBounds = event.detail.bounds;
4  }
5}

bubbles: true 这个配置容易被忽略,但很关键。CustomEvent 默认和大多数原生事件一样不会冒泡,如果地图容器嵌套在好几层业务组件里,外层想在祖先节点上委托监听,不显式打开 bubbles 就收不到。同理如果需要在别的地方 preventDefault 这个自定义事件(比如"允许业务代码取消这次地图跳转"),还要加上 cancelable: true

1var event = new CustomEvent('map:before-navigate', {
2  detail: { target: nextBounds },
3  bubbles: true,
4  cancelable: true,
5});
6var notCancelled = mapContainer.dispatchEvent(event);
7if (notCancelled) {
8  doNavigate(nextBounds);
9}

dispatchEvent 的返回值这里派上用场:如果监听者中有人调用了 event.preventDefault(),且这个事件本身 cancelable 为真,返回值就是 false,业务代码据此决定要不要真的执行后续动作。这套"派发方给一个自定义事件、监听方可以选择性取消"的模式,本质就是把 Vue 里 $emit.sync 或者双向绑定能做的事,下沉到了浏览器原生事件系统这一层,好处是它不依赖任何框架,原生 JS 库、Web Components、甚至跨 iframe 通过 postMessage 转发消息体后在本地重新 dispatchEvent,都能复用同一套通信语义。

这一年 Web Components 在我们团队还只是零星关注、没有大规模生产落地,但组件库那边已经开始有人拿 CustomEvent 给内部一个用原生 Custom Elements 写的小组件做通信实验,这也是促使我认真查这块的直接原因——发现它比想象中要成熟和好用得多,不是只有 Vue/React 生态才有"组件通信"这回事。

这套机制还有一个容易被忽略的边界:事件名不建议直接用普通字符串,尤其是给第三方库或者跨团队消费的场景。地图组件的事件名我们统一加了 map: 前缀,一是避免和浏览器原生事件、其他库自定义的事件撞名,二是让监听代码一眼能看出这是哪个业务模块发出来的信号。如果两个不同的第三方库都往同一个 DOM 节点上派发名叫 changeCustomEvent,监听方是分不清这两个 change 到底谁发的,这个坑没有语法层面的报错,只会在业务逻辑上表现成"这个监听器有时候莫名其妙被触发了",排查起来极费劲。

AbortController:一次性批量移除监听器

给地图组件套壳这件事还带出另一个问题:这个组件对外暴露了七八个 CustomEvent,外层 Vue 组件在 mounted 里挨个 addEventListenerbeforeDestroy 里就得挨个写对应的 removeEventListener,一多一漏很容易对不上号。今年查 AbortController 相关资料时发现它不只能配合 fetch 用来取消请求,addEventListener 的配置对象里同样能接收一个 signal

1var controller = new AbortController();
2
3mapContainer.addEventListener('map:bounds-change', handleBoundsChange, { signal: controller.signal });
4mapContainer.addEventListener('map:zoom-change', handleZoomChange, { signal: controller.signal });
5mapContainer.addEventListener('map:layer-toggle', handleLayerToggle, { signal: controller.signal });

七八个监听器共用同一个 controller,销毁的时候只需要调用一次 controller.abort(),这个 signal 对应的所有监听器会被浏览器统一移除,不需要逐个记住函数引用再逐个 removeEventListener

1export default {
2  mounted() {
3    this.eventController = new AbortController();
4    var signal = this.eventController.signal;
5    mapContainer.addEventListener('map:bounds-change', this.handleBoundsChange, { signal: signal });
6    mapContainer.addEventListener('map:zoom-change', this.handleZoomChange, { signal: signal });
7    mapContainer.addEventListener('map:layer-toggle', this.handleLayerToggle, { signal: signal });
8  },
9  beforeDestroy() {
10    this.eventController.abort();
11  },
12};

这和前面内存泄漏那节自己手写的 createListenerGroup 工具函数是同一类思路,区别是 AbortController 是浏览器原生提供的能力,不需要团队自己维护一份小工具。abort() 调用之后 signal.aborted 会变成 true,如果这个 controller 之后还想复用,需要重新 new 一个,AbortController 是一次性的,不能 abort 之后再重新启用。

这个能力 2021 年这个时间点还比较新:Chrome 年初的 88、Firefox 紧接着的 86 都补上了 addEventListenersignal 选项的支持,Safari 要到今年 9 月的 15 才跟上。三家虽然都齐了,但 Safari 15 毕竟是这两个月才发布的,用户手上的旧版本 Safari 还占着不小的比例,所以我们目前只敢在管理后台这类不需要顾及旧版 Safari 的内部项目里用,面向外部用户的页面暂时还是老老实实用 createListenerGroup 这类手写方案兜底,两条路线在项目里并存了一段时间,等旧版 Safari 的存量用户掉下去之后再考虑统一替换。

signal 还有一个用法是配合一个已有的 AbortController 去中途取消一次尚未完成的异步操作和事件监听。比如一个"等待用户点击确认或者 5 秒后自动关闭"的提示框,可以把定时器和监听器绑在同一个 signal 上,谁先触发就把另一边一起清理掉:

1function showConfirmToast(onConfirm) {
2  var controller = new AbortController();
3  var timer = setTimeout(function () {
4    controller.abort();
5    closeToast();
6  }, 5000);
7
8  document.addEventListener(
9    'click',
10    function (event) {
11      if (event.target.closest('.toast-confirm-btn')) {
12        clearTimeout(timer);
13        controller.abort();
14        onConfirm();
15      }
16    },
17    { signal: controller.signal }
18  );
19}

不管是超时自动关闭还是用户手动点击确认,监听器都会在对应分支里被同一次 abort() 清理掉,不用再单独写一句 removeEventListener 收尾,思路和 once 有点像,但 once 只能限定"这一个监听器执行一次就自动解绑",signal 能同时控制多个毫不相关的监听器和其他可取消操作,粒度更粗、覆盖面更广。

内存泄漏排查:从"忘记解绑"到真正定位

事件监听器导致内存泄漏这件事,道理大家都懂——忘记 removeEventListener,闭包引用的对象就走不掉。真正麻烦的是当项目大到几十个页面、几百个组件之后,光靠人工检查生命周期钩子已经不现实,得靠工具定位到底是哪个监听在攒。

Chrome DevTools 的 Memory 面板里,拍两张堆快照(Heap Snapshot)做对比是最直接的手段。具体做法是:先进入目标页面稳定状态拍一张快照,然后反复执行"打开弹窗再关闭"这类操作十几次,再拍第二张,用面板自带的对比模式筛出"只增不减"的对象。如果某个 Vue 组件实例的数量随着开关次数线性增长却一直不被回收,说明它被什么外部引用勾住了脱不了钩。

Chrome DevTools → Memory → Heap snapshot → 拍快照1
反复开关弹窗 15 次
再拍快照2 → 选择 "Comparison" 视图 → 按 "# Delta" 排序

找到可疑对象后,右键选 "Reveal in Summary view",再展开它的 "Retainers"(谁在引用它)面板,顺着引用链常常能一路追到一个绑在 window 上的监听器闭包。这条链路比自己猜"是不是哪里忘了解绑"精确得多,因为它是真实的引用关系,不是猜测。这个面板还有个实用的辅助功能——勾选 "Allocation instrumentation on timeline",可以录制一段操作过程中的对象分配时间线,柱状图上颜色偏深的那些分配后来一直没被回收,直接点开就能看到分配它的调用栈,比对比两张静态快照更直观地看到"泄漏是在哪一步操作里发生的"。

除了肉眼比对,WeakMap 在这类场景里也能派上用场,不是用来排查,而是用来从设计上让泄漏不可能发生。如果需要给某个 DOM 节点关联一份额外数据(比如记录这个节点最近一次交互的时间戳),比起直接往节点上挂自定义属性或者用一个普通 Map 存 "节点 → 数据" 的映射,WeakMap 的键是弱引用,节点一旦从 DOM 里移除、不再被其他地方引用,垃圾回收器可以正常回收它,WeakMap 里对应的条目会自动消失,不需要手动清理:

1var nodeMeta = new WeakMap();
2
3function markInteraction(node) {
4  nodeMeta.set(node, { lastActiveAt: Date.now() });
5}
6
7function getInteraction(node) {
8  return nodeMeta.get(node);
9}

普通 Map 存这种关系,节点从页面移除之后,只要 Map 里还留着这个键,节点本身就一直被引用着,永远进不了垃圾回收,这是另一种不容易被察觉的泄漏来源——它甚至不涉及事件监听,纯粹是数据结构选错了。今年处理过一次类似的问题:一个表格组件用普通对象把行 DOM 节点缓存起来做"高亮定位",表格翻页、行被销毁重建了很多轮之后,内存里堆积的全是已经从页面上消失、却还被这份缓存拽着不放的旧节点,换成 WeakMap 之后这部分内存直接不再增长。

代码层面,今年我们组内约定了一个规则:所有绑定在 windowdocument 这类页面级长生命周期对象上的监听,一律通过一个统一的小工具函数登记,而不是散落在各个组件里各自 addEventListener

1function createListenerGroup() {
2  var records = [];
3  return {
4    add(target, type, handler, options) {
5      target.addEventListener(type, handler, options);
6      records.push({ target: target, type: type, handler: handler, options: options });
7    },
8    clear() {
9      records.forEach(function (item) {
10        item.target.removeEventListener(item.type, item.handler, item.options);
11      });
12      records.length = 0;
13    },
14  };
15}

组件里这样用:

1export default {
2  created() {
3    this.listeners = createListenerGroup();
4  },
5  mounted() {
6    this.listeners.add(window, 'resize', this.onResize);
7    this.listeners.add(document, 'visibilitychange', this.onVisibilityChange);
8  },
9  beforeDestroy() {
10    this.listeners.clear();
11  },
12};

好处不在于比逐个 removeEventListener 少写几行代码,而在于评审时一眼就能看出这个组件到底往全局对象上挂了几个监听,clear() 调用点集中在一处,不会有人漏写。之前分散写的时候,评审很难注意到某个组件其实绑了三个全局监听、beforeDestroy 里只解绑了两个。

还有一类更隐蔽的泄漏和闭包引用的颗粒度有关。同一个 handler 闭包里如果同时引用了一个大对象(比如整页的原始数据列表)和一个小状态(比如一个布尔值),即使这个 handler 本身及时解绑了,只要它在解绑之前的生命周期里被其他地方保留了引用(比如传给了某个全局事件总线做了记录),那个大对象也会跟着一起被拖住无法回收。这一年处理过一次类似问题,解法是让 handler 只捕获真正需要的最小数据,而不是图省事直接把整个 this 或者整页状态对象整体传进闭包。

1// 不推荐:闭包捕获了整个组件实例,组件所有状态都被间接拖住
2this.onResize = () => {
3  this.layoutInfo.width = window.innerWidth;
4};
5
6// 更克制的写法:只捕获需要更新的那一小块引用
7var layoutRef = this.layoutInfo;
8this.onResize = function () {
9  layoutRef.width = window.innerWidth;
10};

差别看起来很小,但在一个状态对象本身很庞大(比如挂了几千条列表数据)的组件上,这种"整体捕获 this"的写法会让原本该早被回收的大对象因为一个不起眼的 resize 监听而多存活很久。

这一年 ES2021 里其实新增了两个和这个话题直接相关的原生能力:WeakRefFinalizationRegistryWeakRef 允许你持有一个对象的弱引用,随时可以调用 .deref() 试图取回这个对象,如果对象已经被回收就拿到 undefinedFinalizationRegistry 则可以在对象被真正回收时收到一个回调通知,用来做一些"对象没了就顺便清理点什么"的收尾工作。这两个 API 在 Chrome 84(2020 年)就已经支持,但规范里明确写了"不应该依赖它们来实现正常的业务逻辑",回收时机完全由引擎决定,不保证及时性,甚至标签页关闭前可能永远不会触发。我们内部讨论过要不要拿 FinalizationRegistry 去做监听器泄漏的自动告警(比如组件销毁后一段时间还没被回收就上报一次),最后放弃了,因为这类调试性质的用途本来就该用 DevTools 的堆快照去主动排查,而不是指望一个时机不确定的自动回调,这两个 API 目前更适合写一些内部缓存、边车(sidecar)数据结构这类本来就不追求确定性的场景。

组合起来看

这三件事表面上不相关,放在一起看有一条共同的线索:addEventListener 的能力这几年一直在往"更精确地控制事件的产生方式、传播方式、生命周期"这个方向扩展,而不只是多加几个事件类型。Pointer Events 解决的是"同一份交互逻辑要不要为不同输入设备写多份代码";passive 解决的是"浏览器要不要为了一个几乎不会发生的 preventDefault 可能性去牺牲滚动流畅度";CustomEvent 解决的是"事件通信要不要被绑死在某个框架的组件树里";而监听器的生命周期管理解决的是"事件系统会不会反过来变成内存泄漏的源头"。写业务代码时遇到"这个交互写起来别扭""这个页面滚动发涩""这个第三方组件没法跟外层通信"这类感觉,对照这几个能力查一遍,常常比重新设计一套交互方案更快。