关于Vue中事件总线的思考和一种替代方案的实现

372 阅读2分钟

1. 事件总线

我们的 Vue2 项目中,使用了类似于原生开发中“事件总线”的“发布-订阅”的全局消息分发机制,主要是依赖了 Vue2 中的事件API

  • 实现方式一:
// 创建全局Vue单例对象:EventBus.js
import Vue from 'vue'
export default new Vue()

// 消息发送方
EventBus.$emit('EventKey', info)
// 订阅消息,建议在 mounted 钩子中
mounted () {
  EventBus.$on('EventKey', (info) => {
    // 事件处理
  })
}
// 移除消息,建议在组件销毁前
beforeDestroy () {
  EventBus.$off('EventKey')
}
  • 实现方式二:
// 在全局Vue实例初始化时,在钩子函数中绑定原型对象
new Vue({
  render: (h) => h(App),
  beforeCreate () {
    // 这里的this指vm实例对象
    Vue.prototype.$my_bus = this
  }
}).$mount('#app')

// 消息发送方
this.$my_bus.$emit('EventKey', info)
// 订阅消息,建议在 mounted 钩子中
mounted () {
  this.$my_bus.$on('EventKey', () => {
    // 事件处理
  })
},
// 移除消息,建议在组件销毁前
beforeDestroy () {
  this.$my_bus.$off('EventKey')
}

1.1. 优势

  1. 全局使用,可跨组件通信,现象上不依赖缓存;
  2. 消息订阅和发送实现形式灵活,代码量少;
  3. 消息收发方逻辑完全解耦(也是缺陷);
  4. 消息发送支持传参,且支持传函数做参数,订阅方可以回调该函数参数,实现双向通信

1.2. 缺陷

  1. 事件 key 全局散乱,不便于管理,容易发生重名冲突,且不易察觉;
  2. 消息订阅和发送过于灵活,逻辑代码散乱,不便于迭代维护,问题排查难以溯源;
  3. 消息代码逻辑复用性差,对使用方具有一定的逻辑侵入性,组件独立后会导致对应的消息处理逻辑失效;
  4. 消息订阅后,必须及时手动移除监听,否则会引发重复执行的问题;
  5. 如果订阅的消息处理函数中直接持有了当前的 Vue 组件实例的引用或者方法、属性,不及时移除消息监听,会导致对应组件实例不能及时释放,产生内存泄漏问题,还可能产生 JS 报错问题(组件某些属性在 destroyed 钩子中被手动释放了,但组件实例被持有未释放);
  6. 如果是 keep-alive 的缓存页面,还必须同时在 activated 和 deactivated 这两个钩子函数中也增加消息订阅和移除操作;
  7. 使用者的心智负担较大,很难避免出错,且不易察觉

2. JSBridge 和事件总线

HybridApp 开发中,JSBridge 是必不可少的存在, JSBridge 是通过挂载在 window 层的全局对象来实现消息通信机制,因为是全局对象,所以消息传递形式在某些方面和事件总线很相似。

2.1. 实际场景

我们的项目中,有一个全局通用的 JSBridge 方法,就是安卓物理返回事件的控制权交接。
安卓物理返回事件具有全局属性,如果不做特殊处理,在任意 H5 页面直接操作物理返回,就都只是响应退出应用的系统事件。
我们可以通过 JSBridge ,从 H5 层传值给 Native,告知 Native 如何响应物理返回事件,并注入对应的 JS 事件方法,交给 Native 在对应的时机去调用。

//  backType=1 h5接管系统返回 backType=0 不接管Native处理
function callNative ({backType}) {
  JSBridge.ready(() => {
    JSBridge.invoke('backType', {
      type
    })
  })
}
// 注入对应的 JS 事件方法,交给 Native 去调用
function registerActionForNative (callBack) {
  JSBridge.ready(() => {
    JSBridge.registerHandler('backEvent', () => callBack())
  })
}

通常情况下,针对 WebApp ,首页的物理返回事件是 Native 处理的,但是 WebApp 的次级子页面的物理返回事件,就需要 H5 去拦截处理,所以在子页面组件内:

// 接管物理返回事件,并注册H5返回事件
mounted () {
  callNative({
    backType: 1
  })
  registerActionForNative(() => {
    // 执行Vue路由返回,或者其他拦截返回的逻辑
    this.$router.back()
  })
},
// 交还物理返回事件
beforeDestroy () {
  callNative({
    backType: 0
  })
}

2.2. 问题和优化

这样的逻辑操作和事件总线的形式很相似,只不过事件发送方来源于 Native ,两次 callNative 交互,就是对应的订阅和移除的操作。
所以这里也就同样具有事件总线的缺陷,如果不“移除”(交还控制权),那么下次的物理返回事件所触发的,就是最后一次 registerActionForNative 所注册的事件。
因为 JSBridge 是全局的,最后的事件注册,本质上还是挂载在 window 层的全局方法,是始终有效的,这可能会导致更严重的问题:执行了一个已经销毁的页面组件内的方法,导致逻辑错误甚至 JS 报错。
beforeDestroy 时,还应该增加一个 registerActionForNative 的反向清理操作,避免事件全局被缓存,但这同样依赖使用者去操作,心智负担更重。

