彻底搞懂事件解绑的坑及最佳实践
又踩坑了,touchmove滚动失效
今天在搞一个 H5 活动页的时候,遇到个离谱的问题:页面里有个弹窗,弹窗出现后我给 document 绑了 touchmove 事件,想阻止底层页面滚动。结果弹窗关掉之后,底下的页面也滚不动了——解绑没生效。
一开始我还以为是样式问题,z-index 啥的,检查了半天发现不是。后来用 Chrome DevTools 的 Event Listeners 面板一看,好家伙,touchmove 事件还在!说明 removeEventListener 根本没起作用。
这里我踩了个坑:我之前写的是这样:
document.addEventListener('touchmove', handleTouchMove);
// 弹窗关闭时
document.removeEventListener('touchmove', handleTouchMove);
看起来没问题对吧?但实际运行就是解绑不了。折腾了半天,怀疑人生。后来想起来,MDN 上提过一句:只有当传入的函数引用完全一致时,removeEventListener 才能匹配成功。
那问题来了——我这个 handleTouchMove 是不是同一个引用?查了一下,是啊,同一个函数变量。那为什么不行?
等等……我突然意识到:我在别的地方用了 debounce 或者匿名函数封装?翻代码一看,没有。但再仔细一瞅,发现问题出在「调用时机」和「上下文绑定」上。
原来 bind 改变了函数引用
我的 handleTouchMove 其实是通过 .bind(this) 调用的,像这样:
this.handleTouchMove = this.handleTouchMove.bind(this);
document.addEventListener('touchmove', this.handleTouchMove);
这时候你以为传进去的是同一个函数?错。bind 返回的是一个全新的函数实例。所以当你 later 调用 removeEventListener 的时候,虽然名字一样,但内存地址不一样,自然解绑失败。
这坑我踩过好几次了,每次都忘。这次终于记住了。
三种方案对比,我选了最简单的
当时试了三种方法:
- 第一种:把 bind 提前保存成实例属性
- 第二种:用 AbortController(现代浏览器支持)
- 第三种:用事件命名空间 + 自定义管理器
第一种是最常见的做法,也是我最后采用的。很简单,就是在 class 初始化的时候就把带 this 的版本存下来:
class Modal {
constructor() {
// 提前绑定,确保引用一致
this.boundHandleTouchMove = this.handleTouchMove.bind(this);
}
open() {
document.addEventListener('touchmove', this.boundHandleTouchMove, { passive: false });
}
close() {
document.removeEventListener('touchmove', this.boundHandleTouchMove);
// 清理引用避免内存泄漏
console.log('touchmove event removed');
}
handleTouchMove(e) {
e.preventDefault(); // 阻止默认滚动
}
}
这样就能正常解绑了。关键点在于:add 和 remove 传的必须是同一个函数引用,不能是 bind 临时生成的新函数。
第二种方案是用 AbortController,这个比较新,Chrome 66+ 支持。好处是你不用手动管理函数引用,直接 signal.abort() 就行:
class Modal {
constructor() {
this.controller = new AbortController();
}
open() {
document.addEventListener('touchmove', (e) => e.preventDefault(), {
passive: false,
signal: this.controller.signal
});
}
close() {
this.controller.abort(); // 自动移除所有该 signal 下的事件
this.controller = new AbortController(); // 为下次打开重置
}
}
这个写法更干净,但兼容性要考虑。如果你项目要支持 iOS 12 以下或者老安卓机,就得加 polyfill 或者放弃。我当时看了下业务需求,最低支持 iOS 13,所以其实可以用。但我还是没选它,因为团队其他人对 AbortController 不熟,后期维护容易出问题。技术选型不只是看功能,还得看协作成本。
第三种是自己搞个事件管理中心,比如:
const EventManager = {
listeners: new WeakMap(),
add(el, type, handler, options) {
const key = el;
if (!this.listeners.has(key)) {
this.listeners.set(key, []);
}
const boundHandler = handler.bind(this);
el.addEventListener(type, boundHandler, options);
this.listeners.get(key).push({ type, handler: boundHandler });
},
remove(el) {
const list = this.listeners.get(el) || [];
list.forEach(({ type, handler }) => {
el.removeEventListener(type, handler);
});
this.listeners.delete(el);
}
};
这种适合大型项目,但我们这是个轻量活动页,没必要搞这么复杂。过度工程化只会拖慢进度。
核心代码就这几行
最后我上线的代码其实非常简单:
class ScrollBlocker {
constructor() {
this.handleTouchMove = this.handleTouchMove.bind(this);
}
enable() {
document.addEventListener('touchmove', this.handleTouchMove, { passive: false });
}
disable() {
document.removeEventListener('touchmove', this.handleTouchMove);
}
handleTouchMove(e) {
e.preventDefault();
}
}
// 使用示例
const blocker = new ScrollBlocker();
// 弹窗打开
blocker.enable();
// 弹窗关闭
blocker.disable();
就这么几行,搞定。关键是 constructor 里提前 bind,保证引用一致。passive: false 也很重要,不然 preventDefault 会被忽略(Chrome 的优化机制)。
这里注意我踩过好几次坑:有时候你在多个地方都调用了 enable,就会重复绑定。虽然不影响功能,但属于资源浪费。所以我后来加了个 flag 判断:
enable() {
if (this.enabled) return;
document.addEventListener('touchmove', this.handleTouchMove, { passive: false });
this.enabled = true;
}
disable() {
if (!this.enabled) return;
document.removeEventListener('touchmove', this.handleTouchMove);
this.enabled = false;
}
虽然多了一行判断,但安全很多。特别是在 React 或 Vue 的组件频繁挂载卸载场景下,很容易重复触发。
还有一点小问题,但无大碍
改完之后测试发现,在某些安卓机上快速连续开关弹窗,偶尔会出现一次滚动穿透。概率很低,基本可以忽略。排查了一下,应该是事件注册/注销的异步间隙导致的。我没深究,因为加了防重触发(debounce 300ms),用户正常操作不会这么快。
如果追求完美,可以用 requestAnimationFrame 包一层,确保 DOM 状态稳定后再操作事件绑定。但我觉得没必要,毕竟用户体验影响极小,开发成本反而上升了。
另外提醒一下:不要在 handleTouchMove 里做复杂逻辑,否则可能卡主线程。我就见过有人在里面发请求、改状态、算坐标,结果滑动直接卡成幻灯片。这个函数应该越轻越好,只干一件事:preventDefault。
以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流
这个问题看似简单,但真到线上才发现。建议大家以后写事件绑定的时候,养成习惯:凡是需要解绑的,一定要确认函数引用一致性。特别是配合 class、bind、箭头函数混用时,最容易出事。
还有个小技巧:开发阶段可以在 remove 后立刻打印 getEventListeners(document)(仅 Chrome),看看是不是真的没了。当然生产环境不能用,但调试期很有用。
这个技巧的拓展用法还有很多,比如同时阻止 wheel、keydown 等事件,思路都是一样的。关键是要有“事件生命周期”的意识:绑定 → 使用 → 解绑 → 清理引用。
以上是我个人对这个事件解绑问题的完整记录,亲测有效。如果有更优的实现方式,比如更好的兼容性处理或性能优化,欢迎留言讨论。

暂无评论