Subterranean Flower

ブラウザ上で動く独自のイベントシステムを組む

Author
古都こと
ふよんとえんよぷよぐやま!!!

DOMのイベントや、Node.jsのEventEmitterといった仕組みは非常に便利です。しかしブラウザ上でDOM以外で使おうとするとそういった仕組みはなく、ライブラリを探し回ったり、自作する必要があります。

実はああいうものは単純なものなら簡単に作れるので、ここに記しておきます。

イベントの送出とリスナ登録

ブラウザのDOM操作では、addEventListenerメソッドやonXXXプロパティを使用して「イベント」の扱いが可能になります。Node.jsにおいてもEventEmitterという仕組みが存在し、イベントを扱うことができます。

一方でブラウザ上で独自のデータ構造を扱う場合、イベントという土台が存在しない状況で戦っていくはめになります。ときにはこれが苦しく、イベントの仕組みを独自で構築する必要が出てきます。

解決方法は「ライブラリを探す」と「自分で組む」の2つありますが、この記事では自分で組む方法について取り扱います。

たぶんちゃんとしたライブラリ探したほうがいいのでしょうが、ちょろっと使うだけだと探すのも面倒なので……。

対象ブラウザ

Private Class Fieldを使用するため、対象ブラウザはChrome74以降です。時間が経てば他のブラウザやNode.jsでも動作するようになるでしょう。

古いブラウザで動かしたかったら変数名の前の#を外せばたぶん動きます。

EventDispatcher

イベントを送出する仕組みのことをなんと呼ぶかは色々ありますが、ここではEventDispatcherとします。DOMでは「EventTarget」、Node.jsでは「EventEmitter」と呼ばれているので、区別をつけやすくするためです。

EventDispatcherの仕組みは単純で、仕事は以下の2つだけです:

  • イベントタイプに対してコールバック関数を登録する
  • 登録されたコールバック関数に対してイベントを送出する

この2つさえ実装できればあとは使うだけになります。

これらを実現するために、EventDispatcherには「イベントタイプに対するリスナの配列」を持たせます。これは単なるオブジェクトにしておいて、イベントタイプをキーに、値を配列にすると良いでしょう。

「event_dispatcher.js」というファイルに書き込んでいきましょう。最初の雛形はこんな感じです:

export class EventDispatcher {
  // イベントタイプ -> リスナ配列のマップ
  #listenerMap;

  constructor() {
    this.#listenerMap = {};
  }
}

まずはイベントタイプに対してリスナを登録する仕組みを作ります:

export class EventDispatcher {
  // イベントタイプ -> リスナ配列のマップ
  #listenerMap;

  constructor() {
    this.#listenerMap = {};
  }

  #getListeners = function(eventType) {
    // イベントタイプに対するリスナ配列が存在しなかったら作成する
    if(!this.#listenerMap[eventType]) {
      this.#listenerMap[eventType] = [];
    }

    return this.#listenerMap[eventType];
  }

  // イベントタイプに対してコールバックを登録する
  addEventListener(eventType, callback, options = {}) {
    // リスナ情報を作る
    const listener = {
      once: !!options.once,
      callback
    };

    // イベントのリスナを登録する
    this.#getListeners(eventType).push(listener);
  }
}

イベントタイプに対するリスナ配列が存在しなかったら、まず配列を作成して割り当てます。これでイベントタイプに対する配列が存在することが保証されるので、リスナオブジェクトを作ってpushしておきます。

リスナオブジェクトにはコールバック関数とonceの値を入れておきます。onceがtrueだと1度しか実行されないという想定です。

次にイベントを送出する仕組みを作ります。仕組みとしては2種類考えられます:

  • 引数にはいろいろひっくるめたイベントオブジェクトひとつを渡す(DOM)
  • 引数にはイベントタイプと他の値を別々に渡す(Node.js)

今回はブラウザ上で実行することを想定しているので、DOMに寄せましょうか。

EventDispatcherにdispatchEvent(event)メソッドを実装します:

export class EventDispatcher {
  // イベントタイプ -> リスナ配列のマップ
  #listenerMap;

  constructor() {
    this.#listenerMap = {};
  }

  #getListeners = function(eventType) {
    // イベントタイプに対するリスナ配列が存在しなかったら作成する
    if(!this.#listenerMap[eventType]) {
      this.#listenerMap[eventType] = [];
    }

