理解事件发射器

Avatar of Charles Peters
Charles Peters

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

考虑一下,一个 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()函数中使用forEachmap来确保如果我们在该回调函数中,我们正在创建新的订阅或取消订阅订阅。
  • 在我们的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 仓库中。当然,如果有什么问题,欢迎在评论区提出!