Code前端首页关于Code前端联系我们

npm

terry 4天前 阅读数 1064 #Vue

Vue3项目里不用Vuex/Pinia也能搞定组件间跨级通信?还能更轻量更灵活? 在开发中小型Vue3应用时,很多人上来就会纠结要不要上状态管理库——毕竟Vuex/Pinia虽然功能全,但安装配置一套下来,对只有三五个跨级通信场景的小项目来说,总有点“杀鸡用牛刀”的冗余感,那有没有一种东西,能替代状态管理库的跨级通信部分,甚至在特定场景下比它们更顺手?当然有,就是今天要聊的mitt。

mitt到底是什么来头

可能有人第一次听说这个库,先别急着觉得陌生,它其实是Vue2时代EventBus的官方推荐替代品之一,哦对了,聊mitt前先提一嘴Vue2的EventBus,也算帮大家搭个桥理解:Vue2里我们通常会用一个空的Vue实例当事件中心,通过$on、$emit、$off来实现组件的订阅、发布、取消订阅事件,对吧?但到了Vue3,官方移除了全局的$on、$emit等实例方法,EventBus就没法直接用空实例做了,这时候mitt就冒了出来,成了最主流的轻量级跨级通信方案。

mitt本质上是一个独立的、不到200字节压缩后(gzip压缩甚至能到100字节以内)的微型事件发射器库,它不依赖任何框架,不管是Vue、React、Angular,甚至是纯JS项目,都能用它来做事件驱动的消息传递,而且它的API设计得和Vue2的EventBus超级像,基本上上手零门槛——用过Vue2 EventBus的人,扫一眼API就能直接用。

可能有人会说,不就是个事件发射器吗?纯JS我自己写一个也行啊,用得着单独装个库?这话倒是没错,但自己写得考虑兼容性问题、事件重复绑定问题、取消所有事件问题、通配符订阅问题这些细节,写出来的代码未必比mitt轻量,而且还可能有bug,mitt已经经过了好几年的社区打磨,npm周下载量几百万次,这些细节问题它都帮你处理好了,用起来更省心。

Vue3中怎么安装和配置mitt

安装mitt超级简单,npm、yarn、pnpm随便选一个就行:

# yarn
yarn add mitt
# pnpm
pnpm add mitt

安装完之后,怎么在Vue3里配置呢?这里分两种主流的场景:一种是全局挂载,一种是按需引入。

先说说全局挂载的情况,全局挂载的好处是,在任何组件里都能直接用,不用每次都import,适合跨级通信场景比较多的项目,Vue3里全局挂载有两种方式:一种是通过app.config.globalProperties挂载,另一种是通过provide/inject挂载(官方更推荐这种)。

用app.config.globalProperties挂载的话,代码大概长这样:

// main.js
import { createApp } from 'vue'
import App from './App.vue'
import mitt from 'mitt'
// 创建mitt事件中心实例
const emitter = mitt()
const app = createApp(App)
// 挂载到全局属性上,命名为$emitter,和Vue2的$bus保持一致,方便老项目迁移
app.config.globalProperties.$emitter = emitter
app.mount('#app')

然后在组件里用的话,需要用getCurrentInstance来获取全局属性(不过setup语法糖里不能直接用this,这点要注意):

<template>
  <button @click="sendMessage">发送消息给兄弟组件</button>
</template>
<script setup>
import { getCurrentInstance } from 'vue'
// 获取Vue实例
const instance = getCurrentInstance()
// 取出全局挂载的emitter
const emitter = instance?.proxy?.$emitter
const sendMessage = () => {
  emitter.emit('messageFromA', '这是组件A发来的消息')
}
</script>

不过官方不太推荐用app.config.globalProperties,因为它破坏了组件的可测试性——测试组件的时候,你还要去mock这个全局属性,比较麻烦,所以更推荐用provide/inject来挂载:

// main.js
import { createApp } from 'vue'
import App from './App.vue'
import mitt from 'mitt'
const emitter = mitt()
const app = createApp(App)
// provide到根组件,key可以自定义,这里用Symbol('emitter')避免命名冲突
app.provide('emitter', emitter)
app.mount('#app')

组件里用的话,直接inject就行:

<template>
  <button @click="sendMessage">发送消息给兄弟组件</button>
</template>
<script setup>
import { inject } from 'vue'
// inject根组件provide的emitter,默认值设为空函数以防万一
const emitter = inject('emitter', () => {})
const sendMessage = () => {
  emitter.emit('messageFromA', '这是组件A发来的消息')
}
</script>

如果跨级通信场景特别少,比如只有两三个组件之间偶尔发个消息,那按需引入就够了,不用全局挂载:

// 单独创建一个emitter.js文件,导出mitt实例
import mitt from 'mitt'
export default mitt()

然后在需要的组件里直接import这个文件就行:

<!-- 组件A(发布者) -->
<template>
  <button @click="sendMessage">发送消息给兄弟组件</button>
