考虑一下,一个 DOM 事件
const button = document.querySelector("button");
button.addEventListener("click", (event) => /* do something with the event */)
我们为按钮点击添加了一个监听器。我们已经订阅了一个正在发出的事件,并在事件发生时触发一个回调函数。每次我们点击该按钮时,该事件就会被发出,我们的回调函数就会带着该事件一起触发。
在现有的代码库中工作时,您可能需要在某些时候触发自定义事件。不是像点击按钮这样的 DOM 事件,而是假设您想根据某些其他触发器发出事件并让事件做出响应。我们需要一个自定义的事件发射器来做到这一点。
事件发射器是一种模式,它监听一个命名事件,触发一个回调函数,然后用一个值发出该事件。有时这被称为“发布/订阅”模型或监听器。它指的是同一件事。
在 JavaScript 中,它的实现可能类似于这样
let n = 0;
const event = new EventEmitter();
event.subscribe("THUNDER_ON_THE_MOUNTAIN", value => (n = value));
event.emit("THUNDER_ON_THE_MOUNTAIN", 18);
// n: 18
event.emit("THUNDER_ON_THE_MOUNTAIN", 5);
// n: 5
在这个例子中,我们已经订阅了一个名为“THUNDER_ON_THE_MOUNTAIN”
的事件,当该事件被发出时,我们的回调函数value => (n = value)
将被触发。要发出该事件,我们调用emit()
。
这在处理异步代码时很有用,并且需要在某个不与当前模块位于同一位置的地方更新值。
一个非常宏观的例子是 React Redux。Redux 需要一种方法来外部共享其内部存储已更新的信息,以便 React 知道这些值已更改,从而允许它调用setState()
并重新渲染 UI。这是通过事件发射器实现的。Redux 存储有一个 subscribe 函数,它接受一个提供新存储的回调函数,并且在该函数中,调用 React Redux 的组件,该组件使用新的存储值调用
setState()
。您可以查看整个实现 这里。
现在我们的应用程序有两个不同的部分:React UI 和 Redux 存储。两者都不能相互告知已触发的事件。
实现
让我们看看如何构建一个简单的事件发射器。我们将使用一个类,并在该类中跟踪事件
class EventEmitter {
public events: Events;
constructor(events?: Events) {
this.events = events || {};
}
}
事件
我们将定义我们的事件接口。我们将存储一个普通对象,其中每个键将是命名事件,其相应的值是回调函数的数组。
interface Events {
[key: string]: Function[];
}
/**
{
"event": [fn],
"event_two": [fn]
}
*/
我们使用数组是因为每个事件可能有多个订阅者。想象一下,在应用程序中调用element.addEventLister("click")
的次数……可能不止一次。
订阅
现在我们需要处理订阅命名事件。在我们的简单示例中,subscribe()
函数接受两个参数:一个名称和一个要触发的回调函数。
event.subscribe("named event", value => value);
让我们定义该方法,以便我们的类可以接受这两个参数。我们对这些值所做的全部操作就是将它们附加到我们在类内部跟踪的this.events
上。
class EventEmitter {
public events: Events;
constructor(events?: Events) {
this.events = events || {};
}
public subscribe(name: string, cb: Function) {
(this.events[name] || (this.events[name] = [])).push(cb);
}
}
发出
现在我们可以订阅事件了。接下来,我们需要在新的事件发出时触发这些回调函数。当事件发生时,我们将使用我们存储的事件名称(emit("event")
)以及我们希望与回调函数一起传递的任何值(emit("event", value)
)。老实说,我们不想对这些值做任何假设。我们将简单地将第一个参数之后的任何参数传递给回调函数。
class EventEmitter {
public events: Events;
constructor(events?: Events) {
this.events = events || {};
}
public subscribe(name: string, cb: Function) {
(this.events[name] || (this.events[name] = [])).push(cb);
}
public emit(name: string, ...args: any[]): void {
(this.events[name] || []).forEach(fn => fn(...args));
}
}
由于我们知道要发出哪个事件,因此我们可以使用 JavaScript 的对象括号语法(即this.events[name]
)查找它。这给了我们已存储的回调函数数组,以便我们可以遍历每个函数并将我们传递的所有值应用于它。
取消订阅
到目前为止,我们已经解决了主要的部分。我们可以订阅事件并发出该事件。这是最重要的部分。
现在我们需要能够取消订阅事件。
我们已经在subscribe()
函数中有了事件名称和回调函数。由于我们可能对任何一个事件都有多个订阅者,因此我们希望分别删除回调函数
subscribe(name: string, cb: Function) {
(this.events[name] || (this.events[name] = [])).push(cb);
return {
unsubscribe: () =>
this.events[name] && this.events[name].splice(this.events[name].indexOf(cb) >>> 0, 1)
};
}
这将返回一个带有unsubscribe
方法的对象。我们使用箭头函数(() =>
)来获取传递给对象父级的参数的范围。在这个函数中,我们将找到传递给父级的回调函数的索引,并使用按位运算符(>>>
)。按位运算符有悠久而复杂的历史(您可以详细了解)。在这里使用一个可以确保每次我们对回调函数数组调用splice()
时始终获得一个真实的数字,即使indexOf()
没有返回数字。
无论如何,它对我们可用,我们可以像这样使用它
const subscription = event.subscribe("event", value => value);
subscription.unsubscribe();
现在我们已经退出了该特定订阅,而所有其他订阅可以继续运行。
现在全部放在一起!
有时将我们讨论的所有小部分放在一起以了解它们之间的关系会有所帮助。
interface Events {
[key: string]: Function[];
}
export class EventEmitter {
public events: Events;
constructor(events?: Events) {
this.events = events || {};
}
public subscribe(name: string, cb: Function) {
(this.events[name] || (this.events[name] = [])).push(cb);
return {
unsubscribe: () =>
this.events[name] && this.events[name].splice(this.events[name].indexOf(cb) >>> 0, 1)
};
}
public emit(name: string, ...args: any[]): void {
(this.events[name] || []).forEach(fn => fn(...args));
}
}
演示
在这个例子中,我们做了一些事情。首先,我们在另一个事件回调函数中使用了事件发射器。在这种情况下,事件发射器用于清理一些逻辑。我们正在选择 GitHub 上的一个存储库,获取有关它的详细信息,缓存这些详细信息,并更新 DOM 以反映这些详细信息。与其将所有这些都放在一个地方,我们是在订阅回调函数中从网络或缓存中获取结果并更新结果。我们能够做到这一点,因为我们在发出事件时向回调函数提供了列表中的随机存储库
现在让我们考虑一些不太牵强的事情。在整个应用程序中,我们可能有很多应用程序状态由我们是否已登录驱动,并且我们可能希望多个订阅者处理用户尝试注销的事实。由于我们已使用false
发出事件,因此每个订阅者都可以使用该值,以及我们是否需要重定向页面、删除 cookie 或禁用表单。
const events = new EventEmitter();
events.emit("authentication", false);
events.subscribe("authentication", isLoggedIn => {
buttonEl.setAttribute("disabled", !isLogged);
});
events.subscribe("authentication", isLoggedIn => {
window.location.replace(!isLoggedIn ? "/login" : "");
});
events.subscribe("authentication", isLoggedIn => {
!isLoggedIn && cookies.remove("auth_token");
});
注意事项
与任何事物一样,在将发射器投入使用时,需要考虑一些事项。
- 我们需要在我们的
emit()
函数中使用forEach
或map
来确保如果我们在该回调函数中,我们正在创建新的订阅或取消订阅订阅。 - 在我们的
EventEmitter
类的新的实例被实例化时,我们可以按照我们的Events
接口传递预定义的事件,但我还没有真正找到这种用法。 - 我们不需要为此使用类,使用与否很大程度上是个人喜好。我个人使用它是因为它使事件存储的位置非常清楚。
只要我们谈论实用性,我们就可以用一个函数来完成所有这些操作
function emitter(e?: Events) {
let events: Events = e || {};
return {
events,
subscribe: (name: string, cb: Function) => {
(events[name] || (events[name] = [])).push(cb);
return {
unsubscribe: () => {
events[name] && events[name].splice(events[name].indexOf(cb) >>> 0, 1);
}
};
},
emit: (name: string, ...args: any[]) => {
(events[name] || []).forEach(fn => fn(...args));
}
};
}
底线:类只是一个偏好。在对象中存储事件也是一个偏好。我们也可以很容易地使用Map()
代替。使用最让你舒服的方式。
我写这篇文章有两个原因。首先,我一直觉得我理解发射器(emitters)的概念,但从头开始写一个发射器却从未想过自己能做到,但现在我知道我能做到——我希望你也能有这种感觉!其次,发射器在求职面试中频繁出现。我发现很难在那种情况下连贯地表达,像这样写下来可以更容易地抓住主要思想并阐明关键点。
如果你想获取代码并进行尝试,我已经将所有内容都放在了GitHub 仓库中。当然,如果有什么问题,欢迎在评论区提出!
我刚刚设置了一个 React 应用,它通过 WebSockets 连接到后端。这可能是一种构建两者之间类型安全接口的非常好的方法。这样,DOM 事件就可以像网络事件一样处理:“online”、“click”、“user-connected”。它们都只是要发出的事件,对吧?
感谢你的文章,Charles!
为什么不使用
CustomEvent
?好问题,主要原因是,这只是一个常用的工作模式,并且它不与任何特定环境绑定。因此它可以在浏览器和服务器中都工作。
CustomEvent 是 DOM 的东西。这里的发布/订阅模型可以在任何地方使用。
嗨,Charles,
这是一篇很棒的文章!但是我仍然没有理解发射器模式。对我来说,getRepo 看起来像一个普通的函数(https://codepen.io/kimek/pen/RdOjbX)。在那里使用发射器的优势是什么?
在身份验证场景中——发射器是否只是在那里重写了身份验证功能?我得到了监听器和观察者,但没有发射器 :(
发射器在说,“嘿,现在做这件事”,以便订阅者可以回调它。在身份验证示例中,我们应用的许多部分都可以订阅到
logout
事件并处理它们自己的逻辑。因此,当我们发出logout
事件时,每个部分都会自行处理。这是一种向每个订阅者广播某些事情已发生的方式。我并不是想无礼,但是,你写了一篇文章来解释一些基本的东西,比如“事件发射器”,很可能是针对“TypeScript”初学者的!
一点也不无礼,这是一个合理的批评。TypeScript 在这里真正使用的唯一东西是注释类保存事件的形状,并显示可以从该类使用哪些方法。但鉴于 TypeScript 的普及度大幅上升,也许 CSS-Tricks 应该增加一篇 TypeScript 入门文章?
不错的文章!
也许我遗漏了什么,但是,为了保持简单,并遵循 SRP 原则,你认为每个发射器只使用一种事件类型怎么样?
我的意思是,避免使用事件名称,而是在基于事件名称的字典位置简单地存储一个函数数组。
想法?
很棒,非常像jetemit
链接
npmjs.com/package/jetemit
事件总线的精彩介绍!希望能看到一篇后续文章,介绍你最喜欢的事件/发布订阅技术的用例。
由于
EventTarget
和EventEmitter
分别是原生 DOM 和 Node.js 的概念/API,你是否认为将它们抽象出来以更好地利用你工作环境中已经存在的功能是有价值的?