从 mitt 与 CustomerEvent 思考 web 环境下 EventEmitter 的设计
说明
EventEmitter
是在项目中经常会使用到的通信工具。
如果在 React 项目中使用,虽然代码可能看起来没有那么纯粹(这里的纯粹指的是代码整洁度相关),但某个需求可能就会需要去使用这个能力
EventEmitter 本身是 nodejs 中的基础消息实现 events
,在 Web 项目中,出现了各种参照 events 相关实现的库,如:
mitt
: https://www.npmjs.com/package/mittevent-emitter
: https://www.npmjs.com/package/event-emitter
上面这种库很多,实现大同小异。
场景举例
上面这个场景中,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
基于上面 addEventListener
、CustomEvent
、dispatchEvent
就可以封装一个 emitter 库
下面的方法实现了 on
、off
、emit
三个基础方法,足够满足 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)维护数据树才是优先考虑的方案。
文章版权:Postbird-There I am , in the world more exciting!
本文链接:http://www.ptbird.cn/event-emitter-mitt-createEvent.html
转载请注明文章原始出处 !