[译] 理解 Event Emitters

244 阅读9分钟

考虑一个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()

当使用异步代码并且需要在与当前模块不在同一位置的某个位置更新值时,这很有用。

一个真正的宏级别9macro-level)的例子是React Redux。 Redux需要一种外部共享其内部存储已更新的方法,以便React知道这些值已更改,从而允许它调用setState()并重新呈现UI。 这是通过事件发射器发生的。 Redux存储具有一个订阅功能,它接受提供新存储的回调,并在该函数中调用React Redux的组件,该组件使用新存储值调用setState()。 您可以在此处浏览整个实现。

现在,我们的应用程序包含两个不同的部分:React UI和Redux存储。 任何人都无法将已触发的事件告诉对方。

实现

让我们看一下构建一个简单的事件发射器。 我们将使用一个类,并在该类中跟踪事件:

class EventEmitter {
  public events: Events;
  constructor(events?: Events) {
    this.events = events || {};
  }
}

Events

我们将定义事件(events)接口。 我们将存储一个普通的对象,其中每个键将是命名事件,其各自的值是回调函数的数组。

interface Events {
  [key: string]: Function[];
}

/**
{
  "event": [fn],
  "event_two": [fn]
}
*/

现在我们需要处理一个命名事件。 在我们的简单示例中,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

现在我们可以订阅事件了。 接下来,我们需要在发出新事件时触发这些回调。 发生这种情况时,我们将使用要存储的事件名称(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])进行查找。 这为我们提供了已存储的回调数组,因此我们可以遍历每个回调并应用传递的所有值。

Unsubscribing

到目前为止,我们已经解决了主要问题。 我们可以订阅一个事件并发出该事件。 那是很大的东西。

现在,我们需要能够取消订阅事件。

我们已经在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)方法的对象。 我们使用箭头函数(() =>)来获取传递给对象父对象的此参数的范围。 在此函数中,我们将找到传递给父级的回调的索引,并使用按位运算符(>>>)。 按位运算符具有悠久而复杂的历史(您可以阅读所有内容)。 即使在indexOf()不返回数字的情况下,在此处使用一个也可以确保每次在回调数组上调用splice()时,始终获得实数。

无论如何,它可供我们使用,我们可以像这样使用它:

const subscription = event.subscribe("event", value => value);

subscription.unsubscribe();

现在,我们不再使用该特定的订阅,而所有其他订阅可以继续进行。

All Together Now!

有时,将我们讨论过的所有小片段放在一起,以了解它们之间的关系是有帮助的。

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));
  }
}

Demo

<div class="Wrapper">
  <div id="create_root"></div>
  <button>Fetch New Repo</button>
</div>
*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
  font-family: system-ui;
}

button {
  margin: auto;
  display: block;
  padding: 12px 24px;
  font-size: 16px;
  font-family: inherit;
  font-weight: 700;
  background: #147aab;
  color: white;
  border: 0;
  border-radius: 8px;
}

.Wrapper {
  margin: 1rem auto;
  max-width: 600px;
  padding: 8px;
}

.Card {
  margin: 0 0 16px;
  padding: 16px;
  border: 1px solid #c5c5c5;
  display: flex;
}

.Card aside {
  flex: 1;
}

.Card header {
  flex: 7;
}

.Card h2 {
  margin-bottom: 4px;
  font-weight: 700;
  font-size: 20px;
}

.Card__meta {
  color: #4f4f4f;
  font-size: 12px;
}

.Card--error {
  background: #d23923;
  color: white;
  font-weight: 700;
  border: 0;
}

.Avatar {
  display: block;
  margin-right: 16px;
  border-radius: 50%;
}
interface IEvents {
  [key: string]: Function[];
}

class EventEmitter {
  public events: IEvents;
  constructor(events?: IEvents) {
    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));
  }
}

class DOMTree {
  private repo: any = {};
  private node: string = "";
  private root: HTMLElement;

  constructor(repo?: any, rootElement: HTMLElement) {
    if (repo) {
      this.repo = repo;
    }
    this.root = rootElement;
  }

