🌒

mono-event: TypeScript / JavaScriptのイベント処理をシンプルで安全にする

に公開

こんにちは! 普段お仕事でWeb開発をしてるみいぬです。最近、長らく「こんなのがあったらな…」と温めていたイベント管理ライブラリ mono-event を作ってみました。

最近のフロントエンド界隈の話題はReactなどのフレームワーク中心だと思いますので、イベント管理ライブラリはちょっと地味ではありますが... でも、ゲーム開発やライブラリ開発、バックエンド、ツール開発なんかで少し複雑なことをしようとすると、しっかりしたイベント管理の仕組みが途端に欲しくなることも多々。同様のライブラリを作って運用してみてを積み重ねて、十分に枯れてきた内容でもあったので、改めて書き直してみました。

私自身、普段そこそこ規模の大きいWeb上の3Dアプリ開発に携わっていて、初期の頃色々既成のものを触ってみて、

  • 型安全性を確保するのが地味に辛い
  • インテリセンスに現れなくてチームメンバーが認知してない
  • かといってRxJSまで行くとラーニングコストも高く、少数大量な実装ケースではおおげさすぎる印象

と、既存のイベント管理だとシンプルすぎるか、逆に複雑すぎるかで、ちょうどいいものが見つからずにいました。 mono-event は、まさにそんな痒いところに手が届くライブラリを目指して開発しました。

イベント処理の悩み

JavaScript / TypeScriptでのイベント処理といえば EventEmitterEventTarget が定番ですが、特にTypeScriptで書いていると、結構辛いなあ..となるポイントがあるのではと思います。

  • 型安全性の確保が地味に大変: イベントペイロードの型をきちんと書くために、別途分けてinterfaceを実装すれば良いのですが、実装箇所が分散したり、地味に手間がかかります。 勢いで実装されたものが残ってると、積み重ねで実行時エラーを踏むことも... イベントが実装されているクラスを継承するとさらに煩雑になり、気付かぬ間にanyなイベントが増え、型ガードを書くなんてことも...
  • イベント名が文字列リテラル・動的: TypeScriptになってだいぶ改善しましたが、イベント名をtypoしたり、どんなイベントがあるのか把握しづらかったりする傾向があると思ってます。動的に発火させられるメリットは、一方で見つけにくいバグにもなりえます。
  • リスナー管理の煩雑さ: addListener / removeListener (または on / off) をペアで正しく管理するのは意外と面倒で、関数参照を保持するための変数やクラスフィールドが増えがちなのも地味に面倒で嫌なポイントです。
  • どこからでも emit できてしまう問題: これを言ってる人あまりみない気がするのですが、emit (または dispatchEvent) が公開されていると、意図しない場所からイベントが発火されてしまう可能性があり、デバッグが大変になったり、思わぬ副作用をもたらす場合も。これが大規模なアプリだったり、ライブラリコードで、統制がしっかり効いておらず利用側がハック的に使い始めると途端に収拾がつかなくなります。
  • インテリセンスの限界: エディタで補完を見ても、どのイベントにどんなデータが付いてくるのか分かりにくいことが多いです。レビュー時に初めて存在を認知されるというケースもあり。on('') によるイベントは、可視性が低いのではと思っています。

…と、こんな感じで、長年のもうちょっとなんとかならないかな、という思いがあり。 既存のライブラリも色々試したのですが、EventEmitter の系譜を引くものが多く、本当に欲しい機能や使い勝手になかなか出会えず... 自社開発のプロダクトでしたので、mono-eventと似たようなライブラリを書いて何年か運用しています。

mono-eventの推しポイント

細かな話よりも、まずはライブラリの推しポイントを紹介します。

  • C#のイベントに着想を得た、シンプルなAPI・完全な型安全
  • シンプルなAPIでも高機能かつ使いやすさ重視
    • 厳密な発火管理
    • once
    • 同期・非同期への対応
    • 登録・解除も便利に
  • 十分なパフォーマンス
  • インテリセンスにイベント名が現れる(型情報も読める...!)

百聞は一見にしかずということで、使い方例をコードで示します。

