Раздел 5 · Асинхронность

События, сигналы и AbortController

middle~40 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

События, сигналы и AbortController

Урок про три модели обращения с потоком “что-то поменялось”: события (push), сигналы (pull-граф), и сцепляющий их AbortController. Все три живут параллельно: где-то они конкурируют, где-то дополняют друг друга, а где-то заменяемы один на один.

После урока ты сможешь по описанию задачи быстро прикинуть: тут достаточно EventEmitter, тут нужно signal/computed/effect, а здесь, наоборот, хватит чистого Promise плюс AbortSignal. И, что не менее важно, как корректно отписываться, чтобы не было утечек.

1. Push против pull

Две базовые модели “потом, потом, потом”.

Push. Источник кричит, подписчики слушают. Подписчик не знает, когда будет следующее значение, и не контролирует темп.

button.addEventListener('click', (event) => {
  console.log('clicked', event.x, event.y);
});

Pull. Потребитель спрашивает, источник отдаёт. Темп задаёт потребитель.

for await (const line of readLines(file)) {
  process(line);
}

EventEmitter/EventTarget, типичный push. AsyncIterable, типичный pull. Сигнал, гибрид: значение можно прочитать в любой момент (pull), а изменения триггерят re-вычисления подписчиков (push).

2. EventEmitter и EventTarget

В Node, events.EventEmitter. На вебе и в новых API Node, EventTarget. API чуть разный, идея одна.

import { EventEmitter } from 'node:events';

const bus = new EventEmitter();

bus.on('message', (text: string) => {
  console.log('got', text);
});

bus.emit('message', 'hello');

EventTarget на вебе:

const bus = new EventTarget();

const handler = (event: Event) => {
  console.log('got', (event as CustomEvent).detail);
};
bus.addEventListener('message', handler);

bus.dispatchEvent(new CustomEvent('message', { detail: 'hello' }));
bus.removeEventListener('message', handler);

Главные различия:

АспектEventEmitterEventTarget
Передача данныхчерез аргументы emit('e', a, b, c)через объект Event/CustomEvent
Список слушателейпредсказуемый порядок добавленияопределён спецификацией DOM
error eventбез слушателя, throwsбез слушателя, тихо
onceвстроен (once)через { once: true } опцию
off/removeListenerпо ссылке на функциюпо ссылке на функцию

Главный подводный камень EventEmitter. Если сэмитить событие error без подписчика, программа упадёт. Это специально, “молчаливый сбой хуже громкого”. Но в коде, где error не предполагается, это сюрприз. Решение, всегда либо подписаться на error, либо использовать EventTarget/AbortSignal.

3. Observer и его место

EventEmitter, это и есть GoF паттерн Observer. Никакой магии.

class Subject<T> {
  private observers = new Set<(value: T) => void>();

  subscribe(observer: (value: T) => void): () => void {
    this.observers.add(observer);
    return () => this.observers.delete(observer);
  }

  notify(value: T): void {
    for (const observer of this.observers) observer(value);
  }
}

Этот код, эквивалент EventEmitter с одним типом события. На нём строятся:

  • Все реализации pub/sub в фронтэнд-стейт-менеджерах (Redux, Zustand, Pinia).
  • Базовый Observable из RxJS (но с дополнительной семантикой complete/error).
  • Под капотом всех hot-сигналов (Solid, Preact, Angular Signals).

Если знаешь Observer, читать чужой реактивный код легче.

4. Утечки слушателей

Главная боль push-модели, забытая отписка. Подписчик держит ссылку на объект, объект, на подписчика, GC не может собрать ни того, ни другого.

class Component {
  constructor(bus: EventEmitter) {
    bus.on('change', () => this.update());
  }
  update() { /* ... */ }
}

// каждый new Component добавляет слушателя, никто не убирает

Лечится тремя способами.

Способ 1, ручная отписка. Помнить и вызывать off/removeListener:

class Component {
  private handler: () => void;

  constructor(private bus: EventEmitter) {
    this.handler = () => this.update();
    bus.on('change', this.handler);
  }

  destroy() {
    this.bus.off('change', this.handler);
  }
}

Способ 2, AbortSignal. И в EventTarget, и в Node-овых once/on есть опция { signal }:

const ac = new AbortController();
bus.addEventListener('change', handler, { signal: ac.signal });
// одно действие отписывает всех:
ac.abort();

Способ 3, WeakRef (продвинутое). WeakRef/FinalizationRegistry позволяют не держать сильную ссылку. На практике встречается редко, обычно хватает первых двух.

EventEmitter ещё дополнительно ругается, если на одно событие повисло больше 10 слушателей. Это MaxListenersExceededWarning, почти всегда индикатор утечки.

5. Сигналы (TC39 proposal)

Signals, это попытка стандартизовать реактивный примитив, на котором выросли SolidJS, Preact Signals и Angular Signals. Идея:

  • signal(value), ячейка состояния. Чтение через signal.get(), запись через signal.set(newValue).
  • computed(fn), derived-значение. Перевычисляется автоматически, когда изменяется любой signal, прочитанный внутри fn.
  • effect(fn), side-effect-подписчик. Запускается на каждое изменение любого из прочитанных сигналов.
import { Signal } from 'signal-polyfill';

const count = new Signal.State(0);
const doubled = new Signal.Computed(() => count.get() * 2);

const watcher = new Signal.subtle.Watcher(() => {
  console.log('doubled =', doubled.get());
});

watcher.watch(doubled);

count.set(1); // doubled = 2
count.set(3); // doubled = 6

Что даёт сигнал такого, чего не даёт EventEmitter:

  • Автоматический tracking зависимостей. Не нужно вручную подписываться, computed сам понимает, какие сигналы он читает.
  • Glitch-free семантика. Если меняются два сигнала-источника одного computed, перевычисление будет один раз, не два.
  • Текущее значение. signal.get() всегда возвращает актуальное. В EventEmitter для этого нужно вручную хранить “последнее” значение.

Стоимость, граф зависимостей, который нужно поддерживать. Для редких push-событий это лишняя сложность.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.