使用Vue中的eventBus

8,011 阅读2分钟

背景

Vue的状态管理有很多种方法,针对跨多级父子通信, 兄弟通信等,有不同的方案可选。Vue生态中已经有很好的方案解决各种场景的通信问题, 针对中大型项目状态管理, 首选Vuex, 但是如果是小型项目使用Vue的eventBus, 是一个不错的选择, 无需额外的依赖。我们接下来简单说下Vue的eventBus的使用和一些常见的问题。

Vue中的订阅发布

Vue在原型上实现了一套订阅发布,这就是为什么我们可以在组件用$emit、$on 的原因。

import Vue from 'vue';
var vm = new Vue({
    mounted () {
        // 此时就可以监听一个自定义事件event1
        this.$on('event1', (data) => {
            consolo.log(data);
        }); 
    },
    methods: {
        trigger () {
            this.$emit('event1', '猴赛雷~');
        }
    }
});

订阅发布原理

先来理解下订阅发布的原理实现:

class EventBus {
    eventMap = new Map;
    on(eventName, handler) {
        let eventHandlers = this.eventMap.get(eventName);
        if (!eventHandlers) {
            eventHandlers = [];
            this.eventMap.set(eventName, eventHandlers);
        } 
        eventHandlers.push(handler);
    }
    emit(eventName, ...args) {
        const eventHandlers = this.eventMap.get(eventName) || [];
        eventHandlers.forEach(handler => handler(...args));
    }
    once(eventName, handler) {
        const onceHandler = (...args) => {
            this.off(eventName, onceHandler);
            handler(...args);
        }
        this.on(eventName, onceHandler);
    }
    off(eventName, handler) {
        let eventHandlers = this.eventMap.get(eventName);
        if (handler) {
            if (eventHandlers) {
                const idx = eventHandlers.findIndex(cb => cb === handler)
                idx > - 1 && eventHandlers.splice(idx, 1);
            }
        } else {
            this.eventMap.set(eventName, [])
        }
    }
}

const ev = new EventBus();

ev.on('123', console.log);
ev.once('1234', console.log);
ev.emit('123', 1); // 1
ev.emit('123', 2); // 1
ev.emit('1234', 3); // 3
ev.emit('1234', 4); // do nothing

在单个组件内部可以在合适的时间订阅和发布事件。但是下面这中场景是无效,因为组件中的订阅发布系统是私有的,组件中互不干扰。但是我们可以使用一份全局的订阅发布系统,可以在任意组件中使用。

无效的$emit:

<A> 
    <B></B> 
</A>