シンプルな使い方

const event = mono<string>();
const fn = (value: string) => {
  console.log('EventReceived:', value);
}
event.add(fn);
event.emit('hello!'); // EventReceived: hello!

// removeでイベントを消す
event.remove(fn);

// クリーンナップ関数を使うこともできる
const cleanup = event.add(fn);
cleanup();

一度だけ実行する

// 一度だけ実行する
event.add(fn, {once: true});
event.emit('hello!'); // EventReceived: hello!
event.emit('hello!'); // 何も起きない

クラスのイベントを登録/削除する

const event = mono<string>();
const sample = new Sample();
event.add(sample, sample.trigger);
event.emit('hello!'); // Sample Triggered: hello!

event.remove(sample, sample.trigger);  // <- 削除のために変数で関数を持っておかなくても良い

Asyncなイベントを管理する

const asyncEvent = monoAsync<string>();
const asyncFn = (value: string): Promise<void> => {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log('AsyncEventReceived:', value);
      resolve();
    }, 1000);
  });
};
asyncEvent.add(asyncFn);
await asyncEvent.emit('hello!'); // <- すべてのイベントが実行されるまでawaitで待つことができる
console.log('world');
// AsyncEventReceived: hello!
// world

厳密なディスパッチの管理をする

const {event, emit} = monoRestrict<string>(); // <- eventとemitが分離するため、eventだけを公開すれば安全に発火を管理できる
const fn = (value: string) => {
  console.log('RestrictedEventReceived:', value);
};
event.add(fn);
emit('hello!'); // RestrictedEventReceived: hello!

デコレーターユーティリティ

const event = mono<number>();
event.add(monoDebounce((data) => {
  console.log(`Debounced: ${data}`);
}, 500)); // 最後のイベントから 500ms経過したら発火する

event.add(monoThrottle((data) => {
  console.log(`Throttled: ${data}`);
}, 500)); // 初回発火後、500msごとに1度発火する

このように、実践で困る地味な問題を潰しつつ、全体的にシンプルかつ高機能になるようなライブラリを目指しました。

改めて、mono-event のご紹介

mono-event は、ミニマルで型安全、そしてそこそこ高性能なシングルイベント管理ライブラリです。C# イベントっぽいシンプルさと、厳密なイベント管理がポイントです。

他のイベントライブラリとちょっと違う大事な考え方として、「1イベント1インスタンス」アプローチを採用しています。これは、EventEmitterみたいに1つのインスタンスで複数の名前付きイベントを扱う(そして any の罠にハマりがちな)のではなく、特定のイベントの種類ごとに、専用の mono-event インスタンスを作ります。

// ユーザーログインイベント用のインスタンス
const onUserLogin = mono<{ userId: string; timestamp: Date }>();

// データ更新イベント用は、また別のインスタンス
const onDataUpdated = mono<string[]>();

インスタンス自体がジェネリクス (mono<PayloadType>()) でイベントの型情報を持っています。この作りのおかげで、イベントを emit する時も add する時もコンパイル時に型チェックが効いて、文字列ベースのイベントでよくある実行時エラーを防げます。

主な特徴

  • 完全な型安全性
  • シンプルな API (add, remove, emit)
  • 十分なパフォーマンス
  • 軽量なバンドルサイズ
  • 同期 (mono) / 非同期 (monoAsync) サポート (逐次/並列実行)
  • 発行制御の分離 (monoRestrict, monoRestrictAsync)
  • 柔軟なリスナー管理 (once, コンテキスト指定, 全削除)

インストールと使い方

npmで公開しています。 deno / bun でも動きます。

インストール(Node.js)

npm i mono-event

import / 実行

import {mono} from 'mono-event';

const event = mono<string>();

// Register a listener (returns an unsubscribe function)
const unsubscribe = event.add((msg) => {
  console.log("Received:", msg);
});

event.emit('hello!');

パフォーマンスについて

機能重視のライブラリなので、パフォーマンスを最重視しているというわけではありません。ただし、性質的に基盤的なコードや、update関数など実行回数が多い場所で使用されることも想定し、実行速度は類似ライブラリと比較しても遜色がない程度には仕上げました。メモリ使用量は、1イベント1インスタンスのため、実用的にはやや劣ります。

