手把手带你实现浏览器原生自定义事件的完整封装方案

Air-冰杰 前端 阅读 2,965
赞 12 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

上个月在做一个数据看板项目,需求是「主图表区域支持拖拽缩放、双击重置、鼠标滚轮缩放」,同时要和侧边栏的筛选器联动——比如用户在图表里框选了一块区域,侧边栏得立刻高亮对应的数据项。一开始我本能想用 React 的 Context + useState 传状态,但很快发现:组件树太深了,中间还夹着几个第三方渲染库(比如基于 Canvas 的 chart 库),根本没法把状态一层层往下透,更别说反向通知了。

手把手带你实现浏览器原生自定义事件的完整封装方案

后来翻了下 MDN,突然想起 custom event 其实早就能在任意 DOM 节点上 dispatch,而且不依赖框架。试了下,document.dispatchEvent(new CustomEvent('chart:selection', { detail: { x1, y1, x2, y2 } })),侧边栏直接 document.addEventListener('chart:selection', handler) 就能收到——没 props,没 context,没订阅发布模式封装,就两行代码,干净利落。当时心想:行,就它了,先跑起来再说。

最大的坑:事件被吞掉,监听器收不到

实际一写才发现,问题比想象中多。最典型的是:图表区域用的是 canvas,我给 canvas 绑了 mousedown → mousemove → mouseup 的拖拽逻辑,然后在 mouseup 里 dispatch 自定义事件。结果侧边栏死活收不到。

折腾了半天,最后加了一堆 console.log,发现 dispatchEvent 返回 true(说明事件确实发出去了),但 addEventListener 根本没触发。查文档才意识到:自定义事件默认 bubbles: false,而我的 canvas 是嵌套在好几层 div 里的,document.addEventListener 想捕获,得靠冒泡。于是补了 bubbles: true

canvas.addEventListener('mouseup', () => {
  const event = new CustomEvent('chart:selection', {
    bubbles: true,
    detail: { x1, y1, x2, y2 }
  })
  canvas.dispatchEvent(event)
})

但这又带来新问题:事件会一路冒泡到 document,但中间某个父容器写了 e.stopPropagation()(比如一个弹窗遮罩层),直接把事件截胡了。最后改成手动派发到 document 上,绕过 DOM 层级干扰:

canvas.addEventListener('mouseup', () => {
  const event = new CustomEvent('chart:selection', {
    bubbles: false, // 不冒泡
    cancelable: false,
    detail: { x1, y1, x2, y2 }
  })
  document.dispatchEvent(event) // 强制发到顶层
})

这里注意我踩过好几次坑:不能写 window.dispatchEvent,因为某些环境下(比如 iframe 或 Shadow DOM)window 和事件监听目标可能不一致;document 是最稳妥的全局通信载体,只要监听也挂 document 上,就一定收得到。

跨组件通信时,怎么避免内存泄漏?

侧边栏组件是动态加载的(React.lazy + Suspense),每次切换 tab 都会卸载重装。我一开始在 useEffect 里直接 document.addEventListener('chart:selection', handler),但忘了 removeEventListener ——结果切三次 tab,同一个事件会触发四次 handler,数据全乱了。

解决办法很土但有效:在组件 unmount 前移除监听。React 里就是 useEffect 的清理函数:

useEffect(() => {
  const handler = (e) => {
    console.log('收到图表选择:', e.detail)
    updateSidebarHighlight(e.detail)
  }
  document.addEventListener('chart:selection', handler)
  return () => {
    document.removeEventListener('chart:selection', handler)
  }
}, [])

另外,为了防止多人协作时漏写清理逻辑,我抽了个小 hook:

function useCustomEvent(eventName, handler) {
  useEffect(() => {
    document.addEventListener(eventName, handler)
    return () => {
      document.removeEventListener(eventName, handler)
    }
  }, [eventName, handler])
}

// 使用:
useCustomEvent('chart:selection', (e) => {
  updateSidebarHighlight(e.detail)
})

这个 hook 看似简单,但在两个模块同时监听 chart:reset 的时候帮了大忙——谁注册谁负责清理,边界清晰,不会互相污染。

最终的解决方案

现在整个通信链路是这样的:

  • 图表区域(Canvas):mouseup / wheel / dblclick 触发后,构造 CustomEventdetail 里塞结构化数据(坐标、缩放值、时间戳),document.dispatchEvent
  • 侧边栏、顶部状态栏、导出按钮等所有需要响应的模块:用 useCustomEvent 监听,只关心自己要的字段
  • 全局事件命名统一加前缀:chart:*filter:*export:*,避免冲突

完整事件分发代码(含防抖):

let selectionTimeout
canvas.addEventListener('mouseup', () => {
  if (selectionTimeout) clearTimeout(selectionTimeout)
  selectionTimeout = setTimeout(() => {
    const rect = canvas.getBoundingClientRect()
    const event = new CustomEvent('chart:selection', {
      bubbles: false,
      detail: {
        x1: Math.min(startX, currentX) - rect.left,
        y1: Math.min(startY, currentY) - rect.top,
        x2: Math.max(startX, currentX) - rect.left,
        y2: Math.max(startY, currentY) - rect.top,
        timestamp: Date.now()
      }
    })
    document.dispatchEvent(event)
  }, 50)
})

回顾与反思

这套方案上线后跑了两周,整体稳定。最大的好处是解耦彻底:图表组件完全不知道侧边栏长啥样,也不用暴露任何 ref 或方法;新增一个「数据详情浮层」模块,只要监听 chart:selection 就能立刻接入,不用改一行旧代码。

不过也有遗憾的地方。比如 detail 里传的坐标是相对 canvas 的像素值,如果图表做了缩放或 DPR 适配,侧边栏还得自己算一次真实坐标——本来想用 CustomEventcomposedPath() 拿原始 target,但发现 Canvas 上的事件路径不可靠,最后还是靠约定字段 + 注释来维护。

还有个没彻底解决的小问题:当用户快速连续双击重置 + 滚轮缩放时,chart:resetchart:zoom 事件偶尔会错序到达(因为 JS 单线程,但事件 dispatch 是同步的,理论上不该乱……但真测出来过)。我们加了时间戳排序临时规避,没深挖,毕竟影响极小,优先级调低了。

总的来说,custom event 在这个项目里不是炫技,而是恰到好处地填补了「非父子、跨技术栈、轻量通信」的空白。比起引入 mitt 或 tiny-emitter 这类库,它零依赖、浏览器原生支持、调试方便(Chrome DevTools → Event Listener Breakpoints → 打勾就行),适合中小型项目快速落地。

以上是我踩坑后的总结,希望对你有帮助。如果你遇到类似场景,或者有更好的事件命名规范、错误兜底策略,欢迎评论区交流。

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

暂无评论