sth

A thing that is thought to be important or worth taking notice of.

高级事件

订阅和触发事件想必都是家常便饭,在 JS 开发中,相信大家都用过 node.js 的 events 库吧,或至少是类似的。这里给大家介绍一个更为高级的事件托管服务,它能提供更详尽的时机、可用性、频率等的控制,以及诸如统一代理、上下文存储、溯源等增强功能。

开始

npm i datasense

首先,需要在 dependencies 中加入 DataSense 库(本文以 v1.2.0 为例)。然后,在代码中引入以下类型。

import { EventController } from 'datasense';

随后,便可以创建一个或多个事件控制器,每个事件控制器均可管理多个事件服务,这些事件控制器可以依据需要绑定到对应的组件成员上,或者库的 export 对象上。创建事件控制器的方法非常简单,只需初始对应实例即可,如下。

let events = new EventController();

如同大家熟知的 onemit 一样,EventController 在监听和触发事件方面,也是非常简单。

events.on("click", ev => {
  console.info("Clicked!", ev);
});

其中,成员方法 on 的第1个参数为事件名,是一个可根据业务随意自定义的字符串;而第2个参数自然就是一个回调啦。该方法会返回一个对象,包含 dispose() 方法,执行后即解除此事件注册。

let evRegResult = events.on("click", ev => {
  console.info("Clicked!", ev);
});

// 当需要解除上述事件注册时,可以执行其销毁方法。
evRegResult.Dispose();

如果你需要触发其上的某个事件,直接 fire 即可,并传入该事件名和对应的参数。

events.fire("click", { // ev
  // 这是一个对象,包含你想包含的内容。
  // 他们将作为前面一段代码中的 ev 参数。
});

就是这么简单!

当然,如果就只有这么简单,那我们为何还需要这样一个库?

监听

让我们一点点来看看它到底还有什么能耐吧,先从监听开始 。

如果我告诉你,它还有 once 用于一次性监听,那似乎也提不起你什么兴趣。如果我又告诉你,在成员方法 on 中,其实还大有乾坤,或许你还可以来了解一下,说不定会逐步打动你的内心。

试着在 on 的第2个参数,也就是那个回调中,给回调那个方法的入参,在加个参数(如下示例中的 listenerController)看看?

events.on("click", (ev, listenerController) => {
  if (!listenerController) throw "没有这个参数!";
});

运行后发现,没有抛出异常,也就是说:原来这个参数是有值的!而且细看会发现,它是个对象,其内的属性和方法如下。

另外还有几个方法,我们稍后再介绍。以上内容可能有一点多,有一些也比较类似,以及可能大家会疑惑这有什么用呢?我们来举个简单一点的例子吧:我们希望输出事件触发的具体时间,并在触发了 3 次后,取消订阅。这里可能会用到它里面的一个我还没提及的 dispose() 成员方法,这个方法可以销毁(去除)该注册的事件。

events.on("click", (ev, listenerController) => {
  // 你可以通过参数 listenerController 访问本次事件相关的信息,
  // 例如,你可以获得本轮触发的时间。
  console.info(`Clicked at ${listenerController.fireDate.toLocaleTimeString()}!`, ev);

  // 这些信息当然不局限于 log 出来,也包括可以做一些正事。
  // 例如,执行3次后,我们就执行销毁操作,即去除该事件。
  if (listenerController.count > 3)
    listener.dispose();
});

回调选项

除了上述回调入参可以增加一些访问能力外,on 成员方法本身也支持不只这 2 个参数,它的完整参数列表如下。

  1. key (字符串) 事件名。
  2. h (函数) 回调。
  3. thisArg (对象) 回调所需绑定的 this
  4. options (对象) 选项。
  5. disposableArray (对象) 需要绑定的销毁列表对象,值需要是此库的 DisposableArray 对象。

其中,thisArg 很好理解,大家在许多地方都有用过,关键是那个 options(选项)是个什么。其实,这个主要是用于控制本次注册的事件,需要在什么场合下才生效,以及执行的时机点是什么,因为有些时候,我们可能会有如下要求。

而这个参数就是为了满足这些需求的。其类型为对象,具体属性如下,全部为选填。

简单看来,前2个属性用于限定事件何时失效(自动解除绑定),后5个属性用于限定是否要延迟、去抖、截流等。我们来举几个例子吧,先看一个限定执行次数的,以下示例和前面那个示例(只执行3次即销毁)是等效的。

events.on("click", (ev, listenerController) => {
  console.info(`Clicked at ${listenerController.fireDate.toLocaleTimeString()}!`, ev);
}, null, {
  invalid: 3  // 执行3次后即本次注册的事件回调失效(取消绑定)。
});

再来一个示例,如下,我们限定某一事件会在等待 0.5s 后执行,如果期间又触发了执行,则抛弃上一次的执行,并重置等待。此场景很常见吧?没错,搜索框里的去抖!

events.on("click", (ev, listenerController) => {
  // 执行某操作。
}, null, {
  delay: 500,  // 延迟 500ms。
  mergeMode: "debounce"  // 只会响应延迟期间最后一次触发。
});

都说了去抖,那截流呢?

events.on("click", (ev, listenerController) => {
  // 执行某操作。
}, null, {
  span: 30000,  // 30s 后重置,即本选项设定的有效期为 30s。
  maxCount: 1 // 有效期内只能触发 1 次。
});

再演示一下模拟双击操作吧:通过在一个较短的有效期(由 span 指定)设定 minCountmaxCount 都为 2 的方式,限定事件(通常为点击事件)被触发 2 次才执行,从而模拟了双击。暴击其实也是类似,只是最小值会设定的比较高,最大值可能不设。