以下はベンチマークです。どの処理も3回ずつ実行し、平均値を使用しています。

Node.js (v22)

Library Init (ms) Register (Single) (ms) Register (Multi) (ms) Removal (Fwd) (ms) Removal (Bwd) (ms) Removal (Rnd) (ms) Emit (ms) Emit Once (ms) Memory (Empty) (KB/inst) Memory (100 Listeners) (KB/inst) Comprehensive (ms)
mono-event 0.91 4.37 2.05 6.63 108.10 74.42 171.97 0.93 0.14 9.67 1982.91
Restrict - - - - - - 181.61 - - - -
EventEmitter3 0.74 0.99 1.13 216.03 204.67 223.72 237.99 214.39 0.09 7.34 1950.76
mitt 8.07 2.21 1.16 5.87 10.58 8.61 194.76 6.18 0.49 3.20 1878.78
nanoevents 0.86 0.66 0.89 166.37 146.22 151.13 166.97 145.01 0.26 13.49 1345.60
RxJS 0.93 23.00 7.87 8.33 11.72 11.78 367.22 8.13 0.17 52.93 4239.76
Node Events 11.48 1.65 1.66 71.09 1.37 75.55 234.24 139.86 0.28 2.82 1555.88
EventTarget 18.69 3080.67 3.11 155.55 284.60 285.44 315.01 333.83 0.46 8.48 2683.55

Bun

Library Init (ms) Register (Single) (ms) Register (Multi) (ms) Removal (Fwd) (ms) Removal (Bwd) (ms) Removal (Rnd) (ms) Emit (ms) Emit Once (ms) Memory (Empty) (KB/inst) Memory (100 Listeners) (KB/inst) Comprehensive (ms)
mono-event 2.38 4.59 12.91 88.04 0.67 58.90 121.48 0.86 - - 696.29
Restrict - - - - - - 126.51 - - - -
EventEmitter3 1.98 1.43 4.88 178.34 152.54 195.17 132.41 160.25 - - 992.84
mitt 23.35 0.77 2.89 6.35 9.94 9.64 180.11 5.94 - - 2197.11
nanoevents 2.20 1.47 2.91 158.35 152.67 182.38 136.22 160.28 - - 1259.63
RxJS 42.25 12.16 15.99 7.36 11.22 11.83 205.36 6.57 - - 2005.92
Node Events 118.95 1.28 4.19 60.20 0.91 34.83 145.26 75.15 - - 989.63
EventTarget 566.28 13155.77 34.42 127.79 252.76 204.71 1164.29 132.11 - - 12307.24

Deno

Library Init (ms) Register (Single) (ms) Register (Multi) (ms) Removal (Fwd) (ms) Removal (Bwd) (ms) Removal (Rnd) (ms) Emit (ms) Emit Once (ms) Comprehensive (ms)
mono-event 5.33 6.00 6.67 117.33 1.33 64.67 186.67 1.33 2007.33
Restrict - - - - - - 190.67 - -
EventEmitter3 4.00 1.33 3.33 198.00 196.67 220.67 247.33 206.00 2026.00
mitt 66.00 3.33 3.33 6.00 10.67 9.33 211.33 6.67 1908.00
nanoevents 2.67 3.33 2.00 198.00 135.33 161.33 166.00 145.33 1380.67
RxJS 4.00 38.67 16.00 8.00 12.00 12.00 382.67 8.00 4541.33
Node Events 166.67 3.33 4.67 66.00 1.33 58.67 242.00 127.33 1652.00
EventTarget 72.00 25616.67 36.67 1450.67 1444.67 1485.33 2098.00 269.33 10070.67

Memory Usage (Node.js v22)

