VUE组件跨通信vue2 与 vue3 中实现全局事件总线
1. 引言
1.1 总线
总线(Bus)一词源于工业通信网络,原表示计算机各种功能部件之间传送信息的公共通信干线。我们借用总线的概念,希望在 Vue 开发中寻找到一种能够在 Vue 的各个组件之间传送信息的公共通信干线,这就是我们所说的 事件总线。
1.2 全局可访问的事件
简而言之,实现 事件总线 的目标是便于 Vue 不同组件之间的通信。为了实现这一目标我们需要找到一个虽然不属于任何组件,但是所有的组件都必须要能够访问。
需要找到这么一个布置总线的地方,在 Web 开发环境中当然最先想到的就是 全局 Window 对象。你可以在该对象上挂载一个对象(这里我们取名为 $bus)到该对象上:
window.$bus = {}
虽然功能上看,Window 对象上确实都可以访问到,但是我们几乎没有人会去修改环境自带的 Window 对象,而通过其它的方式来实现。具体的实现方式在 Vue2 和 Vue3 中有所不同,比如在 Vue2 中我们会通过将这个对象挂载到 Vue 的原型上来实现,而在 Vue3 中却没有暴露一个 Vue 对象来给我们用于创建 Vue 的根组件实例。本文在后面的内容中,将详细讲解 Vue2 和 Vue3 中具体的实现方式。
首先,我们需要在组件A上实现一个回调,并将其绑定一个 自定义事件(event function),挂到 全局事件总线 bus 上。在 B 组件中需要向 A 组件传递数据时,通过访问 全局事件总线 上的该事件函数,将参数传入 组件 A。通过这样的方式,就实现了 跨组件的事件触发和参数传递。
2. 在 Vue2 中实现全局事件总线
2.1 vue2 中的 Vue 和 VueComponent
在 Vue2 中,我们是通过 Vue 对象来创建 Vue 的实例对象的。在脚手架初始化的 Vue 项目中,项目 main.js 的内容大致如下:
// vue2 pack entrance mian.js import Vue from 'vue' import App from './App.vue' Vue.config.productionTip = false new Vue({ render: h => h(App), }).$mount('#app')
除了根组件是传入 Vue 构造函数时有构造函数建立的,其它的组件是在根组件的基础上,在构建时通过组件的父子使用方式使用其它引入的 vue 文件,由 vue.extend
进行生成的。
在 Vue 中,为了让组件实例对象可以访问到 Vue 原型上的属性和方法,在Vue内部有:
// 见 《关于 VueComponent 是什么》 部分 VueComponent.prototype.__proto__= Vue.prototype
因此我们需要在使用 Vue 构造函数创建实例对象之前在 Vue 构造函数的原型上绑定一个变量,那么之后不论在任意 Vue 组件 实例对象,还是 Vue的实例对象上,都可以对这个变量进行访问。
关于 VueComponent 是什么
Vue2 中,在 Vue 构建时的最后阶段将依次执行initUse
、initMixin
、initExtend
、initAssetRegisters
这几个过程。
其中 initExtend
相当于递归构建组件树,其代码如下:
// 摘选自 vue 源码 function initExtend(Vue) { /** * 每个实例构造函数,包括Vue,都有一个唯一的cid。 * 这使我们能够为原型继承创建包装的“子构造函数”并缓存它们。 */ Vue.cid = 0; var cid = 1; /** * Class 继承 */ Vue.extend = function (extendOptions) { extendOptions = extendOptions || {}; var Super = this; var SuperId = Super.cid; var cachedCtors = extendOptions._Ctor || (extendOptions._Ctor = {}); if (cachedCtors[SuperId]) { return cachedCtors[SuperId]; } var name = getComponentName(extendOptions) || getComponentName(Super.options); if (name) { validateComponentName(name); } var Sub = function VueComponent(options) { this._init(options); }; // 注意这行代码 Sub.prototype = Object.create(Super.prototype); Sub.prototype.constructor = Sub; Sub.cid = cid++; Sub.options = mergeOptions(Super.options, extendOptions); Sub['super'] = Super; // 对于props和computed属性,我们在扩展时,在扩展的原型上,在Vue实例上定义代理getters。 // 这避免了为每个创建的实例调用Object.defineProperty。 if (Sub.options.props) { initProps(Sub); } if (Sub.options.computed) { initComputed(Sub); } // 允许进一步使用 extension/mixin/plugin Sub.extend = Super.extend; Sub.mixin = Super.mixin; Sub.use = Super.use; // 创建asset注册,这样扩展类也可以拥有它们的私有asset。 ASSET_TYPES.forEach(function (type) { Sub[type] = Super[type]; }); // 启用 递归 自查找 if (name) { Sub.options.components[name] = Sub; } // 在扩展时保留对超类选项的引用。 // 稍后在实例化时,我们可以检查Super的选项是否已经更新。 Sub.superOptions = Super.options; Sub.extendOptions = extendOptions; Sub.sealedOptions = extend({}, Sub.options); // cache constructor cachedCtors[SuperId] = Sub; return Sub; }; }
模拟类的继承的实现过程中,其中有一行代码是:
Sub.prototype = Object.create(Super.prototype);
其中这里的 Sub
就是函数 VueComponent
。
下面的 objCreate 函数模拟 Object.create
的方法具体步骤:
function objCreate(proto, propertiesObject){ const _obj = {} _obj.__proto__ = proto; if(propertiesObject){ Object.defineProperties(_obj, propertiesObject) } return _obj }
因此上面的过程相当于:
const _obj = {}; _obj.__proto__ = Vue.prototype; VueComponent.prototype = _obj; // 相当于VueComponent.prototype.__proto__ = Vue.prototype
因而相当于:
VueComponent.prototype.__proto__ = Vue.prototype
先记住这个结论,后面我们还会用到。
这里多插一句,有人自己无法分析是因为他实现
Object.create
的方法是思路是完全错误的,比如一个典型的错误但似乎可以用的实现:
// Object.create 的错误实现 // 以下未曾实现Object.create ,只是通过 new 关键字间接调用了现有的 Object.create 方法 function objectCreate(obj){ function F(){}; F.prototype = obj; return new F(); }
这个实现中使用了
new
关键字,而实际上,实现new
需要使用到Object.create()
函数,因此这是一个典型的套娃形式。试想,如果JavaScript 内部new
关键字调用Object.create()
函数实现,而实现Object.create()
函数又使用new
,那么Object.create()
的真正实现到底在哪里呢?因此这种实现相当于完全未曾实现,只不过是借用关键字new
中调用的Object.create()
,仅此而已。
ES6 后很多初学者对于原型继承并不是那么熟悉,因此在讲解上面源码,我们尽量讲解基础一些。
在 JavaScript 面向对象编程中,构造函数的prototype
属性 和 实例的 __proto__
属性都指向了 构造函数的 prototype 对象,即这个 构造函数的原型对象。因此,对于 Vue 和 VueComponent 分别有:
从 Vue 对象看:
从 VueComponent 对象看:
其中,一个对象的原型对象(prototype)上的 __proto__
即 [[Prototype]]
,它来源于 ECMA-262规范中的定义,但实际在很多浏览器的实现中,为每个对象都提供了一个 __proto__
的指针,它就相当于 [[Prototype]]
。
在实例对象上 __proto__
指针用于 指向发生构造调用的函数(构造函数)的原型对象(即构造函数的prototype 属性)的指针。
而在原型对象(prototype)上的 __proto__
指针(即构造函数.prototype.__proto__
)可以用来模拟类的继承。
在 JavaScript 中,查找一个被访问的属性时,先查找对象自身是否存在这个属性,如果没有找到,则继续查找这个构造器的原型对象(prototype)。这个原型对象(prototype)自身又可以访问它构造器上的原型对象,也就是说假设这个 prototype 对象是另外一个构造器构造出来的实例对象,则 prototype 对象视作实例对象时的 __proto__
属性(prototype.__proto__
)指向即它自己构造器的原型。依此类推,就构成了一个链式结构,即所谓原型链。这样,访问一个对象的属性,就可以顺着原型链去查找。我们之前分析的,VueComponent.prototype.__proto__ = Vue.prototype
,画在图上就是:
因此可以得出结论:
组件实例对象(VueComponent instance)可以访问到 Vue 原型(Vue.prototype)上的属性和方法。
往往 VueComponent 对象有很多个,但是 Vue 却始终只有一个,它就是我们在main.js
中通过导入的 Vue 构造器函数。因此,我们只要构造Vue实例前,在 这个Vue构造器函数的原型对象即 Vue.prototype
上添加新的属性,可以实现全局访问。
注:
vc 和 vm 简写出均自于 vue2 官方文档。其中 vc 表示某个组件实例对象,即 VueComponent 的实例对象。而 vm 表示某个 Vue 对象实例。之所以叫 vm 是因为文档给出过这样的示例:
var vm = new Vue({ data: data })
2.2 Vue 中的组件自定义事件(简要)
2.2.1 事件的触发与监听
通过 v-on 绑定事件
和 原生事件 的处理类似,我们可以使用 v-on
指令 (简写为 @
) 来监听 DOM 事件,并在事件触发时执行对应的 JavaScript。
例如在父子组件中:
<template> <MyComponent @myevent="myCallback" /> </template> <script> import MyComponent from "./MyComponent.vue" export default { components: [MyComponent], methods:{ myCallback(){ console.log("myevent 事件的回调 myCallback 被调用了。") } } } </script>
这里的 myevent
是一个定义在子组件 MyComponent 上的自定义事件。这样就要求子组件 MyComponent 上有什么方式来触发属于它的 myevent
事件。
在 Vue 中,为我们提供了 $emit
来触发某个事件,其语法格式如下:
vm.$emit( eventName, […args] )
我们可以依据一定的条件,比如监视某个数据的值到达多少,又比如通过其它事件的回调,对 $emit
方法进行调用。
例如在 MyComponent 组件中定义一个按钮,它点击事件的回调中调用 $emit
来触发组件 MyComponent 的 自定义 myevent 事件:
<template> <button @click="trig_myevent" ></button> </template> <script> export default { methods:{ // 触发 myevent 事件 trig_myevent:(){ this.$emit("myevent") } } } </script>
注: emit 方法也可以用于触发原生事件,还可以用在模板中。比如:
<button @click="$emit('increaseBy', 1)"></button>
通过 ref 绑定事件
<template> <MyComponent ref="xxx" /> </template> <script> import MyComponent from "./MyComponent.vue" export default { components: [MyComponent], methods:{ myCallback(){ console.log("myevent 事件的回调 myCallback 被调用了。") } }, mounted() { // this.$ref.xxx 是引用当前组件中的 xxx 元素的引用 // 这里 xxx 即 MyComponent 子组件的 VueComponent 对象。 // 其 $on 方法用于自定义事件的监听,也就是事件的绑定,实现了和 v-on 一样的效果 this.$ref.xxx.$on("myevent", this.myCallback) } } </script>
自定义事件的监听的方法
vm.$on
用于监听当前实例上的自定义事件。事件可以由 vm.$emit
方法触发。回调函数会接收所有传入事件触发函数的额外参数。
vm.$on( event, callback )
使用 vm.$once
可以只监听一次,者也同样可以使用 v-on 和 .once 修饰符实现:
vm.$once( event, callback )
2.2.2 事件的解绑
在上一小节,我们介绍过使用 v-on
(或其@
语法糖)、vm.$on
、vm.$once
绑定(或说监听)一个事件,以及使用vm.$emit
来触发一个事件。对于一个绑定的事件也可以对其进行解绑,解绑后的事件无法再对其进行触发。
使用 $off
方法可以对一个自定义事件进行解绑。其语法格式为:
vm.$off( [event, callback] )
比如,我们在上面的子组件中再添加一个用于解绑事件的按钮:
<template> <button @click="trig_myevent" ></button> <button @click="unbundling_event" ></button> </template> <script> export default { methods:{ trig_myevent:(){ this.$emit("myevent") }, // 解绑 myevent 事件 unbundling_event:(){ this.$off("myevent") } } } </script>
this.$off
可以传入一个字符串数组,以实现同时解绑该组件的多个事件。如果不传入参数,默认解绑所有事件。
组件上的自定义事件如果一旦组件(VueComponent)的实例对象销毁,比如手动调用this.$destroy()
,则自动失效。
2.2 在 Vue 对象的原型上实现事件总线
现在我们已经做完了两方面的知识铺垫,第一个方面是如何让 vc 和 vm 都可以访问到,而另一个方面是Vue中的事件相关API。
要实现全局事件总线,我们希望在一个全局变量上能够访问$emit
、$on
、$off
等方法,而这些方法都是存在于 Vue 的原型对象(prototype)上,可知 vc 和 vm上都可以访问这些方法。
我们如果要创建一个组件的实例,需要如下两个步骤:
const MyComponent = Vue.extend({...}) // ...表示配置信息 const vc = new MyComponent()
这样 vc 上就可以访问 $emit
、$on
、$off
等方法。
但是如果需要全局访问,就需要绑定在全局变量上。因此在 main.js 中我们这么做:
import Vue from 'vue' import App from './App.vue' Vue.config.productionTip = false const MyComponent = Vue.extend({}); const vc = new MyComponent(); Vue.prototype.$bus = vc; new Vue({ render: h => h(App), }).$mount('#app')
这样,从此我们可以通过 this.$bus
在任意组件内访问该 vc 上的 $emit
、$on
、$off
方法,如 this.$bus.$emit
。但是显然实际上我们不需要实例化一个新的组件来实现,也就是说不仅 可以 利用 vc 上的这些方法,还可以利用 vm 上的这些方法。
只不过以下做法是错误的:
// 错误方式 import Vue from 'vue' import App from './App.vue' Vue.config.productionTip = false let vm = new Vue({ render: h => h(App), }).$mount('#app') Vue.prototype.$bus = vm;
因为,一旦 Vue 实例创建,则 Vue.prototype.$bus
不再能够访问到。
为了让其能够在执行 new
时能够同时绑定在Vue.prototype
上,需要使用 beforeCreate
声明周期选项:
// 正确方式 import Vue from 'vue' import App from './App.vue' Vue.config.productionTip = false let vm = new Vue({ render: h => h(App), beforeCreate() { // 安装全局事件总线 Vue.prototype.$bus = this; } }).$mount('#app')
这里 this
指向的即是当前 vm (Vue实例),这样我们就巧妙地将当前 Vue 直接绑定到实例化它地原型上的 bus 属性上了。
3. 在 Vue3 中实现全局事件总线
3.1 Vue3 API 的相关变化
vue3 中,不再暴露 Vue 用于创建 vm,而是提供了一个 createApp
的 API:
import { createApp } from "vue"; import App from "./App.vue"; import router from "./router"; import store from "./store"; const app = createApp(App); app.use(store).use(router).mount("#app");
在控制台打印它可发现显然不是之前的 Vue 对象实例:
createApp(rootComponent: Component<any, any, any, ComputedOptions, MethodOptions>, rootProps?: Data | null | undefined): App<Element>
可知,使用 createApp
API 创建的应用实例中有一个 config
属性。应用实例会暴露一个 .config 对象允许我们配置一些应用级的选项,例如定义一个应用级的错误处理器,它将捕获所有由子组件上抛而未被处理的错误:
app.config.errorHandler = (err) => { /* 处理错误 */ }
我们可以在控制台上打印一下app.config
对象:
其中我们考到了一个叫 globalProperties
的属性,在Vue3中,他是一个用于注册能够被应用内所有组件实例访问到的全局属性的对象,这是对 Vue 2 中 Vue.prototype 使用方式的一种替代。
比如我们若在 mian.js 绑定一个全局变量,只需要:
app.config.globalProperties.msg = 'hello'
这使得属性 msg 在应用的任意组件模板上都可用,并且也可以通过任意组件实例的 this 访问到:
export default { mounted() { console.log(this.msg) // 'hello' } }
事件这一块,在 Vue3 中,被代理的当前组件实例上事件相关的 API,相对于 Vue2 中的 VueComponent 实例,$on
,$off
和 $once
实例方法已被移除,只保留了 $emit
。
3.2 Vue3 实现全局事件总线
3.2.1 使用 mitt 模块实现基本事件总线
这个主要是为了升级旧的项目。
在 Vue 的 API 做出更改后,我们需要 使用第三方库 mitt
为我们提供类似于 Vue2 中的事件API。
npm i mitt
然后配置到全局:
import { createApp } from "vue"; import App from "./App.vue"; import router from "./router"; import store from "./store"; import mitt from 'mitt'; const app = createApp(App); app.config.globalProperties.$bus = mitt() app.use(store).use(router).mount("#app");
这样,在组件中就可以直接使用 this
进行访问,比如:
import { defineComponent } from 'vue'; import HelloWorld from '@/components/HelloWorld.vue'; // @ is an alias to /src export default defineComponent({ name: 'HomeView', components: { HelloWorld, }, // 选项式 API: mounted() { console.log("$bus =",(this as any).$bus); } });
3.2.2 全局发布订阅器 EventEmmiter
用作事件总线
上面 3.1.1 小结 介绍的 mitt
模块是一个最基本的事件发布对象,它算是 EventEmmiter
的极致精简版。在 nodeJS 模块中,提供了一个 EventEmmiter
对象,这是 发布-订阅 模式用于 JavaScript 管理事件的一个模板。
(关于 发布-订阅 模式 和 EventEmmiter
对象 请移步我的另外一篇博客《发布订阅模式原理及其应用(多种语言实现)》,地址:https://blog.csdn.net/qq_28550263/article/details/129930814)
如果你的事件管理足够复杂,你也比较熟悉发布订阅模式的基本思想,可能你需要使用 EventEmmiter
对象。不过这个 EventEmmiter
对象本身并不包含在除了 nodeJS 模块以外的地方。
好消息是,你可以通过安装 @jcstdio/jc-utils 模块获得该对象:
(这里我安装的是 0.0.14 版本)
npm install @jcstdio/jc-utils@0.0.14
你可以直接将 @jcstdio/jc-utils
模块中的 EventEmmiter
对象导入:
import { EventEmmiter } from '@jcstdio/jc-utils'
让后将这个对象的实例设置为Vue3全局属性:
// main.ts import { createApp } from 'vue' import App from './App.vue' const app = createApp(App); const emitter = new EventEmitter(); app.config.globalProperties.$emitter = emitter; app.mount('#app');
也可能你希望将其集成到你的插件中,作为插件之一给 vue 安装。可以这样做:
// 封装为插件,位置 @/plugins/event import { EventEmitter } from "@jcstdio/jc-utils"; import type { App, Plugin } from 'vue'; const emitter = new EventEmitter(); const eventEmitterPlugin: Plugin = { install: (app: App) => { app.config.globalProperties.$emitter = emitter; }, }; export default eventEmitterPlugin;
然后使用时,通过 use
方法安装:
// main.ts import { createApp } from 'vue' import eventEmitterPlugin from "@/plugins/event"; import App from './App.vue' const app = createApp(App); app.use(eventEmitterPlugin); app.mount('#app');
mounted 选项中使用
现在你可以像之前那样在 mounted 选项中使用,比如:
export default { name: 'HomeView', // 选项式 API 的 mounted 选项: mounted() { console.log("$bus =",(this as any).$bus); } }
onMounted 方法中使用
虽然如果你不使用 setup 语法糖,仅仅使用 setup 选项时,可以将 组合式API 和 旧的 选项式API 混合用。不过单单使用 setup 语法糖还是会写起来更舒服一些,这意味着你无法再使用 mounted 选项,不得不改用 onMounted 方法。
<template> <!-- ... --> </template> <script lang='ts' setup> import { getCurrentInstance,onMounted } from 'vue' import type { ComponentInternalInstance} from 'vue' const that = getCurrentInstance() as ComponentInternalInstance; onMounted(()=>{ const proxy = that.proxy as ComponentPublicInstance; console.log('proxy.$emitter =',proxy.$emitter); }) </script>
这样就可以拿到 EventEmmiter 的实例了:
4. 补充总结:关于 Vue3 中扩展全局属性
这些其实已经再上面用到了,有一些细节上的问题未必见得多数读者能够搞明白,这里细说一下。
Vue 2 中的 Vue.prototype
使用方式在 Vue 3 已经不存在了。作为一种替代方式,Vue3 提供了 app.config.globalProperties
属性,它是 一个用于注册能够被应用内所有组件实例访问到的全局属性的对象。
此 globalProperties
属性的类型签名为:
interface AppConfig { globalProperties: Record<string, any> }
如果全局属性与组件自己的属性冲突,组件自己的属性将具有更高的优先级。
在传统的 选项式 API 中
如何使用
// main.ts import { createApp } from 'vue' import App from './App.vue' const app = createApp(App); app.config.globalProperties.msg = 'hello' app.mount('#app');
这使得 msg 在应用的任意组件模板上都可用,并且也可以通过任意组件实例的 this
访问到:
export default { mounted() { console.log(this.msg) // 'hello' } }
如果你使用的是 TypeScript,那就是:
import { defineComponent } from 'vue' export default defineComponent({ mounted() { console.log(this.msg) // 'hello' } })
关于 this
虽然是 Vue3 这里的 this
不像 组合式API 中的 this
是 undefined
,而是 ComponentPublicInstance
对象。
这就相当于 Vue2 中的 Vue组件实例,但是最大不同在 Vue3 中是一个对组件实例对象的 代理(Proxy)。这里,你可以参考我上面的 关于 VueComponent 是什么 小节。
这里其实是,由于你使用的 选项式 API 的选项 本身就是包裹在一个 JavaScript 对象中,Vue3 就直接替你将 this
绑定到对应的组件实例被代理后的对象上了,你可以使用 this
访问到组件实例上的属性。
在Vue3的 组合式 API 中
Vue3的 组合式 API 中的 this
值是 undefined
,毕竟你的全局 this 也确实没有哪个指向。但是我们还是希望能够获得 当前组件的实例。
因此,在 setup 模式下,vue 不得不提供 getCurrentInstance
函数,它返回一个 ComponentInternalInstance
对象。们一般这样写:
const that = getCurrentInstance(); // 返回 ComponentInternalInstance 或 null(未获取到当前组件实例)
如果获取到当前组件实例则返回 ComponentInternalInstance
对象,我将其存于 that
中,注意这个 ComponentInternalInstance
还不是上一节 ComponentPublicInstance
。我们再通过 proxy
获取 ComponentInternalInstance
对象:
const proxy = that.proxy as ComponentPublicInstance;
关于类型扩展
很多插件会通过 app.config.globalProperties
为所有组件都安装全局可用的属性,就像本文之前所干的那样。
开发中,我们可能为了请求数据而安装了 this.$http
,或者为了国际化而安装了 this.$translate
。为了使 TypeScript 更好地支持这个行为,Vue 暴露了一个被设计为可以通过 TypeScript 模块扩展来扩展的 ComponentCustomProperties 接口。
我们可以将这些类型扩展放在一个 .ts
文件,或是一个影响整个项目的 *.d.ts
文件中。无论哪一种,都应确保在 tsconfig.json
中包括了此文件。对于库或插件作者,这个文件应该在 package.json 的 types 属性中被列出。
为了利用模块扩展的优势,你需要确保将扩展的模块放在 TypeScript 模块 中。 也就是说,该文件需要包含至少一个顶级的 import 或 export,即使它只是 export {}
。如果扩展被放在模块之外,它将覆盖原始类型,而不是扩展!
例如 axios 扩展到全局属性:
import axios from 'axios' declare module 'vue' { interface ComponentCustomProperties { $http: typeof axios } } export {}
实际上我们也可以添加一条 EventEmitter
进去:
// 一个 xx.d.ts, 需要在 tsconfig.json 文件中include 进来 import axios from 'axios' declare module 'vue' { interface ComponentCustomProperties { $http: typeof axios, //如果你还添加了全局 app.config.globalProperties.$http $emitter: EventEmitter } } export {}
这样做时,我们使用绑定 app.config.globalProperties.$emitter = xxx
时,如果你的 xxx 一旦不是一个 EventEmitter 的实例对象,则 会有形如 不能将类型“TYPEOFxxx”分配给类型“EventEmitter”。ts(2322)
的错误提示,说明你的全局 类型扩展 添加成功了。