← Runnable Projects

阅读中文版本

Mini EventEmitter Implementation

A from-scratch event emitter with on, once, off, and emit, including listener return values and once-handler auto-removal, covered by unit tests.

beginnerjavascriptunverified

Project

node-event-emitter-mini

Entry point: emitter.mjs

Install

node --version

Run

node emitter.mjs

Test

node --test emitter.test.mjs

Expected output

The demo emits a greeting event twice, prints the listener payload each time, and reports the remaining listener count.

Dependencies

  • node >=20

What it does

A from-scratch event emitter with on, once, off, emit, and listenerCount. The unsubscribe function returned by on makes teardown explicit.

export class MiniEmitter {
  #listeners = new Map();

  on(event, listener) {
    if (!this.#listeners.has(event)) this.#listeners.set(event, new Set());
    this.#listeners.get(event).add(listener);
    return () => this.off(event, listener);
  }

  once(event, listener) {
    const wrapped = (...args) => {
      this.off(event, wrapped);
      return listener(...args);
    };
    return this.on(event, wrapped);
  }

  off(event, listener) {
    const set = this.#listeners.get(event);
    if (set) set.delete(listener);
  }

  emit(event, ...args) {
    const set = this.#listeners.get(event);
    if (!set) return 0;
    const results = [];
    for (const listener of [...set]) results.push(listener(...args));
    return results.length;
  }

  listenerCount(event) {
    return this.#listeners.get(event)?.size ?? 0;
  }
}

const emitter = new MiniEmitter();
emitter.on('greeting', (name) => console.log('hello ' + name));
const fired = emitter.emit('greeting', 'world');
console.log('listeners=' + emitter.listenerCount('greeting') + ' fired=' + fired);
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { MiniEmitter } from './emitter.mjs';

test('emit returns the number of listeners called', () => {
  const e = new MiniEmitter();
  let hits = 0;
  e.on('tick', () => { hits += 1; });
  e.on('tick', () => { hits += 10; });
  const called = e.emit('tick');
  assert.equal(called, 2);
  assert.equal(hits, 11);
});

test('once listeners fire exactly one time', () => {
  const e = new MiniEmitter();
  let count = 0;
  e.once('boot', () => { count += 1; });
  e.emit('boot');
  e.emit('boot');
  assert.equal(count, 1);
  assert.equal(e.listenerCount('boot'), 0);
});

test('the unsubscribe handle removes the listener', () => {
  const e = new MiniEmitter();
  const off = e.on('x', () => {});
  off();
  assert.equal(e.emit('x'), 0);
});

test('removing a listener during emit does not break the loop', () => {
  const e = new MiniEmitter();
  const seen = [];
  e.on('ev', () => seen.push('first'));
  const off = e.on('ev', () => { off(); seen.push('second'); });
  e.emit('ev');
  assert.deepEqual(seen, ['first', 'second']);
});

Run and verify

node --test emitter.test.mjs
node emitter.mjs

Run the tests covering once semantics and mid-emit removal, then the demo emitting a greeting.

Expected Output

The test run should print four passing tests, including the mid-emit removal case that proves the loop over a snapshot of the listener set is safe. The demo’s console.log output should print the greeting payload and then the listener accounting for the single registered listener:

hello world
listeners=1 fired=1

The first line comes from the listener itself (hello plus the emitted argument), and the second line reports that the greeting event still has one listener attached and that the emit call dispatched to exactly one of them — the unsubscribe handle returned by on is what keeps the count honest.

Limitations

No once-with-context, no wildcard or namespace events, and no max-listener guard; it is deliberately minimal.