1. 问题现场还原与核心机制拆解
1.1 一个让人抓狂的现象
如果你写过基于发布订阅模式的前端项目,大概率遇到过这种场景:页面上某个组件在初始化时调用了pubsub.subscribe('someEvent', handler),然后在某个交互动作里执行了一次pubsub.publish('someEvent', data),结果发现那个 handler 被执行了不止一次,而是两次、三次甚至更多次。更诡异的是,刷新页面后第一次操作往往正常,反复切换路由或者反复打开关闭某个弹窗之后,问题就冒出来了。
我第一次碰到这个问题是在一个中后台管理系统的消息通知模块里。当时的需求很简单:A 组件负责监听“数据刷新完成”事件,B 组件在接口回调里发布这个事件。测试环境一切正常,上线之后有用户反馈说列表刷新一次,toast 弹了三条。排查了半天,最后定位到就是pubsub.subscribe被重复注册了,而pubsub.publish只调用了一次。
这个问题的本质并不复杂,但它之所以频繁出现在实际项目里,是因为它牵扯到组件生命周期、事件总线设计、闭包引用这几个容易踩坑的地方。下面我会把这个问题的来龙去脉拆开讲清楚,然后给出几种可以直接落地的解决方案。
1.2 发布订阅模式到底是怎么运转的
在动手解决问题之前,有必要先把发布订阅的底层机制捋一遍。很多人用 pubsub 用了很久,但对它内部怎么存回调、怎么触发回调其实只有一个模糊的印象,这恰恰是问题排查时最大的障碍。
一个典型的发布订阅实现,内部通常维护一个事件池,结构大概长这样:
{ 'eventA': [handler1, handler2], 'eventB': [handler3] }当你调用subscribe('eventA', handler1)时,实现会做两件事:先检查eventA这个键是否存在,不存在就创建一个空数组;然后把handler1push 进这个数组。注意这里的关键点——它不会去重。也就是说,如果你用同一个函数引用订阅了两次,数组里就会有两个相同的引用;如果你用两个不同的函数引用(哪怕函数体一模一样)订阅了两次,数组里也会有两个不同的引用。
当你调用publish('eventA', data)时,实现会取出eventA对应的数组,然后遍历数组,依次执行里面的每一个回调。数组里有几个回调,handler 就被执行几次。这就是“publish 一次、subscribe 回调执行多次”的直接原因。
所以问题的根子不在 publish,而在 subscribe 被调用了多次。publish 只是忠实地执行了它该执行的事情。
1.3 为什么 subscribe 会被重复调用
知道了机制,接下来要回答的是:为什么 subscribe 会被重复调用?根据我这些年排查过的案例,原因基本集中在以下几类。
第一类是组件重复挂载。在 Vue 或 React 这类框架里,组件销毁再重建是很常见的操作。比如你用v-if控制一个弹窗的显示隐藏,每次打开弹窗,组件都会重新走一遍生命周期,mounted或useEffect里的 subscribe 就会再执行一次。如果组件销毁时没有取消订阅,那么旧的订阅还留在事件池里,新的订阅又加进去了,事件池里的回调数量就翻倍了。
第二类是订阅逻辑写在了会被反复执行的地方。比如把 subscribe 写在了updated钩子里、写在了 watch 的回调里、写在了某个会被多次触发的点击事件里。这种情况下,subscribe 的执行次数取决于那段代码被触发的次数,完全不可控。
第三类是模块被多次引入导致事件总线实例不唯一。这个稍微隐蔽一点。如果你的 pubsub 实例是通过某个模块导出的单例,但打包配置或者模块引用路径出了问题,导致这个模块被实例化了两次,那么你 subscribe 的时候用的是实例 A,publish 的时候用的是实例 B,表面上看起来是“没触发”,但如果你在两个实例上都订阅了,就会出现重复触发。这种情况在微前端或者多入口打包的项目里偶尔会遇到。
第四类是热更新导致的重复注册。开发环境下,模块热更新会重新执行模块代码,但旧的订阅关系可能没有被清理。这个问题通常只在开发环境出现,生产环境不会有,但排查的时候容易把人带偏。
把这四类原因记住,基本上覆盖了九成以上的重复订阅场景。接下来我们进入实操环节,看看怎么定位和解决。
2. 定位重复订阅的实操方法
2.1 先确认是不是真的重复订阅了
遇到回调执行多次,第一步不是急着改代码,而是先确认问题到底出在哪。因为“回调执行多次”这个现象,除了重复订阅之外,还有可能是别的原因造成的,比如事件冒泡、多次请求返回、定时器重复触发等等。如果一上来就认定是重复订阅,可能会在错误的方向上浪费很多时间。
我的习惯是在 handler 的第一行打一个标记,把当前的事件池状态打印出来。大多数 pubsub 库都提供了查看订阅者的方法,比如有的库有subscribers属性,有的有getSubscriptions方法。如果没有现成的方法,可以临时在 subscribe 和 publish 的实现里加日志:
// 临时调试用 const originalSubscribe = pubsub.subscribe; pubsub.subscribe = function(...args) { console.log('[subscribe]', args[0], new Error().stack); return originalSubscribe.apply(this, args); };这里用new Error().stack打印调用栈是关键。它能直接告诉你 subscribe 是在哪个文件、哪一行被调用的。如果看到同一个位置被打印了多次,那基本可以确定是重复订阅了。如果调用栈显示的是不同的位置,那说明是多个地方都在订阅同一个事件,需要进一步判断是不是设计上的问题。
提示:调试完成后记得把这段临时补丁去掉,不要留在生产代码里。
2.2 用调用栈快速锁定重复注册的位置
调用栈信息有时候会很长,尤其是经过打包工具处理之后,函数名可能都被压缩了。这时候可以结合 sourcemap 来看,或者在开发环境下调试,因为开发环境的代码通常没有压缩,可读性好很多。
我一般会重点关注调用栈里出现的组件生命周期函数名,比如mounted、created、useEffect、componentDidMount这些。如果发现 subscribe 是在这些生命周期里被调用的,而且同一个组件被挂载了多次,那问题就很明确了。
还有一种情况是调用栈里出现了路由相关的函数,比如beforeEach、afterEach或者路由组件的激活钩子。这说明订阅逻辑可能写在了路由守卫或者activated钩子里,每次路由切换都会重新订阅。
2.3 一个容易被忽略的排查点:事件总线实例是否唯一
前面提到过,如果事件总线实例不唯一,也会导致各种奇怪的现象。排查这个问题的方法很简单:在 subscribe 和 publish 的地方分别打印实例的引用,看看是不是同一个对象。
console.log('subscribe instance:', pubsub); console.log('publish instance:', pubsub);如果两次打印出来的对象引用不同,那就说明模块被实例化了多次。这种情况通常需要检查模块的导出方式,确保导出的是单例而不是每次调用都创建新实例。比如下面这种写法就是有问题的:
// 错误示范:每次 import 都可能创建新实例 export default new PubSub();虽然 ES Module 的静态导入通常只会执行一次模块代码,但在某些打包配置下,或者使用了动态导入、多入口的情况下,模块代码确实可能被执行多次。更稳妥的做法是把实例挂载到一个全局唯一的对象上,或者用明确的单例模式来保证唯一性。
3. 四种可直接落地的解决方案
3.1 方案一:在组件销毁时取消订阅
这是最基础也是最应该做的操作。不管你用的是什么框架,只要在组件里订阅了事件,就必须在组件销毁时取消订阅。这是对称原则——有订阅就要有取消订阅。
在 Vue 2 里,通常是在beforeDestroy钩子里调用pubsub.unsubscribe(handler)。注意这里要传的是当初 subscribe 时用的那个 handler 引用,所以 handler 不能是匿名函数,必须提前定义好:
// Vue 2 示例 export default { data() { return { handler: null }; }, mounted() { this.handler = (data) => { // 处理逻辑 }; pubsub.subscribe('someEvent', this.handler); }, beforeDestroy() { pubsub.unsubscribe('someEvent', this.handler); } };在 Vue 3 的 Composition API 里,可以在onUnmounted里做同样的事情。在 React 里,则是在useEffect的返回函数里取消订阅:
// React 示例 useEffect(() => { const handler = (data) => { // 处理逻辑 }; pubsub.subscribe('someEvent', handler); return () => { pubsub.unsubscribe('someEvent', handler); }; }, []);这里有一个细节需要注意:unsubscribe的参数形式取决于你用的 pubsub 库。有的库支持unsubscribe(eventName, handler),有的只支持unsubscribe(handler),还有的返回一个 token 用于取消订阅。用之前一定要看清楚文档,传错参数会导致取消失败,问题依然存在。
注意:如果 handler 是匿名函数,取消订阅时无法传入相同的引用,所以一定要把 handler 保存下来。这是新手最容易犯的错误之一。
3.2 方案二:订阅前先取消,保证幂等
有时候即使写了取消订阅的逻辑,还是会出现重复订阅。原因可能是取消订阅的时机不对,或者某些异常路径下没有走到取消订阅的代码。这时候可以用一种更保险的策略:在订阅之前先取消一次。
// 订阅前先取消,保证不会重复 pubsub.unsubscribe('someEvent', handler); pubsub.subscribe('someEvent', handler);这种写法叫做幂等订阅——不管执行多少次,最终事件池里只会有一个 handler。它的好处是不依赖销毁钩子的正确执行,即使组件生命周期出了问题,也不会导致重复订阅。
但这种方法也有局限性。如果同一个事件确实需要多个不同的 handler,那这种写法就不适用了,因为unsubscribe('someEvent', handler)只会取消指定的那个 handler,不会影响其他的。所以它适合“一个事件对应一个处理逻辑”的场景。
另外,有些 pubsub 库的unsubscribe在找不到对应订阅时会报错或者抛异常,这种情况下需要先判断再取消,或者用 try-catch 包一下。
3.3 方案三:用标记位控制订阅状态
还有一种思路是在组件内部维护一个标记,记录当前是否已经订阅过了。只有没订阅过的时候才执行订阅:
let isSubscribed = false; function setupSubscription() { if (isSubscribed) return; pubsub.subscribe('someEvent', handler); isSubscribed = true; } function teardownSubscription() { if (!isSubscribed) return; pubsub.unsubscribe('someEvent', handler); isSubscribed = false; }这种写法的好处是逻辑清晰,订阅和取消订阅的状态一目了然。缺点是需要额外维护一个变量,而且在多实例场景下(比如同一个组件被创建了多个实例),标记位不能是模块级的,必须是实例级的,否则会互相干扰。
在 Vue 组件里,这个标记可以放在data里;在 React 里,可以用useRef来保存。关键是要保证标记的生命周期和组件的生命周期一致。
3.4 方案四:封装一个带自动清理的订阅工具
如果项目里订阅操作很多,每个地方都手动写取消订阅的逻辑会很繁琐,而且容易遗漏。这时候可以封装一个工具函数,把订阅和取消订阅的逻辑统一管理起来。
function useSubscription(eventName, handler) { const subscriptions = []; function subscribe(name, fn) { pubsub.subscribe(name, fn); subscriptions.push({ name, fn }); } function unsubscribeAll() { subscriptions.forEach(({ name, fn }) => { pubsub.unsubscribe(name, fn); }); subscriptions.length = 0; } subscribe(eventName, handler); return { subscribe, unsubscribeAll }; }在组件里使用时,只需要在销毁时调用unsubscribeAll即可,不用关心具体订阅了哪些事件。这种封装方式特别适合订阅关系比较复杂的组件,能有效避免遗漏。
如果用的是 Vue 3 或 React,还可以进一步封装成自定义 Hook 或组合式函数,让调用方完全不用关心清理逻辑。比如在 React 里可以这样:
function usePubSub(eventName, handler) { useEffect(() => { pubsub.subscribe(eventName, handler); return () => { pubsub.unsubscribe(eventName, handler); }; }, [eventName, handler]); }这样在组件里只需要一行usePubSub('someEvent', handler)就完成了订阅和自动清理,既简洁又不容易出错。
4. 常见问题排查速查表与避坑经验
4.1 排查速查表
下面这张表整理了我在实际项目中遇到过的典型症状和对应的排查方向,可以当作一个快速索引来用。
| 症状表现 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 首次操作正常,反复操作后回调次数递增 | 组件重复挂载,未取消订阅 | 打印 subscribe 调用栈 | 在销毁钩子中取消订阅 |
| 回调次数固定为 2 或 3 次 | 多个地方订阅了同一事件 | 搜索所有 subscribe 调用 | 合并订阅逻辑或去重 |
| 开发环境正常,生产环境异常 | 打包导致模块重复实例化 | 打印实例引用对比 | 确保事件总线为单例 |
| 热更新后回调次数增加 | 热更新未清理旧订阅 | 重启开发服务器验证 | 开发环境手动清理或忽略 |
| 取消订阅后仍然触发 | unsubscribe 参数不匹配 | 检查 handler 引用是否一致 | 保存 handler 引用,传对参数 |
| 某个组件销毁后其他组件也收不到事件 | 误取消了全局订阅 | 检查 unsubscribe 的事件名 | 区分全局事件和局部事件 |
这张表不是万能的,但它覆盖了大部分常见场景。遇到问题时可以先对照着排查,能省不少时间。
4.2 几个血泪教训
教训一:不要用匿名函数订阅。这个问题我强调过很多次,但还是经常看到有人写pubsub.subscribe('event', () => { ... })。匿名函数订阅之后,你没有任何办法取消它,因为拿不到函数引用。如果这个订阅是在一个会重复执行的地方,那就等着回调爆炸吧。
教训二:注意事件名的命名规范。如果项目里事件名很随意,比如'update'、'change'、'refresh'这种通用词,很容易出现不同模块订阅了同一个事件名的情况。A 模块 publish 了'update',结果 B 模块和 C 模块的 handler 都被触发了,看起来就像“一次 publish 触发了多次”。建议事件名加上模块前缀,比如'user:update'、'order:refresh',减少冲突概率。
教训三:异步操作中的订阅要格外小心。如果 subscribe 是在一个异步回调里执行的,比如接口返回之后才订阅,那么组件的销毁时机和订阅的执行时机可能不一致。组件已经销毁了,异步回调才执行 subscribe,这时候取消订阅的逻辑已经跑完了,新的订阅就留在了事件池里。解决方法是加一个标记,组件销毁后不再执行订阅:
let destroyed = false; onUnmounted(() => { destroyed = true; }); someAsyncOperation().then(() => { if (destroyed) return; pubsub.subscribe('event', handler); });教训四:不要过度依赖事件总线。发布订阅模式确实方便,但它是一种隐式的依赖关系,调试起来比显式的函数调用麻烦得多。如果一个项目里到处都是 pubsub,那维护成本会很高。我的建议是只在跨模块、跨层级的通信场景下使用事件总线,同一个模块内部的通信尽量用 props、状态管理或者直接调用。
4.3 一个实用的调试小工具
最后分享一个我常用的调试工具,可以在开发环境下监控所有订阅和发布操作:
function createDebugPubSub(pubsub) { const originalSubscribe = pubsub.subscribe.bind(pubsub); const originalPublish = pubsub.publish.bind(pubsub); pubsub.subscribe = function(eventName, handler) { console.log(`[PubSub] subscribe: ${eventName}`, handler.name || 'anonymous'); return originalSubscribe(eventName, handler); }; pubsub.publish = function(eventName, data) { console.log(`[PubSub] publish: ${eventName}`, data); return originalPublish(eventName, data); }; return pubsub; }把这个工具挂到开发环境的入口文件里,所有订阅和发布操作都会在控制台留下记录。当出现重复触发时,看一眼日志就能知道是订阅多了还是发布多了,定位效率能提升不少。生产环境记得去掉这段代码,避免不必要的性能开销。
这个问题的核心其实就一句话:publish 只负责触发,subscribe 才决定次数。把订阅的生命周期管理好,重复触发的问题自然就消失了。我在实际项目里踩过的坑告诉我,与其在 publish 那边做各种防御性判断,不如在 subscribe 这边把好关,该取消的取消,该去重的去重,从源头上杜绝重复订阅。