events.on("click", (ev, listenerController) => {
  // 执行某操作。
}, null, {
  span: 300,  // 0.3s 为有效期,过了有效期重新计算。
  minCount: 2,  // 有效期内需至少触发 2 次才执行。
  maxCount: 2  // 有效期内触发超过 2 次时不再执行。
});

上下文临时存储

有时候,我们在注册的事件回调里,会需要临时存储一些数据,这些数据可能会在后续触发时读取甚至修改。通常,我们可以设定一个外部对象,然后将这些数据存储在这些对象上。这个场景可能非常的业务化,但好消息是,在这个事件控制器里,特别针对此做了支持,使得你可以存储许多这样的变量,并分别给定一个名称,随后即可在任意时候读取它们的最新值,或是进行修改或删除,而且用起来非常简单,如下示例。

events.on("click", (ev, listenerController) => {
  // 假设我们需要读取一个被命名为 latestValue 的变量。
  // 读取前,如果需要,也可以先判断是否有。如果没有,直接获取会返回 undefined。
  if (listenerController.hasStoreData("latestValue"))
    console.info("Latest event argument.", listenerController.getStoreData("latestValue"));

  // 可以在任意时候进行变量的存储,下次即刻读取到。
  listenerController.setStoreData("latestValue", ev);

  // 也可以删除。
  listenerController.removeStoreData("latestValue");
});

当然,这些内容不会和其它事件进行共享。

触发

以上都是如何注册事件,但注册的事件需要有人触发才会被执行啊,因此我们再来看看事件都是如何触发的。哦,对,前面已经介绍过了,调用 fire 成员方法,所以我们这里主要是介绍一些额外的功能。可能你已经想到了,肯定是这个方法除了事件名和事件参数 ev 以外,还有别的参数嘛。对的!具体如下。

  1. key (字符串) 事件名。
  2. ev (字符串) 事件传入参数。
  3. message (字符串 | 对象) 当为字符串时,表示消息,该消息会作为 on 方法里回调第 2 个入参的 message 属性(即前述示例中的 listenerController.message)返回;当为对象时,此参数表示选项。
  4. delay (整数 | 布尔值) 用于设定事件触发的延迟。和监听选项里的延迟不同的是,监听选项的延迟适用于事件触发后延迟执行,而此处用于设定何时触发,即如果两者皆设置,则会出现延迟叠加现象。如果为数字,则为延迟执行的毫秒数;如果为 true,则为当前线程内容执行完后便即刻执行;如果为 false 或为空,则为立即执行。

其中,当参数 message 为对象时,如上所属,即为触发选项,其属性如下,皆为选填。

如下示例,延迟 0.2s 后触发 click 事件,并包含一些附加信息。

events.fire("click", { // ev
  // 这是一个对象,包含你想包含的内容。
  // 他们将作为前面一段代码中的 ev 参数。
}, {
  source: "somewhere",
  message: "Hello!",
  addition: {
    name: "abc",
    value: "defg"
  }
}, 200);

监听者模式

有的时候,我们可能希望某些事件的触发是掌握在触发源处,而不希望暴露给需要监听的地方,因为监听者只需监听,这也就意味着,我们不希望将完整的 EventController 实例暴露出来。那么,这可怎么办呢?简单!如下。

let eventObs = events.createObservable();

这么一来,eventObs 对象只含注册事件能力,而无事件触发能力(即无 fire 等方法)。

甚至不光如此,这样生成的对象,还拥有一项非常有利于模块化管理的好处,那就是:通过调用其 dispose() 成员方法,可以随时销毁它,销毁后,通过该对象注册的所有事件也会被一并销毁。而此对象的销毁,并不会导致原始的事件控制器整体无效。此项特性非常有助于,在具有生命周期管理的组件内,对所有依赖注册的事件,进行简单有效的统一管理。

有时使用者有自己的规范,这些规范中,事件名(key 值)可能与目标事件名不一样,这也没关系,可以通过 createMappedObservable(mapKey) 成员方法,来创建一个具有事件名映射的注册机。

另外,还可以针对单一事件(即指定的 key)生成只读的注册机。

let clickEventObs = events.createSingleObservable("click");

需要留意的是,其对应的 on 方法,没有第一个参数 key,因为一定是 click(示例)。如果希望创建的单一事件托管服务,并不只是包含事件注册,也希望包含触发,那么可以调用 createSingle(key) 成员方法实现。

订阅

另外,如果你熟悉观察者模式里的订阅,也可以采用标准订阅的方式,来获取事件,而不是通过注册。

events.subscribeSingle("click", ev => {
  // 执行你希望执行的操作。
});

对于只含事件注册能力的实例,也是支持的。

eventObs.subscribeSingle("click", ev => {
  // 执行你希望执行的操作。
});

对于单一事件注册机,也是类似,但方法名和参数列表略有不同。

clickEventObs.subscribe(ev => {
  // 执行你希望执行的操作。
});

所有通过订阅返回的对象,都支持通过 dispose() 方法,来取消订阅。并且,如同大多数方法,订阅方法后面也支持再增加一个 thisArg 参数,来指定回调的 this 绑定。

与 DOM 集成

这些功能,想在 DOM 事件中使用?没关系,可以使用类似如下的辅助方法搞定!

let element = document.getByElement("#someone");
let obs = EventObservable.createForElement(element, "click");
obs.on(ev => {
    // Do something.
});

怎么样,是不是很方便?


Kingcean Tuan (@kingcean)

March 1st, 2021 AD.

Keywords: js; event; dataflow; task; observable; message.

See also

(cc) Kingcean Tuan, 2021.