概述

在日常开发中,我们经常遇到这样的场景:用户点击了按钮,需要更新多个地方的显示;订单完成后,需要同时刷新列表、发送通知、更新统计;购物车添加商品时,需要更新数量、计算总价、保存到本地。如果每个地方都直接调用,代码会变得又乱又难维护。

这时候,自定义事件就派上用场了。它就像广播电台,某个地方"广播"了一个消息,所有"收听"这个消息的地方都能收到通知并做出响应。浏览器内置的 click、input 等事件主要处理用户交互,而自定义事件则用来传递业务语义,让代码更加解耦和灵活。

为什么需要自定义事件

想象一下,如果没有自定义事件,我们要实现"用户登录后,更新用户信息、加载数据、记录日志、发送通知"这个功能,可能会写出这样的代码:登录函数里调用更新用户信息的函数,更新用户信息的函数里调用加载数据的函数,加载数据的函数里调用记录日志的函数... 这样一层套一层,代码耦合度非常高,改一个地方可能影响其他地方。

自定义事件的出现,完美解决了这个问题。它实现了发布 - 订阅模式,发布者(触发事件的地方)和订阅者(监听事件的地方)彼此不知道对方的存在,只需要知道事件名称即可。这样一来,组件之间可以实现通信而不需要直接依赖,模块之间可以解耦,便于后续替换和扩展。更重要的是,用业务事件来表达"发生了什么",比如 "user:login"、"order:completed",让代码的语义更加清晰,日志和监控也更容易理解。在测试时,我们也可以直接构造事件来测试监听器的行为,不需要依赖真实的业务逻辑。

自定义事件 vs 直接调用 vs 全局状态

在实际项目中,我们经常会面临选择:是用直接调用、全局状态管理,还是自定义事件?这三种方式各有适用场景,我们来简单对比一下。

直接调用就像打电话,你知道要打给谁,也知道对方的号码,双方有明确的依赖关系。这种方式适合紧邻模块之间的同步协作,比如父组件调用子组件的方法。但如果调用链太长,就会形成强依赖,耦合度高,改一个地方可能影响整条链路。

全局状态管理(如 Redux、Pinia)就像共享的黑板,所有模块都可以在上面读写数据。这种方式适合跨页面、跨组件的复杂状态共享,比如用户信息、主题设置等。但它不太擅长表达瞬时动作,比如"用户刚刚登录了"这种一次性的通知。

自定义事件则像广播电台,某个地方发出信号,所有在"收听"的地方都能收到。它适合表达"发生了什么"这种瞬时动作,适合跨模块的松耦合触发与扩展。比如用户登录后,需要通知多个模块,用自定义事件就很合适,不需要知道具体有哪些模块在监听,也不需要它们之间相互依赖。

创建自定义事件

使用 CustomEvent(推荐)

CustomEvent 是浏览器提供的专门用于创建自定义事件的 API,它比普通的 Event 更强大,可以携带自定义数据。下面我们来看一个完整的例子。

// 创建自定义事件
// 第一个参数是事件名称,建议使用有意义的名称,比如 'userLogin'
// 第二个参数是配置对象,可以设置事件的详细信息
const myEvent = new CustomEvent('userLogin', {
  // detail: 自定义事件携带的数据,可以是任何类型
  // 这里我们传递了用户名、用户ID和时间戳
  detail: {
    username: 'zhangsan',
    userId: 12345,
    timestamp: Date.now()
  },
  // bubbles: 是否冒泡,true 表示事件会向上冒泡到父元素
  // 就像水里的气泡会往上冒一样,事件会从子元素传播到父元素
  bubbles: true,
  // cancelable: 是否可以取消,true 表示可以通过 preventDefault() 取消
  // 类似原生事件的 cancelable,允许监听器阻止默认行为
  cancelable: true
});

// 监听自定义事件
// 使用 addEventListener 监听事件,和监听原生事件一样
// 事件名称要和创建时保持一致:'userLogin'
document.addEventListener('userLogin', (e) => {
  // e 是事件对象,e.detail 就是创建事件时传入的 detail 数据
  console.log('用户登录:', e.detail.username);
  console.log('用户ID:', e.detail.userId);
  console.log('登录时间:', new Date(e.detail.timestamp).toLocaleString());
});

// 触发自定义事件
// dispatchEvent 会触发事件,所有监听这个事件的函数都会执行
document.dispatchEvent(myEvent);

使用 Event(不推荐,功能有限)

Event 也可以创建自定义事件,但它不能携带数据,功能比较有限。在实际开发中,除非你确实不需要传递数据,否则还是建议使用 CustomEvent。

// Event 只能创建事件,不能携带数据
const event = new Event('myEvent');
// 触发事件
document.dispatchEvent(event);

// 监听事件
document.addEventListener('myEvent', (e) => {
  console.log('事件触发了');
  // e.detail 是 undefined,因为 Event 不支持携带数据
});

命名规范与数据契约

在实际使用自定义事件时,有一些最佳实践可以帮助我们写出更易维护的代码。首先是命名规范,建议使用"领域:动作"或"领域.动作"的格式,比如 user:login、cart:itemAdded、order.completed。这种命名方式一目了然,一眼就能看出这是哪个业务领域、什么动作。比如看到 user:login,就知道是用户领域的登录动作。

其次是负载设计,也就是 detail 字段里放什么数据。这里有个原则:只放最小必要的字段,避免携带庞大的对象。比如用户登录事件,只需要传递 userId、username 等关键信息就够了,没必要把整个用户对象都传过去。这样既能减少内存占用,也能让事件的语义更清晰。

关于可取消行为,cancelable 默认为 false。如果你需要允许监听器阻止事件的后续处理流程,可以设置为 true。然后在需要阻止的地方,调用 e.preventDefault(),在其他监听器中通过 e.defaultPrevented 来判断是否被阻止了。这个机制在表单提交、页面跳转等场景很有用。

最后是冒泡策略,bubbles 默认为 false。如果你需要事件在 DOM 树中向上传播,可以设置为 true。比如在某个按钮上触发事件,如果设置了冒泡,父元素、祖父元素都能收到这个事件。但要注意避免过度广播,因为事件冒泡会触发所有父元素的监听器,可能会影响性能。

常见陷阱

在使用自定义事件时,有一些常见的坑需要注意,稍不留神就可能踩进去。第一个坑是使用匿名函数绑定监听器,后续无法移除。如果你在某个地方添加了事件监听器,但使用的是匿名函数,那么当你想要移除这个监听器时,会发现找不到对应的函数引用。因为 removeEventListener 需要传入和 addEventListener 时完全相同的函数引用,匿名函数每次都是新的引用,无法匹配。解决方法是使用命名函数,或者保存函数引用。

第二个坑是滥用 document 作为事件分发器。虽然 document 很方便,但如果所有事件都在 document 上分发,会导致事件风暴,所有监听器都在 document 上,难以区分和调试。更好的做法是使用更小粒度的分发节点,比如特定的容器元素,这样事件的作用域更清晰,也更容易管理。

第三个坑是将庞大的状态对象放进 detail。虽然技术上可以这样做,但会导致序列化和日志过载。比如把整个购物车对象、用户完整信息都放进去,不仅占用内存,日志也会变得冗长难读。正确的做法是只放最小必要的上下文信息,比如事件相关的关键字段,其他信息可以通过 ID 去查询。


FunTester 原创精华


↙↙↙阅读原文可查看相关链接,并与作者交流