Library Per Instance (KB) With 10,000 Handlers (KB) 1,000 Events × 100 Instances (KB) 1,000,000 Instances (Total KB)
mono-event 0.09 1,786.47 - 282,977.47
EventEmitter3 0.09 733.39 5,366.01 166,269.33
mitt 0.31 260.34 2,366.6 527,172.73
nanoevents 0.24 1,338.14 19,156.15 405,540.1
RxJS 0.13 6,658.26 73,713.15 767,824.14
Node Events 0.12 277.9 91.51 249,870.11
EventTarget 0.27 1,046.12 - 537,181.27

実行コードはこちら: https://github.com/yukimi-inu/mono-event/tree/main/docs/performance

ベンチマーク結果の分析とトレードオフ

Node.js/Deno環境では他の軽量ライブラリと同等レベル、Bun環境だと予想外に良い結果が出ました(Bunの配列操作が速い?)。
とはいえ、nanoevents のEmit速度や node:events の総合的な速さと軽さはさすがです。ネイティブ実装(やそれに近い最適化)はやはり強い…

mono-event が特に Emit Once で健闘しているのは、主に以下の内部的な工夫によるものです。

  • シンプルな内部構造: リスナーは { h: handler, c: caller } という軽量なオブジェクトで、ただのJavaScript配列 (listenersonceListeners) に格納しています。複雑なデータ構造は使わず、基本的な配列操作に頼ることで、多くのケースで高速に動作します。
  • 効率的な once 処理: once リスナーは専用配列 onceListeners で管理されます。emit 時にこれらのリスナーは実行された後、後続の emit で呼ばれないように効率的に削除されます。(内部的には、実行モードに応じて splice で削除したり、配列コピー後に length = 0 でクリアしたりと最適化しています)。これにより、emit 中に once リスナーを安全かつ高速に処理でき、特に非同期並列時の Emit Once パフォーマンス向上に貢献しています。
  • プロトタイプ共有: add, remove 等のメソッドはプロトタイプオブジェクトで共有し、インスタンスごとのメモリ消費を抑えています。

一方で、メモリ使用量、特に多数のハンドラを登録した場合に他のライブラリ(特に mittnode:events)に劣ることがあるのは、このシンプルな配列構造の特性です。

  • 線形なメモリ増加: リスナーが増えるほど、配列内のオブジェクトも増えるため、メモリ使用量も基本的に比例して増加します。
  • 1イベント1インスタンス: イベント名ごとにリスナーを管理するライブラリと違い、mono-event はインスタンス自体が単一イベント用なので、リスナーデータがメモリ使用量の主な要因となります。

これは意図的なトレードオフです。 多くのアプリケーションでは問題にならないメモリ使用量だと考えていますが、極端に多数のリスナーを扱う場合や、メモリ制約が非常に厳しい環境では考慮が必要かもしれません。

パフォーマンス改善には試行錯誤しましたが、使い勝手や型安全性を最優先した結果、現状はこのバランスが良いと判断しました。機能性、バンドルサイズ、メモリ、速度…すべてを最高にするのは難しいですが、mono-event では特に使いやすさと型安全性を重視した、良いバランスを目指しました。

ユースケースについて

では、どんな時に mono-event が役に立つのでしょうか。 主に以下のような場面を想定しています。

  • 型安全性がとにかく欲しい時: TypeScript を使っているなら、イベント周りもしっかり型で固めておきたいところ。mono<T>() のようにシンプルに書けるので、ラーニングコストもメンテナンスコストも低いのが推しポイントです。
  • シンプルなイベント処理で十分な時: RxJS ほど高機能じゃなくていいけど、基本的なイベントの購読・発行を安全かつシンプルに行いたい場合に。
  • イベントの発行元を制御したい時: monoRestrict を使って、特定のクラスやモジュール内からしか emit できないようにしたい場合。アーキテクチャをきれいに保つのに役立ちます。
  • once リスナーを多用する時: パフォーマンスベンチマークでも見たように、once の処理はかなり高速です。一時的なリスナーをたくさん使う場合に有利です。
  • デバウンス・スロットルを手軽に使いたい時: monoDebounce, monoThrottle が用意されているので、UI イベントの処理などでも気軽に導入できます。

