Самостоятельная реализация EventEmitter-паттерна

EventEmitter — это паттерн проектирования, позволяющий объектам взаимодействовать через события. Объект, реализующий EventEmitter, может излучать события (emit), а другие объекты могут подписываться на эти события (on) и получать уведомления при их возникновении. Этот подход повышает модульность кода и упрощает обработку асинхронных действий.

В библиотеке Cannon.js EventEmitter используется для уведомления о событиях физического движка, таких как столкновения тел, изменения состояния, окончания симуляции. Однако собственную реализацию EventEmitter можно создать с нуля, чтобы глубже понять механизм работы и оптимизировать её под конкретные задачи.


Структура простой реализации

Минимальная структура EventEmitter включает три базовых метода:

  1. on(eventName, listener) — подписка на событие.
  2. off(eventName, listener) — удаление конкретного слушателя.
  3. emit(eventName, …args) — генерация события с передачей аргументов слушателям.
class EventEmitter {
    constructor() {
        this.events = {};
    }

    on(eventName, listener) {
        if (!this.events[eventName]) {
            this.events[eventName] = [];
        }
        this.events[eventName].push(listener);
    }

    off(eventName, listener) {
        if (!this.events[eventName]) return;
        this.events[eventName] = this.events[eventName].filter(l => l !== listener);
    }

    emit(eventName, ...args) {
        if (!this.events[eventName]) return;
        this.events[eventName].forEach(listener => listener(...args));
    }
}

Ключевые моменты:

  • this.events хранит массив слушателей для каждого события.
  • Метод on создаёт массив, если его ещё нет, и добавляет функцию-слушатель.
  • Метод off удаляет конкретную функцию, оставляя другие подписки нетронутыми.
  • Метод emit вызывает все слушатели с переданными аргументами.

Поддержка одноразовых событий

Иногда необходимо, чтобы событие вызывалось только один раз. Для этого реализуется метод once:

once(eventName, listener) {
    const wrapper = (...args) => {
        listener(...args);
        this.off(eventName, wrapper);
    };
    this.on(eventName, wrapper);
}

Особенности реализации:

  • Используется обёртка wrapper, которая вызывает оригинальный слушатель.
  • После первого вызова wrapper удаляется из списка подписчиков, предотвращая повторное срабатывание.

Передача контекста и аргументов

Для интеграции с объектами Cannon.js часто нужно передавать контекст или дополнительные параметры при вызове слушателя. Это реализуется через замыкания или метод bind:

class EventEmitterWithContext extends EventEmitter {
    emit(eventName, ...args) {
        if (!this.events[eventName]) return;
        this.events[eventName].forEach(listener => listener.apply(this, args));
    }
}
  • Использование apply(this, args) позволяет слушателям использовать this как ссылку на объект-эмиттер.
  • Это особенно полезно при работе с физическими телами, чтобы слушатели имели доступ к их состоянию.

Удаление всех слушателей

Для управления ресурсами и предотвращения утечек памяти полезно уметь очищать все подписки:

removeAllListeners(eventName) {
    if (eventName) {
        delete this.events[eventName];
    } else {
        this.events = {};
    }
}
  • Если указано имя события, удаляются только его подписчики.
  • Если аргумент отсутствует, очищаются все события.

Реализация приоритетов слушателей

Иногда требуется, чтобы определённые обработчики вызывались раньше других. Для этого можно хранить слушателей в виде объектов с приоритетом:

on(eventName, listener, priority = 0) {
    if (!this.events[eventName]) this.events[eventName] = [];
    this.events[eventName].push({ listener, priority });
    this.events[eventName].sort((a, b) => b.priority - a.priority);
}

emit(eventName, ...args) {
    if (!this.events[eventName]) return;
    this.events[eventName].forEach(obj => obj.listener(...args));
}
  • Более высокий приоритет вызывает обработчик раньше.
  • Сортировка массива после добавления нового слушателя гарантирует правильный порядок вызова.

Интеграция с Cannon.js

В Cannon.js объекты Body, World, Constraint могут использовать EventEmitter для уведомления о физических событиях. С собственной реализацией:

class Body extends EventEmitter {
    constructor(mass = 1) {
        super();
        this.mass = mass;
        this.velocity = { x: 0, y: 0, z: 0 };
    }

    applyForce(force) {
        this.velocity.x += force.x / this.mass;
        this.velocity.y += force.y / this.mass;
        this.velocity.z += force.z / this.mass;
        this.emit('forceApplied', force);
    }
}

const ball = new Body(2);
ball.on('forceApplied', (force) => {
    console.log('Применена сила:', force);
});
ball.applyForce({ x: 10, y: 0, z: 0 });
  • emit('forceApplied', force) уведомляет всех слушателей о воздействии силы.
  • Такой подход позволяет добавлять кастомные обработчики для физической симуляции без изменения ядра движка.

Рекомендации по оптимизации

  1. Использовать слабые ссылки или WeakMap для хранения слушателей, если объекты часто создаются и уничтожаются.
  2. Минимизировать создание новых функций при вызове once, чтобы снижать нагрузку на сборщик мусора.
  3. Сортировка по приоритету полезна для сложных симуляций, но для большинства задач достаточно стандартного порядка добавления.
  4. Следить за удалением слушателей при уничтожении объектов Cannon.js, чтобы предотвратить утечки памяти при длительных симуляциях.

Эта реализация EventEmitter обеспечивает полное управление событиями, легко расширяется и полностью совместима с объектами Cannon.js, позволяя строить масштабируемые и гибкие системы физического взаимодействия.