</template>
<script setup>
import emitter from '@/utils/emitter.js'
const sendMessage = () => {
  emitter.emit('messageFromA', '这是组件A发来的消息')
}
</script>
<!-- 组件B(订阅者) -->
<template>
  <div>{{ receivedMessage }}</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import emitter from '@/utils/emitter.js'
const receivedMessage = ref('')
// 定义订阅事件的处理函数
const handleMessage = (msg) => {
  receivedMessage.value = msg
}
// 组件挂载时订阅事件
onMounted(() => {
  emitter.on('messageFromA', handleMessage)
})
// 组件卸载时取消订阅,防止内存泄漏!这点超级重要
onUnmounted(() => {
  emitter.off('messageFromA', handleMessage)
})
</script>

mitt有哪些核心API

mitt的核心API只有5个,简单到爆炸:

  1. mitt():创建一个新的mitt事件中心实例,也可以传入一个Map对象作为初始的事件存储(一般不用传)。
  2. on(event, handler):订阅事件,event可以是字符串、Symbol或者通配符'*',handler是事件触发时的回调函数。
  3. off(event, handler):取消订阅事件,如果不传handler,会取消该事件的所有订阅;如果event和handler都不传,会取消所有事件的所有订阅。
  4. emit(event, [payload]):发布事件,event是要触发的事件名,payload是可选的传递给订阅者的参数(注意,mitt只支持传递一个参数,如果要传递多个,就把它们放在一个对象或数组里)。
  5. all:这个是可选的API,它是一个Map对象,存储了当前事件中心所有的事件和对应的处理函数,你可以直接操作它,比如清空所有事件(不过一般用off()不传参数更规范)。

这里重点说一下通配符订阅'*',这个功能Vue2的EventBus是没有的,mitt的这个小功能有时候特别好用,比如你想在项目里加一个日志功能,记录所有被触发的事件,那用通配符订阅就太方便了:

// emitter.js
import mitt from 'mitt'
const emitter = mitt()
// 通配符订阅所有事件
emitter.on('*', (event, payload) => {
  console.log(`触发了事件:${event},传递的参数是:`, payload)
})
export default emitter

这样不管哪个组件触发了哪个事件,控制台都会打印出来,调试的时候特别爽。

mitt在Vue3里的实际应用场景

刚才说了mitt是轻量级跨级通信方案,那它具体适合哪些场景呢?

兄弟组件间的通信

兄弟组件间的通信是最常见的跨级通信场景之一,如果兄弟组件之间没有共同的父组件或者共同的父组件离得太远(比如跨了三四层),那用props层层传递就太麻烦了,用Vuex/Pinia又太冗余,这时候mitt就是最佳选择。

举个具体的例子:比如你在做一个电商网站的商品列表页,左边是筛选栏组件,右边是商品列表组件,这两个是兄弟组件,当用户在筛选栏里选择了某个价格区间或者某个品牌,右边的商品列表组件需要重新请求数据,这时候就可以用mitt:筛选栏组件发布一个'filterChange'事件,把筛选条件传过去;商品列表组件订阅这个'filterChange'事件,接收到筛选条件后重新请求接口。

祖孙组件间的通信

祖孙组件间的通信也差不多,如果用provide/inject的话,父组件也得参与进来(虽然provide/inject可以跨级,但有时候父组件只是个中间层,不想让它知道这件事),这时候mitt更直接。

比如你在做一个后台管理系统的侧边栏组件,侧边栏里有一个折叠按钮组件(孙组件),点击折叠按钮后,需要改变整个布局的宽度(祖组件),这时候就可以用mitt:折叠按钮组件发布一个'sidebarCollapse'事件;祖组件订阅这个事件,接收到后改变布局的宽度。

路由跳转前后的通信

有时候你需要在路由跳转前保存一些数据,或者在路由跳转后触发前一个页面的某个操作,这时候用mitt也很方便,比如你在做一个表单页面,用户填写了一半表单,然后去其他页面了,回来的时候希望表单的数据还在,这时候可以用mitt:在路由跳转前(比如beforeRouteLeave)发布一个'saveFormData'事件,把表单数据传过去;在组件挂载时订阅这个事件,接收到后回填表单数据。

非Vue组件和Vue组件间的通信

因为mitt不依赖任何框架,所以它还能用来做非Vue组件(比如原生JS写的插件、第三方库的组件)和Vue组件间的通信,比如你在项目里用了一个原生JS写的图表库,图表点击某个数据点的时候,需要在Vue组件里显示详细信息,这时候就可以用mitt:在图表的点击回调里发布一个'chartPointClick'事件,把数据点的信息传过去;在Vue组件里订阅这个事件,接收到后显示详细信息。

mitt有哪些注意事项

虽然mitt简单好用,但也有一些注意事项,要是忽略了可能会出问题:

注意事项一:组件卸载时一定要取消订阅!

这是最重要的一点,没有之一,如果组件卸载时不取消订阅,那mitt事件中心里还会保留这个组件的回调函数,当事件再次触发时,回调函数还会执行,这不仅会导致逻辑错误,还会造成内存泄漏——因为组件实例已经被销毁了,但回调函数里还引用着组件里的变量,导致这些变量无法被垃圾回收,时间长了页面会越来越卡。