具体的には、以下のようなユースケースが考えられます。

  • UI フレームワーク/ライブラリの内部イベント: コンポーネント間の通信や状態変更通知に。型安全で、monoRestrict を使えばイベントの発行元をコンポーネント内部に限定できます。
  • 状態管理ライブラリ: 状態変更の通知や、非同期アクションの完了通知 (monoRestrictAsync) などに。
  • ゲーム開発: キャラクターのアクション完了、敵の出現、スコア更新など、様々なゲーム内イベントの管理に。パフォーマンスが求められる場面でも対応可能です。
  • モジュール間の疎結合な連携: monoRestrict を活用し、イベントの発行元と購読者を明確に分離することで、モジュール間の依存を減らし、見通しの良い設計を実現します。
  • イベント頻度の制御: ユーザー入力 (検索ボックスへの入力、ウィンドウリサイズ) やセンサーデータなど、頻繁に発生するイベントを monoDebounce や monoThrottle で効率的に処理します。例えば、検索入力が止まってから API を叩く、スクロールイベントを間引いて処理負荷を軽減する、といった用途に活用できます。
  • 初期化・セットアップ処理: アプリケーション起動時に、複数の非同期初期化処理 (monoAsync) の完了を待ってからメイン処理を開始する、といった制御にも利用できます。

実際の使用例をいくつか挙げてみます:

シンプルな使われ方 (クラスプロパティ)

class NumberInput {
  // readonly で外部からの再代入を防ぐ
  readonly onChange = mono<number>();
  readonly onInput = mono<number>();

  private _value: number = 0;

  setValue(newValue: number) {
    if (this._value !== newValue) {
      this._value = newValue;
      // 内部からイベントを発行
      this.onInput.emit(newValue); // 入力途中
      this.onChange.emit(newValue); // 変更確定
    }
  }
}

const input = new NumberInput();
input.onChange.add((value) => {
  console.log('Change:', value);
});
input.onInput.add(monoThrottle((value) => { // スロットルで処理負荷軽減
  console.log('Input:', value);
}, 200));

input.setValue(10);
input.setValue(20);

シンプルなユースケースですが、使いやすく、調べやすく、読みやすいのではと思います。

より堅牢な使われ方 (monoRestrict)

class SecureCounter {
  // 外部にはイベント購読用のインターフェースのみ公開
  readonly onIncrement: MonoRestrictedEvent<number>;
  readonly onDecrement: MonoRestrictedEvent<number>;

  // emit 関数は private で保持
  private readonly _emitIncrement: (value: number) => void;
  private readonly _emitDecrement: (value: number) => void;

  private _count = 0;

  constructor() {
    const { event: onIncrement, emit: emitIncrement } = monoRestrict<number>();
    this.onIncrement = onIncrement;
    this._emitIncrement = emitIncrement;

    const { event: onDecrement, emit: emitDecrement } = monoRestrict<number>();
    this.onDecrement = onDecrement;
    this._emitDecrement = emitDecrement;
  }

  increment() {
    this._count++;
    this._emitIncrement(this._count); // 内部からのみ発行可能
  }

  decrement() {
    this._count--;
    this._emitDecrement(this._count); // 内部からのみ発行可能
  }

  getCount(): number {
    return this._count;
  }
}

const counter = new SecureCounter();
counter.onIncrement.add((count) => console.log(`Incremented to: ${count}`));
// counter.onIncrement.emit(100); // これは型エラー!外部からはemitできない

counter.increment(); // Incremented to: 1

この例では monoRestrict を使うことで、イベントの発行権限をクラス内部に完全に閉じ込めています。少し記述量は増えますが、より安全で堅牢な設計が可能です。非同期版の monoRestrictAsync も同様に使えます。

セットアップ処理 (monoAsync)

class MyApp {
  // アプリケーション全体のセットアップ完了イベント (非同期)
  readonly onApplicationSetup = monoAsync<MyApp>();
  private _isInitialized = false;

