彻底搞懂事件解绑的坑及最佳实践

Dev · 翌萌 优化 阅读 2,467
赞 15 收藏
二维码
手机扫码查看
反馈

又踩坑了,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 等事件,思路都是一样的。关键是要有“事件生命周期”的意识:绑定 → 使用 → 解绑 → 清理引用。

以上是我个人对这个事件解绑问题的完整记录,亲测有效。如果有更优的实现方式,比如更好的兼容性处理或性能优化,欢迎留言讨论。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论