说明

EventEmitter 是在项目中经常会使用到的通信工具。

如果在 React 项目中使用,虽然代码可能看起来没有那么纯粹(这里的纯粹指的是代码整洁度相关),但某个需求可能就会需要去使用这个能力

EventEmitter 本身是 nodejs 中的基础消息实现 events ,在 Web 项目中,出现了各种参照 events 相关实现的库,如:

上面这种库很多,实现大同小异。

场景举例

90959-iugg0ysp9ma.png

上面这个场景中,Test1 和 Test2 可以理解成两个组件,之间通过 EventEmitter 互动通信,并且接受对方的消息。

这里只是为了举例 EventEmitter 构造的场景,与实际应用无关。

如果基于 EventEmitter 的方式,如何实现呢?

EventEmitter 的实现与结构

基本结构

首先先看下 EventEmitter 的基本结构和能力。

我自己本身也写过相关的实现,如果不考虑过多条件,一般来说一个 EventEmitter 基本上满足如下 API 即可:

emitter = {
    // 保存所有 event key 与 handlers 数据
    // 基本结构 key: string-handlers: Function[]
    _eventMap: {},
    on() {},
    emit() {},
    off() {},
    once() {},
    clear() {},
};
  • 一个队列维护所有的事件与处理方法(一个事件可能有多个方法,因此是一个列表)
  • on 用来添加事件监听与对应的 handler
  • off 用来取消事件坚挺
  • emit 触发某个事件,传入对应的入参

思考点

  • emitter 的实例如何导出和引入?
  • 如何保证 _eventMap 队列中事件和 handler 在多个环境下保持一致?

实现上面两个点的核心在于,在任何组件中,我们使用的 emitter 实例都需要是同一个,要做到这一点,需要保证引入和引出的包是同一个,并且如果是分包发布的组件(CDN异步加载、三方组件)这里的问题会比较多。

emitter 实例的 export 和 import

要做到这一点,如果是一个项目内,通常可以通过两种方法去实现:

  • import 同一个 emitter 实例
  • 通过 context 注入到整个上下文中

import 同一个 emitter 实例

emitter.js:实例化 mitt,并且 export,用于其他组件的 import

import mitt from 'mitt';

export default mitt();

componentA.js:从 emitter.js 中引入 mitt 实例,通过引入的实例调用 on 或者 emit 能够保证维护的队列一致

import emitter from '../emitter';

emitter.on('event1', () => {});
emitter.emit('event1', {});

缺点:

  • 需要在组件中明确的有 import 的代码注入
  • 无法满足 npm 组件单独发包或者异步组件的诉求(只能通过 props 传入)

保证 _eventMap 消息队列在多个组件引入模式下保持一致

如果都是本地项目组件,通过 import emitter from './emitter' 就可以做到保持一致,每个组件引入的都是一个 emitter 实例。

场景1:

如果里面存在异步渲染的组件(这里的异步并非是 splitChunk,而是如:unpkg 中 CDN 托管的 umd/cmd/amd 规范的打包结果),可视化搭建中这种场景很常见,是无法直接引入我们项目中的 emitter 实例的

场景2:

如果项目存在某些组件发到私服 npm registry 上,通过 import Comp from '@xx/comp 的方式从 npm 安装,并且引入,包里面也无法引入本地项目的 emitter 实例。

上面两种场景一般如何保证维护同一个消息队列?

通过 props 注入到组件中(层层透传)

比如下面的示例:

emitter.js :emitter 实例化并且导出,与上面示例一样,这里不重复

App.jsx:入口组件注入 props 到组件中,组件通过 props 获取 emitter 实例

<Test1
  name={'test1'}
  dispatchName={'test2_message'}
  eventName={'test1_message'}
  context={emitter}
/>

Test.jsx:通过 props 拿到 emitter 并且使用

const Test = ({ name, dispatchName, eventName, emitter }) => { }

这种模式虽然解决了问题,但是需要子组件显示的去通过 props.emitter 使用 emitter 实例,对组件的侵入性还是比较强

通过 Context 注入(1) - 使用 useContext

Context 也被认为是显式的干扰子组件,但我的理解是比起 props 要 “优雅” 一点

当然 Context 的注入方式有两种,一种是跟组件通过 createContext 创建一个 context,然后 子组件通过 useContext(someContext) 来使用这个 context。这种方式问题很明显,依旧需要显示的引入 someContext

import React, { useEffect, useState, useContext } from 'react';
import myContext from './context';
const Test = ({ name, dispatchName, eventName, appContext }) => {
    const [value, setValue] = useState('');
    const [messageList, setMessageList] = useState([]);

    const { emitter } = useContext(myContext);
    console.log(emitter);
}

通过 Context 注入(2) - 全局定义组件渲染

这里的全局重新定义组件渲染是指在某些场景下,我们渲染组件的方式可能并非直接通过 JSX 渲染,而是通过其他方式注入。

比如某些可配置的中后台项目,将一部分组件定义为插件,而这些插件是“可插拔”的,组件本身是一个数组,组件数组的渲染不通过 JSX。

举例:

有一部分插件,通过配置组装形成一个插件列表,每个插件是一个 React 组件,基本结构如下:

注意,这里的 props 多了一个 appContext,这是一个全局定义的上下文,每次渲染插件的时候,都会注入这个上下文。

const Plugin = props => {
    const { emitter } = props?.appContext;
    console.log(emitter);
    return <h1>Plugin</h1>;
};