  async initialize() {
    if (this._isInitialized) return;
    console.log('Application is initializing...');
    // ... DB接続、設定読み込みなどの非同期初期化処理 ...
    await someAsyncSetup();
    await anotherAsyncSetup();
    this._isInitialized = true;
    console.log('Application initialization complete.');
    // 初期化完了を通知 (自分自身をペイロードとして渡す)
    // この await で全てのリスナーの完了を待つ
    await this.onApplicationSetup.emit(this);
    console.log('All setup listeners finished.');
  }
}

// サブシステムAの初期化
function initializeSubsystemA(app: MyApp) {
  app.onApplicationSetup.add(async (appInstance) => {
    console.log('Subsystem A setting up...');
    await new Promise(res => setTimeout(res, 500)); // 非同期処理
    console.log('Subsystem A setup complete.');
  });
}

// サブシステムBの初期化
function initializeSubsystemB(app: MyApp) {
  app.onApplicationSetup.add(async (appInstance) => {
    console.log('Subsystem B setting up...');
    await new Promise(res => setTimeout(res, 300)); // 非同期処理
    console.log('Subsystem B setup complete.');
  });
}

const app = new MyApp();
initializeSubsystemA(app);
initializeSubsystemB(app);

// アプリケーションの初期化を開始
app.initialize();

// Output:
// Application is initializing...
// Application initialization complete.
// Subsystem A setting up...
// (500ms)
// Subsystem A setup complete.
// Subsystem B setting up...
// (300ms)
// Subsystem B setup complete.
// All setup listeners finished.

このように、中心的なオブジェクト (MyApp) の初期化完了イベントをトリガーとして、各サブシステムが非同期で自身のセットアップを行う、といった協調動作を実現できます。emit が Promise を返すため、全てのサブシステムのセットアップ完了を待機することも容易です。

実装の裏側(ちょっとだけ)

せっかくなので、内部実装で少し工夫した点もご紹介します。

バンドルサイズとメモリ効率のために:

Gzip後1KB台前半という目標のために、いくつかの地味な最適化を入れています。

  • プロトタイプ共有: addremove などの共通メソッドは、各インスタンスが個別に持つのではなく、プロトタイプオブジェクトで共有しています。これでインスタンスごとのメモリ消費とバンドルサイズを削減。 (実装は Object.createObject.assign を使っていて、クラス継承よりちょっと読みにくいかもです)
  • リスナー配列の遅延初期化: 実際にリスナーが add されるまでは、内部の配列 (listeners, onceListeners) は null のままです。無駄なメモリ確保を避けています。
  • コンパクトなリスナー表現: 内部でリスナー情報を保持するオブジェクトは { h: handler, c: caller } のように、プロパティ名を短くしています。これも涙ぐましい最適化です… (ちなみに Symbol を使ったらパフォーマンスが落ちたのでやめました)

パフォーマンスの工夫:

  • Emit時の配列コピー: emit する際は、現在のリスナー配列のコピーを作ってから処理します。これにより、リスナー関数内で addremove をしても、進行中の emit 処理が壊れないようにしています(リエントラント性の確保)。
  • Remove時の検索方向: remove する時は、配列を末尾から検索しています。一時的なリスナーは後から追加されやすいかな?という経験則に基づいた、ささやかな最適化(おまじないレベル)です。

まとめ

mono-event は、TypeScript 環境におけるイベント処理の一般的な課題(型安全性、API の煩雑さ、発行制御など)を解決するため、これまでの知見を詰め込んで、シンプルかつ高機能なライブラリに仕上がってます!(あと、初めてちゃんとOSSとして公開したので色々と勉強になりました)

使い分けの指標

  • EventEmitter や EventTarget の型定義や発行制御に不満がある場合。
  • C# ライクなシンプルなイベント管理をしたい場合。
  • モジュール間で疎結合な通知機構を構築したい場合 (monoRestrict)。
  • デバウンスやスロットルを手軽に導入したい場合。
  • RxJS ほど高機能である必要はないが、基本的なイベント管理を型安全に行いたい場合。

ぜひ mono-event を試してみて、フィードバックをいただけると嬉しいです!

参考

GitHub リポジトリ: https://github.com/yukimi-inu/mono-event
ベンチマーク詳細: https://github.com/yukimi-inu/mono-event/tree/main/docs/performance

Discussion