JavaScript 如何实现自定义事件?
基本用法:CustomEvent 三步走
自定义事件的核心流程就三步:创建 → 监听 → 触发。
javascript// 1. 创建事件 const event = new CustomEvent('user-login', { detail: { userId: 42, role: 'admin' }, // 携带数据 bubbles: true, // 是否冒泡 cancelable: true // 能否被 preventDefault() 取消 }); // 2. 监听 element.addEventListener('user-login', (e) => { console.log(e.detail); // { userId: 42, role: 'admin' } }); // 3. 触发 element.dispatchEvent(event);
detail 是 CustomEvent 独有的字段,用于携带任意数据,和 Event 构造函数的唯一区别就在这里。Event 只能表示"某件事发生了",CustomEvent 还能告诉你"发生了什么"。
事件的冒泡与委托
bubbles: true 让事件沿 DOM 树向上冒泡,这意味着你可以在父元素上统一监听子元素发出的自定义事件:
javascript// 子元素派发 child.dispatchEvent(new CustomEvent('item-delete', { detail: { id: 1 }, bubbles: true })); // 父元素委托监听 parent.addEventListener('item-delete', (e) => { console.log('删除了:', e.detail.id); e.stopPropagation(); // 阻止继续冒泡 });
这是事件委托在自定义事件上的直接应用——不需要给每个子元素绑定监听器,一个父元素就够了。
手写 EventEmitter:脱离 DOM 的事件系统
自定义事件依赖 DOM,但面试常考的是纯 JS 的发布订阅实现。核心就是一个事件名到回调数组的映射:
javascriptclass EventEmitter { constructor() { this.events = new Map(); } on(event, handler) { if (!this.events.has(event)) { this.events.set(event, []); } this.events.get(event).push(handler); return this; // 支持链式调用 } off(event, handler) { const handlers = this.events.get(event); if (handlers) { this.events.set(event, handlers.filter(h => h !== handler)); } return this; } emit(event, ...args) { const handlers = this.events.get(event); if (handlers) { handlers.forEach(h => h(...args)); } return this; } once(event, handler) { const wrapper = (...args) => { handler(...args); this.off(event, wrapper); }; this.on(event, wrapper); return this; } }
once 的实现要点:用包装函数代替原函数注册,触发后自动解绑。面试写到这里基本够用。
自定义事件 vs 发布订阅:何时选谁
| 维度 | CustomEvent | EventEmitter |
|---|---|---|
| 依赖 | DOM 元素 | 纯 JS,无依赖 |
| 事件传播 | 冒泡/捕获,支持委托 | 无传播机制 |
| 数据传递 | detail 字段 | 直接参数 |
| 内存管理 | 移除元素自动清理 | 需手动 off |
| Shadow DOM | composed: true 可穿透 | 不涉及 |
简单判断:需要 DOM 层级传播用 CustomEvent,纯数据流用 EventEmitter。
无框架组件通信
没有 Vue/React 时,自定义事件就是组件通信的基础设施:
javascript// 子组件:触发事件 class MyInput extends HTMLElement { connectedCallback() { this.addEventListener('input', () => { this.dispatchEvent(new CustomEvent('value-change', { detail: { value: this.value }, bubbles: true })); }); } } // 父组件:监听子组件事件 parent.querySelector('my-input') .addEventListener('value-change', (e) => { console.log(e.detail.value); });
这就是 Web Components 通信的基本模式,也是浏览器原生组件化方案的核心机制。
Shadow DOM 边界与 composed
Shadow DOM 默认隔离事件。composed: true 让 CustomEvent 穿透 Shadow DOM 边界,否则事件被封闭在 Shadow DOM 内部:
javascript// Shadow DOM 内部派发 shadow.dispatchEvent(new CustomEvent('inner-action', { detail: { data: 1 }, bubbles: true, composed: true // 关键:穿透 Shadow DOM }));
composedPath() 方法可以查看事件经过的完整路径,调试 Shadow DOM 事件传播时很有用。
内存泄漏防护
自定义事件常见的内存陷阱:移除元素前没解绑监听器,或者闭包引用了大对象。
javascript// 错误示范:匿名函数无法解绑 el.addEventListener('my-event', (e) => { /* ... */ }); // 无法 off,因为匿名函数没有引用 // 正确做法:保存引用 const handler = (e) => { /* ... */ }; el.addEventListener('my-event', handler); // 用完解绑 el.removeEventListener('my-event', handler);
更安全的方案是用 AbortController:
javascriptconst controller = new AbortController(); el.addEventListener('my-event', handler, { signal: controller.signal }); // 批量清理 controller.abort();
一个 abort() 就能清理同一 controller 下的所有监听器,比逐个 removeEventListener 方便得多。
追问
自定义事件和发布订阅有什么区别?
自定义事件绑定在 DOM 元素上,有冒泡/捕获机制、事件委托、stopPropagation 等 DOM 事件系统的完整能力。发布订阅(EventEmitter)是纯 JS 模式,没有 DOM 树概念。需要跨层级通信、事件委托时用自定义事件;需要纯数据流、完全与 DOM 无关时用发布订阅。
不用框架(Vue/React)怎么用自定义事件做组件通信?
父组件给子组件传一个 DOM 元素引用,子组件在这个元素上 dispatchEvent(new CustomEvent('change', { detail: value })),父组件监听这个元素的 change 事件。这是 Web Components 的通信基础。也可以用自定义元素的属性变化回调 observedAttributes + attributeChangedCallback 配合事件派发做双向通信。
自定义事件可以跨 Shadow DOM 吗?
可以。composed: true 的 CustomEvent 会穿透 Shadow DOM 边界,不设置则封闭在 Shadow DOM 内部。用 event.composedPath() 可以查看事件传播的完整路径,这对调试 Shadow DOM 内的事件流向很有帮助。