插件的列表定义如下:

import Plugin from './Plugin';

export default [
    {
        key: 'p1',
        component: Plugin,
    },
    {
        key: 'p2',
        component: Plugin,
    },
    {
        key: 'p3',
        component: Plugin,
    },
];

问题:应用层如何渲染这个插件列表?

import React from 'react';
import plugins from './plugins';
import emitter from '../emitter';

const appContext = { emitter };

const App = () => {
    const renderPlugin = plugin => {
        const { component, key } = plugin;
        return React.createElement(component, { appContext, key });
    };

    const renderPlugins = () => {
        return plugins.map(plugin => renderPlugin(plugin));
    };

    return <div>{renderPlugins()}</div>;
};

export default App;

从上面代码可以看出,组件的渲染是通过 React.createElement 进行的,在渲染一个插件的时候,通过 createElement 传入 appContext,注入应用级别的上下文。

因此插件 Plugin 能够通过 props.appContext.emitter 获取到 emitter 并且应用。

通过全局变量挂载

这个比较简单,比如直接通过 window.__emitter 访问 emitter 即可

总结

为了保持 eventemitter 事件队列的一致性,可以通过 props 或者是 context 的方式注入,无论哪种方法,都可以解决一致性问题。

这两种方案(通过 props 注入 emitter,或者全局上下文注入) 都有一个同样的问题,要求组件都要强制使用 props 接收 appContext,也会出现组件耦合性

以前做过比价恶心的问题是,同一个业务逻辑组件在不同的应用场景中,有的场景需要 props 接收上下文信息,有的场景则不需要且存在逻辑冲突,然后一个模块分成了三个模块:两个壳+一个业务逻辑

CustomEvent & dispatchEvent & 自定义事件

之前创建一个用户自定义事件,通常会使用 document.createEvent ,但是因为目前诸多属性和方法(如 initCustomEvent)都废弃,createEvent 已经不推荐使用,而是推荐使用 CustomEvent

CustomEvent 是一个构造函数,通过 CustomEvent 创建的事件可以加进行任何自定义的功能。

浏览器自定义事件

通过 addEventListener 可以添加一个自定义事件,比如:

window.addEventListener(key, handler);

CustomEvent 结构

基础语法:

event = new CustomEvent(typeArg, customEventInit);

typeArg 就是 addEventListener 传入的事件 type,string 类型,customEventInit 是一个对象,数据存储在 detail 属性

const evt = new CustomEvent(key, { detail: data });

dispatchEvent 触发自定义事件

如果要触发自定义事件,需要通过 dispatchEvent 方法:

dispatchEvent 方法入参是一个 CustomEvent,因此在调用 dispatchEvent 的时候,首先需要声明 CustomEvent 的实例。

const CustomEvent = window.CustomEvent;
const evt = new CustomEvent(key, { detail: data });
window.dispatchEvent(evt);

基于自定义事件封装 emitter

基于上面 addEventListenerCustomEventdispatchEvent 就可以封装一个 emitter 库

下面的方法实现了 onoffemit 三个基础方法,足够满足 emitter 的基本使用场景

const emitter = {
    on: (key, handler) => {
        window.addEventListener(key, handler);
    },
    off: key => {
        window.removeEventListener(key);
    },
    emit: (key, data) => {
        const CustomEvent = window.CustomEvent;
        const evt = new CustomEvent(key, { detail: data });
        window.dispatchEvent(evt);
    },
};

export default emitter;

优点

基于自定义事件,保证事件队列一致性,不需要通过 props 或者 context 将 emitter 实例传入到模块中,无论是 npm 模块还是 cdn assets 加载的异步模块,都可以直接监听或者触发事件。

缺点

无法直接清空所有监听的自定义事件,只能一个个 removeListener

之前的使用场景中,一般是将所有的 key 还是维护在了 window 下的全局变量中,如 window.__EMITTER_EVENT_KEY__ = []
如果要清空所有的 key,遍历列表然后 remove

只支持 web,无法支持 node(node 有自己默认的 EventEmitter 机制)

CustomEvent 不支持 IE

IE 中要使用 CustomEvent 可以考虑使用 polyfill

(function(){
    try{
        // a : While a window.CustomEvent object exists, it cannot be called as a constructor.
        // b : There is no window.CustomEvent object
        new window.CustomEvent('T');
    }catch(e){
        var CustomEvent = function(event, params){
            params = params || { bubbles: false, cancelable: false, detail: undefined };

            var evt = document.createEvent('CustomEvent');

            evt.initCustomEvent(event, params.bubbles, params.cancelable, params.detail);

            return evt;
        };

        CustomEvent.prototype = window.Event.prototype;

        window.CustomEvent = CustomEvent;
    }
})();

总结

通过浏览器自定义事件与 CustomEvent 在某些场景下,能够更好的实现我们对于 emitter 的需求。

某些搭建场景中,会频繁使用 emitter 在两个组件之间通信,而组件本身又是异步加载,自定义事件在这种场景下,很好的保证了事件队列一致

另外的一个经验是,在单向数据流的项目当中,滥用 emitter 也会造成事件过多难以维护,以及数据流向混乱等问题。

如果只是单纯修改数据,而不是一定要组件之间的通信,通过统一的状态库(如 recoil/redux/mobx)维护数据树才是优先考虑的方案。

文章已经结束啦