  public create(repo): void {
    this.node = `
      <div class="Card">
        <aside>
          <img width="48" height="48" class="Avatar" src="${
            repo.owner.avatar_url
          }" alt="Profile picture for ${repo.owner.login}" />
        </aside>
        <header>
          <h2>${repo.full_name}</h2>
          <span class="Card__meta">${repo.description}</span>
        </header>
      </div>
  `
  }

  public insert(repo: any) {
    this.create(repo);
    this.root.innerHTML = this.node;
  }
}

interface IMemoizeCache<T> {
  [key: string]: T;
}

function asyncMemoize<T>(fn: Promise<T>): Function {
  let cache: IMemoizeCache<T> = {};

  return async (...args: any[]): T => {
    console.log("Cache", Object.keys(cache));

    let n = args[0];

    if (n in cache) {
      return cache[n];
    } else {
      let result = await fn(n);
      cache[n] = result;
      return result;
    }
  };
}


const events = new EventEmitter();
const tree = new DOMTree({}, document.querySelector('#create_root'));

const REPOS: string[] = [
  "charliewilco/obsidian",
  "charliewilco/react-branches",
  "charliewilco/downwrite",
  "charliewilco/cyclops",
  "jaredpalmer/formik",
  "prettier/prettier",
  "github/fetch",
  "WordPress/gutenberg",
  "facebook/react",
  "atom/atom",
  "microsoft/vscode",
  "facebook/relay",
  "apollographql/react-apollo",
  "zeit/hyper",
  "mobxjs/mobx",
  "vuejs/vue"
]

async function getRepository(name: string) {
  // 原文 这里的await其实是多余的。会增加多余的等待时间。
  // return await fetch(`https://api.github.com/repos/${name}`, { mode: "cors" }).then(res => res.json()).then(repo => repo);
  
  // 优化后
  return fetch(`https://api.github.com/repos/${name}`, { mode: "cors" }).then(res => res.json()).then(repo => repo);
}

const memoizedGetDetails = asyncMemoize(getRepository);

events.subscribe("getRepo", async function(repo: string): Promise<void> {
  const result = await memoizedGetDetails(repo);
  tree.insert(result);
});

events.emit("getRepo", REPOS[getRandomInt(REPOS.length)]);

function getRandomInt(max) {
  return Math.floor(Math.random() * Math.floor(max));
}

document.querySelector("button").addEventListener("click", function() {
  const index = getRandomInt(REPOS.length);
  events.emit("getRepo", REPOS[index]);
})

在此示例中,我们正在做一些事情。 首先,我们在另一个事件回调中使用事件发射器。 在这种情况下,将使用事件发射器来清理一些逻辑。 我们正在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");
});

陷阱(Gotchas)

像其他任何事情一样,在使发射器工作时要考虑一些事项。

  • 我们需要在我们的generate()函数中使用forEachmap,以确保我们正在创建新的订阅,或者如果我们在该回调中,则取消订阅。

  • 当实例化我们的EventEmitter类的新实例时,我们可以在事件接口之后传递预定义的事件,但是我还没有真正找到一个用例。

  • 我们不需要为此使用类,无论您是否使用一个类,在很大程度上都是个人喜好。 我个人使用一个,因为它可以很清楚地将事件存储在何处。

只要我们讲实用性,我们就可以使用一个函数来完成所有这些工作:

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()来代替。 选择让你最舒服的方式。


我决定写这篇文章有两个原因。 首先,我始终觉得自己理解发射器的概念很好,但是从头开始写一个从来都不是我以为可以做的事,但是现在我知道了,我希望您现在也有同样的感觉! 其次,在求职面试中,应聘者经常出现。 我发现在这种情况下要进行连贯的讨论真的很困难,并且像这样将其记下来,可以更轻松地捕捉主要思想并阐明关键点。

如果您想提取代码并使用它,我已经在GitHub存储库中进行了所有设置。 而且,如果出现任何问题,当然可以在评论中问我一些问题!