    return this.#listenerMap[eventType];
  }

  // イベントタイプに対してコールバックを登録する
  addEventListener(eventType, callback, options = {}) {
    // リスナ情報を作る
    const listener = {
      once: !!options.once,
      callback
    };

    // イベントのリスナを登録する
    this.#getListeners(eventType).push(listener);
  }

  // イベントを送出する
  // イベントは
  //     { type: 'test', message: 'Hello' }
  // のようなオブジェクト
  dispatchEvent(event) {
    // イベントタイプに登録されている各リスナを呼び出す
    // 呼び出し時には一応イベントオブジェクトも渡しておく
    const listenerList = this.#getListeners(event.type);
    for(const listener of listenerList) {
      listener.callback(event); // 呼び出す
    }

    // onceリスナを削除する
    const filtered = listenerList.filter((lsn) => !lsn.once);
    this.#listenerMap[event.type] = filtered;
  }
}

dispatchEventは、渡されたイベントオブジェクトのtypeプロパティに応じて送出先を変えます。addEventListenerで登録されたリスナを取得し、各コールバック関数を順番に呼び出します。このとき一応イベントオブジェクトも渡しておきます。

これだけで基本的な仕組みは完成です。

使ってみる

以下のようなスクリプトをHTMLから<script type=”module” src=”main.js”></script>みたいな感じで呼び出します。

import { EventDispatcher } from './event_dispatcher.js';

const dispatcher = new EventDispatcher();

// コールバック関数を登録する
dispatcher.addEventListener('test', () => console.log('Event Received!'));
dispatcher.addEventListener('test', () => console.log('Event Received Once!'), {once: true});

// 3回送ってみる
dispatcher.dispatchEvent({type: 'test'});
dispatcher.dispatchEvent({type: 'test'});
dispatcher.dispatchEvent({type: 'test'});

HTMLファイルを開くとコンソールに何か表示されていると思います。

うまい感じに動きました。

機能追加

removeEventListenerもおそらく必要になることがあるでしょう。さくっと追加してみましょう。

export class EventDispatcher {
  // イベントタイプ -> リスナ配列のマップ
  #listenerMap;

  constructor() {
    this.#listenerMap = {};
  }

  #getListeners = function(eventType) {
    // イベントタイプに対するリスナ配列が存在しなかったら作成する
    if(!this.#listenerMap[eventType]) {
      this.#listenerMap[eventType] = [];
    }

    return this.#listenerMap[eventType];
  }

  // イベントタイプに対してコールバックを登録する
  addEventListener(eventType, callback, options = {}) {
    // リスナ情報を作る
    const listener = {
      once: !!options.once,
      callback
    };

    // イベントのリスナを登録する
    this.#getListeners(eventType).push(listener);
  }

  // コールバック関数をリスナ配列から削除する
  removeEventListener(eventType, callback) {
    const listenerList = this.#getListeners(eventType);
    const index = listenerList.findIndex((lsn) => lsn.callback === callback);
    if(index >= 0) {
      listenerList.splice(index, 1);
    }
  }

  // イベントを送出する
  // イベントは
  //     { type: 'test', message: 'Hello' }
  // のようなオブジェクト
  dispatchEvent(event) {
    // イベントタイプに登録されている各リスナを呼び出す
    // 呼び出し時には一応イベントオブジェクトも渡しておく
    const listenerList = this.#getListeners(event.type);
    for(const listener of listenerList) {
      listener.callback(event); // 呼び出す
    }

    // onceリスナを削除する
    const filtered = listenerList.filter((lsn) => !lsn.once);
    this.#listenerMap[event.type] = filtered;
  }
}

これでコールバック関数を削除することができるようになりました。

サブクラス化

他のクラスでこのEventDispatcherの仕組みを使うのは、EventDispatcherをextendsするだけです。

例えば1秒ごとにメッセージ受信イベントを送出するFakeMessageReceiverクラスを作ってみます:

import { EventDispatcher } from './event_dispatcher.js';

class FakeMessageReceiver extends EventDispatcher {
  constructor() {
    super();

    // 1000ミリ秒ごとに
    // メッセージ受信イベントをdispatchする
    setInterval(() => {
      this.dispatchEvent({
        type: 'message',
        message: 'Hello!'
      });
    }, 1000);
  }
}

const receiver = new FakeMessageReceiver();

const callback = (event) => console.log(event.message);
receiver.addEventListener('message' , callback);

これだけで動きます。

注意点

こういったシステムにとって重要な部分を自作するのは、小規模なプロジェクトのみにとどめておいたほうがいいでしょう。大規模なプロジェクトでは既存の実績のあるライブラリを探しましょう。