2.3. 依赖事件总线的 JSBridge 的实现方式

针对单页面项目,在全局基础组件中,例如默认的 App.vue 中,实现如下逻辑:

// App.vue 全局接管物理返回事件
mounted () {
  // 全局注册唯一的物理返回键交互拦截
  registerActionForNative(() => {
    this.$my_bus.$emit('GlobalBack')
  })
},
// 使用 watch 监听 $router 的变化
watch: {
  $route (to, from) {
    // 不是首页,接管物理返回
    if (path !== '/home') {
      // 接管物理返回
      callNative({
        backType: 1
      })
    } else {
      // 交还物理返回
      callNative({
        backType: 0
      })
    }
  }
}

在任意子组件中,监听事件消息:

// 订阅消息
mounted () {
  this.$my_bus.$on('GlobalBack', () => {
    // 执行Vue路由返回,或者其他拦截返回的逻辑
    this.$router.back()
  })
},
// 移除消息
beforeDestroy () {
  this.$my_bus.$off('GlobalBack')
}

通过两者相结合,在一定程度上,解决了 JSBridge 全局对象导致的一些问题,但是,使用者依旧需要注意消息的移除操作,这还是事件总线的缺陷问题。

  • 可能导致的实际问题
    1. 如果某个子页面 A 没有及时移除消息监听,恰好又进入另一个子页面 B,页面 B 没有订阅返回事件的消息,那此时 B 页面的物理返回事件,表现上就是在响应页面 A 所注册的全局事件,如果页面 A 订阅的返回消息的事件处理函数中有其他逻辑:比如操作A页面的属性或方法,那就可能会导致一定的逻辑 bug 甚至是 JS 报错问题,而且,其问题溯源非常困难。
    2. 更进一步,如果该页面是 keep-alive 的缓存页面,甚至该页面同时还存在复用情况,那么多层级的路由跳转,加上 keep-alive 缓存的情况,其问题溯源会是灾难级的。

3. Vue3 中的事件总线

事件总线总归是很方便的,但是 Vue3 项目中,并不能像 Vue2 那么方便的实现事件总线,如果是项目升级迁移,或者就纯粹是为了省事,可以考虑使用对应的三方库,其中用的较多的是 mitt
其实源码非常简单,和 iOS 的三方路由库 MGJRouter 的原理十分类似。

4. 替代事件总线的一种方式

其实 Vuex 可以在一定程度上替代事件总线,甚至可以做到更好。
以前面的物理返回交互为例,其核心痛点就是必须关注事件移除操作。如果使用者可以不关心事件的移除,或者事件不移除也不会有太大的副作用,那才是比较好的设计模式。
所以,替代方案的核心得设计思路就是:在去中心化的同时(每个子页面都有独立的物理返回事件),还能保留中心化的优势(每个子页面可以直接调用全局通用的事件接口和事件管理)。

通过 Vuex ,具体可以这样实现:

4.1. Vuex 中心化管理

export default new Vuex.Store({
  state: {
    // 物理返回注册事件的缓存
    _nativeBackActions: {}
  },
  mutations: {
    // 注册某一个页面的物理返回事件
    registerNativeBackAction (state, data) {
      Object.assign(state._nativeBackActions, data)
    },
    // 清理当前页面链路的返回方法注册,可在返回主页面时操作
    clearNativeBackActions (state) {
      state._nativeBackActions = {}
    }
  },
  getters: {
    // 根据指定的key查找方法
    getCurrentNativeBackAction: (state) => (key) => {
      return key ? state._nativeBackActions[key] : null
    }
  }
})

4.2. App.vue 全局触发

这里借鉴了 [2.3] 部分的实现思路。

// App.vue 全局接管物理返回事件
mounted () {
  // 全局注册唯一的JSBridge物理返回键交互拦截
  registerActionForNative(() => {
    // 动态获取当前href的返回注册方法
    const backAction = this.$store.getters.getCurrentNativeBackAction(window.location.href)
    if (backAction) {
      backAction()
    }
  })
},
// 使用 watch 监听 $router 的变化
watch: {
  $route (to, from) {
    // 不是首页,接管物理返回
    if (path !== '/home') {
      // 接管物理返回
      callNative({
        backType: 1
      })
    } else {
      // 交还物理返回
      callNative({
        backType: 0
      })
      // 自动清理当前链路的返回方法
      this.$store.commit('clearNativeBackActions')
    }
  }
}

4.3. 子页面的操作

此时子页面只需要按照规范去动态注册对应的返回事件,这里统一使用了页面的 href 去做 key ,一个是因为它在一定程度上具有全局唯一性;另一个是因为它可以动态获取,比较方便。

mounted () {
  // 注册当前页面独有的物理返回事件
  this.$store.commit('registerNativeBackAction', {
    [window.location.href]: () => {
      // 执行Vue路由返回,或者其他拦截返回的逻辑
      this.$router.back()
    }
  })
}