那怎么取消订阅呢?刚才已经提到过了,就是在onUnmounted生命周期钩子函数里调用emitter.off(),传入对应的事件名和处理函数,这里要注意,处理函数必须是同一个引用,不能在on里写匿名函数,然后在off里又写一个相同的匿名函数——因为两个匿名函数的引用不一样,这样是取消不了订阅的。

错误的写法:

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import emitter from '@/utils/emitter.js'
const receivedMessage = ref('')
onMounted(() => {
  // 这里写了匿名函数
  emitter.on('messageFromA', (msg) => {
    receivedMessage.value = msg
  })
})
onUnmounted(() => {
  // 这里又写了一个相同的匿名函数,但引用不一样,取消不了
  emitter.off('messageFromA', (msg) => {
    receivedMessage.value = msg
  })
})
</script>

正确的写法:

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import emitter from '@/utils/emitter.js'
const receivedMessage = ref('')
// 先把处理函数定义成一个变量
const handleMessage = (msg) => {
  receivedMessage.value = msg
}
onMounted(() => {
  emitter.on('messageFromA', handleMessage)
})
onUnmounted(() => {
  emitter.off('messageFromA', handleMessage)
})
</script>

注意事项二:mitt只支持传递一个参数

刚才也提到过,mitt的emit方法只支持传递一个参数,如果要传递多个,就把它们放在一个对象或数组里。

// 错误的写法:传递两个参数
emitter.emit('addUser', '张三', 18)
// 订阅者只能接收到第一个参数'张三',第二个参数18收不到
emitter.on('addUser', (name, age) => {
  console.log(name) // 张三
  console.log(age) // undefined
})
// 正确的写法:把多个参数放在对象里
emitter.emit('addUser', { name: '张三', age: 18 })
// 订阅者接收到对象,解构一下就行
emitter.on('addUser', ({ name, age }) => {
  console.log(name) // 张三
  console.log(age) // 18
})

注意事项三:事件名最好用Symbol或者统一的命名空间

如果项目比较大,事件名可能会冲突,比如两个不同的组件都定义了'change'事件,这时候就会出问题,所以最好用Symbol来定义事件名,或者给事件名加上统一的命名空间,user/change'、'product/change'。

用Symbol定义事件名的例子:

// constants/events.js
// 把所有事件名定义成Symbol,放在一个单独的文件里,方便管理
export const USER_CHANGE = Symbol('userChange')
export const PRODUCT_CHANGE = Symbol('productChange')

然后在组件里引入这些Symbol:

<script setup>
import { inject } from 'vue'
import { USER_CHANGE } from '@/constants/events.js'
import emitter from '@/utils/emitter.js'
const emitter = inject('emitter', () => {})
const changeUser = () => {
  emitter.emit(USER_CHANGE, { name: '李四' })
}
</script>

这样事件名就绝对不会冲突了。

注意事项四:mitt不适合做复杂的状态管理

mitt只是一个事件发射器,它只能做消息传递,不能像Vuex/Pinia那样做状态的持久化、状态的追踪(比如Vuex的devtools可以查看状态的变化历史)、状态的计算(比如Pinia的getters)、状态的修改约束(比如Vuex的mutations必须同步),如果你的项目有复杂的状态管理需求,比如有很多全局共享的状态,需要持久化到localStorage,需要查看状态的变化历史,那还是老老实实用Vuex/Pinia吧。

mitt和Vuex/Pinia的对比

最后我们来对比一下mitt和Vuex/Pinia,帮大家更好地选择:

对比项 mitt Vuex/Pinia
大小 不到200字节(gzip压缩) Pinia约1KB,Vuex约2KB(gzip压缩)
功能 只有事件发布订阅 状态存储、计算属性、修改约束、devtools、持久化等
学习成本 零门槛(用过Vue2 EventBus的直接上手) 有一定学习成本
适用场景 中小型项目的轻量级跨级通信 中大型项目的复杂状态管理
状态追踪 不支持 支持(devtools)
持久化 不支持(需要自己写) 支持(有现成的插件)
可测试性 高(按需引入的话直接mock就行) 中(需要mock store)

mitt是一个超级轻量、超级简单的事件发射器库,它是Vue2 EventBus的完美替代品,适合中小型项目的轻量级跨级通信,它的核心API只有5个,上手零门槛,而且不依赖任何框架,非Vue组件和Vue组件间的通信也能用。

不过使用mitt的时候一定要注意:组件卸载时一定要取消订阅,防止内存泄漏;只支持传递一个参数,多个参数要放在对象或数组里;事件名最好用Symbol或者统一的命名空间,防止冲突;不适合做复杂的状态管理,复杂状态管理还是用Vuex/Pinia。

mitt是Vue3开发工具箱里的一把“小剪刀”,虽然不能像“大刀”Vuex/Pinia那样砍大树,但剪剪小树枝、小树叶还是非常顺手的,如果你正在开发中小型Vue3应用,有一些轻量级的跨级通信需求,不妨试试mitt,相信你会爱上它的。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门