// A.vue文件:
export default {
    created () {
        this.$on('a-event', () => { // do something})
    }
}

// B.vue文件: 
export default {
    mounted () {
        this.$emit('a-event', 'come from B.vue');
    }
}

实现一个全局的eventBus

全局的eventBus简单理解为在一个文件创建一个新的vue实例然后暴露出去, 使用的时候import这个模块进来即可。我们来编写下这个文件:

在项目中新增一个文件eventBus.js, 代码实现如下:
import Vue from 'vue';
const Bus = new Vue();
const eventBus = {
    TYPES: { // 'TYPES'
        EVENT1: { // 'TYPES.EVENT1'
            EDIT: { // 'TYPES.EVENT1.EDIT'
                INVOKE: {},
                CANCEL: {}, // 'TYPES.EVENT1.EDIT.CANCEL'
                CONFIRM: {}
            },
            ADD: {
                INVOKE: {},
                CANCEL: {},
                CONFIRM: {}
            },
        },
        EVENT2: {
            EDIT: {
                INVOKE: {},
                CANCEL: {},
                CONFIRM: {}
            },
            DELETE: {
                INVOKE: {},
                CANCEL: {},
                CONFIRM: {}
            },
        }
    },
    // 注册事件函数
    on (eventType, cb = () => {}) {
        Bus.$on(eventType.toString(), (...args) => {
            cb(...args);
        });
    },
    // 触发事件函数
    emit (eventType, data) {
        Bus.$emit(eventType.toString(), data);
    },
    // 销毁注册事件函数
    off (eventType) {
        Bus.$off(eventType.toString());
    },
    // 注册事件触发一次后销毁函数
    once (eventType, cb = () => {}) {
        Bus.$on(eventType.toString(), (...args) => {
            cb(...args);
            eventBus.off(eventType.toString());
        });
    }
};

(function (typeRoot) {
    /**
     * @param {*} source 要给每个节点添加链的对象
     * @param {*} parentNode 当前节点的链 比如 EVENT1.EDIT.CANCEL
     */
    function addNodeChain(source, parentNode = 'TYPES') {
        const isObj = typeof source === 'object';
        if (!isObj) return; // 支持传入默认的字符串方式
        const separator = !!parentNode ? '.' : '';
        const isObjEmpty = Object.keys(source).length === 0;
        if (isObjEmpty) {
            source['nodeChain'] = parentNode;
            source.toString = function () {
                return parentNode;
            }
            return;
        }
        for (const key in source) {
            if (source.hasOwnProperty(key)) {
                source['nodeChain'] = parentNode;
                source.toString = function () {
                    return parentNode;
                }
                const nodeChain = parentNode + separator + key;
                addNodeChain(source[key], nodeChain);
            }
        }
    }
    addNodeChain(typeRoot);
    Object.freeze(eventBus);
    window.eventBus = eventBus;
})(eventBus.TYPES);

export default eventBus;

上面的定义了一个eventBus对象,里面定义以下五个属性:

  • TYPES (预先定义好的一些事件模型)
  • on(监听(订阅)事件函数)
  • emit(触发(发布)事件函数
  • once(只监听(订阅)一次事件函数
  • off(移除事件)

TYPES & addNodeChain

上面定义好的eventBus对象很好理解,无非就是简单封装了下Bus的一些api, 我们来说说TYPES对象和addNodeChain方法。
TYPES:
因为on方法第一个传入的参数是字符串, 也是事件名字, 字符串作为事件名字有个弊端, 事件写错了,Vue内部并不会抛错不像(vuex会提示dispatch或者commit不存在), 这对错误跟踪和定位都是比较困难的, 因此我们可以用一个变量(实际上事件还是字符串)维护这些事件名。这里使用对象嵌套的方式来事先定义事件名称, 使用的时候比如 eventBus.on(eventBus.TYPES.EVENT1.UPDATE.INVOKE), 因为事先没有定义eventBus.TYPES.EVENT1.UPDATE, undefined没有INVOKE属性, 控制台直接报错,自然就知道是事件名出错了。

addNodeChain:
这个方法是遍历整个对象, 给每个节点添加nodeChain属性, 也是当前节点的到根节点的链。 并且重写了当前节点的toString方法, 返回当前节点的nodeChain, 我们来打印一下执行addNodeChain后eventBus.TYPES的结构。

{EVENT1: {…}, EVENT2: {…}, nodeChain: "TYPES", toString: ƒ}
EVENT1: {
    ADD: {
        CANCEL: {
        nodeChain: "TYPES.EVENT1.ADD.CANCEL",
        toString: ƒ (),
        },
        CONFIRM: {nodeChain: "TYPES.EVENT1.ADD.CONFIRM", toString: ƒ},
        INVOKE: {nodeChain: "TYPES.EVENT1.ADD.INVOKE", toString: ƒ},
        nodeChain: "TYPES.EVENT1.ADD",
        toString: ƒ (),
    },
},

而在定义事件方法on时:
   on (eventType, cb = () => {}) {
        Bus.$on(eventType.toString(), (...args) => {
            cb(...args);
        });
    },
上面会把接受进来的参数调用toString方法,传入给Bus.$on, 因此事件名称还是字符串。
至此, 我们的eventBus就可以用了。

测试eventBus

我们验证常见的两个场景:

  • 父子组件通信
  • 兄弟组件通信
有以下父组件A, 和子组件B, C, 其中B, C是兄弟组件:
A.vue: 
<template>
    <div>
        <h1>这个是父组件</h1>
        <B></B>
        <C></C>
    </div>
</template>
<script>
import B from './B';
import C from './C';
import eventBus from '@/util/eventBus.js';
export default {
    created () {
        eventBus.on(eventBus.TYPES.EVENT1.ADD.CONFIRM, (data) => {
            console.log(data);
        })
    },
    components: {
        B,
        C,
    }
}
</script>
--------------------------------------------------------------------------------------------

B.vue:
<template>
    <div>
        <h3>这个是子组件B</h3>
        <button @click="emitParentEvent">点击触发父组件A事件</button>
        <button @click="emitBrotherEvent">点击触发兄弟组件C事件</button>
    </div>
</template>
<script>
import eventBus from '@/util/eventBus.js';
export default {
    methods: {
        emitParentEvent () {
            eventBus.emit(eventBus.TYPES.EVENT1.ADD.CONFIRM, '父组件A事件被触发了');
        },
        emitBrotherEvent () {
            eventBus.emit(eventBus.TYPES.EVENT1.ADD.INVOKE, '兄弟组件C事件被触发了');
        }
    }
}
</script>
--------------------------------------------------------------------------------------------
C.vue:
<template>
    <div>
        <h3>这个是子组件C</h3>
    </div>
</template>
<script>
import eventBus from '@/util/eventBus.js';
export default {
    created () {
        eventBus.on(eventBus.TYPES.EVENT1.ADD.INVOKE, (data) => {
            console.log(data);
        })
    },
}
</script>

我们在父组件A和子组件C都注册了事件, 在B组件中通过点击对应的按钮emit事件, 当点击按钮时控制台输出:

嗯, 预期符合结果~~~

eventBus的问题

当我们来回点击上面切换路由, 重新渲染 (父组件A会重新render), 然后再去点击B组件的按钮emit事件, 你会发现, 事件会被重复执行多次, 比如我切换了6次, 事件被触发了6次, emmm:

原因是因为在父组件A和子组件C被destory时候, eventBus.$on的事件是不会被销毁, 组件的每次重新render, 事件就会叠加注册, 而eventBus是全局的,它不会随着你页面切换而重新执行生命周期。
issue: github.com/vuejs/vue/i…

尤大对这个问题也作出了解析, 如图:

解决多次触发的bug

既然eventBus不会随着组件的销毁而注销事件, 那我们可以主动去注销掉事件, 具体的方法就是在eventBus.on的组件中,在beforeDestroy或者 destoryed生命周期中off事件, 我们来修改一下A.vue;

A.vue:
export default {
    created () {
        eventBus.on(eventBus.TYPES.EVENT1.ADD.CONFIRM, data => {
            console.log(data);
        })
        this.$once('hook:beforeDestroy', () =>  {
            eventBus.off(eventBus.TYPES.EVENT1.ADD.CONFIRM);
        })
    }
    
}
</script>

C组件同理

修改后, 就不会出现渲染组件, 叠加注册事件的bug, 每次点击按钮只会被emit一次。

总结

  • 如何实例化一个eventBus和使用方法
  • 编写一个全局的eventBus和处理事件名的命名的问题
  • 解决中央事件总线eventBus重复执行的bug