手把手带你实现浏览器原生自定义事件的完整封装方案
项目初期的技术选型
上个月在做一个数据看板项目,需求是「主图表区域支持拖拽缩放、双击重置、鼠标滚轮缩放」,同时要和侧边栏的筛选器联动——比如用户在图表里框选了一块区域,侧边栏得立刻高亮对应的数据项。一开始我本能想用 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触发后,构造CustomEvent,detail里塞结构化数据(坐标、缩放值、时间戳),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 适配,侧边栏还得自己算一次真实坐标——本来想用 CustomEvent 的 composedPath() 拿原始 target,但发现 Canvas 上的事件路径不可靠,最后还是靠约定字段 + 注释来维护。
还有个没彻底解决的小问题:当用户快速连续双击重置 + 滚轮缩放时,chart:reset 和 chart:zoom 事件偶尔会错序到达(因为 JS 单线程,但事件 dispatch 是同步的,理论上不该乱……但真测出来过)。我们加了时间戳排序临时规避,没深挖,毕竟影响极小,优先级调低了。
总的来说,custom event 在这个项目里不是炫技,而是恰到好处地填补了「非父子、跨技术栈、轻量通信」的空白。比起引入 mitt 或 tiny-emitter 这类库,它零依赖、浏览器原生支持、调试方便(Chrome DevTools → Event Listener Breakpoints → 打勾就行),适合中小型项目快速落地。
以上是我踩坑后的总结,希望对你有帮助。如果你遇到类似场景,或者有更好的事件命名规范、错误兜底策略,欢迎评论区交流。

暂无评论