Observer Pattern — Наблюдатель

Подписка на события объекта. В JS — встроенный EventEmitter (Node) и EventTarget (browser). Источник развития: RxJS, Signals, реактивное программирование.

Проблема

Один объект меняется/генерирует события. Несколько других должны реагировать. Прямые вызовы создают зацепление. Хочется, чтобы источник не знал о потребителях.

Где используется

  • DOM Events (addEventListener — встроенный Observer)
  • EventEmitter в Node.js
  • Реактивные библиотеки (RxJS, MobX)
  • Redux store (subscribe)
  • Vue.js reactivity, Signals (Svelte 5, Solid, Angular)
  • Чаты, уведомления, real-time обновления

Решение

  • Observable хранит список подписчиков и эмитит события.
  • Observer подписывается через subscribe(callback).
  • При событии — рассылка всем подписчикам.

Реализации

EventEmitter

class EventEmitter {
  constructor { this.listeners = new Map(); }

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

  off(event, callback) { this.listeners.get(event)?.delete(callback); }

  emit(event, ...args) {
    this.listeners.get(event)?.forEach(cb => {
      try { cb(...args); } catch (err) { console.error('listener error:', err); }
    });
  }

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

Через функции

const observable = () => {
  const observers = new Set();
  return {
    subscribe: (fn) => { observers.add(fn); return  => observers.delete(fn); },
    emit: (value) => observers.forEach(o => o(value)),
  };
};

Через встроенные API

// Node EventEmitter — синхронный
const { EventEmitter } = require('node:events');
const ee = new EventEmitter();
ee.on('data', chunk => process(chunk));
ee.emit('data', 'hello');

// DOM EventTarget — асинхронный через macrotask
const target = new EventTarget();
target.addEventListener('data', e => console.log(e.detail));
target.dispatchEvent(new CustomEvent('data', { detail: 'hello' }));

Store (как Redux)

class Store {
  constructor(reducer, initial = {}) {
    this.reducer = reducer;
    this.state = initial;
    this.listeners = new Set();
  }

  getState { return this.state; }

  dispatch(action) {
    const prev = this.state;
    this.state = this.reducer(this.state, action);
    if (prev !== this.state) this.listeners.forEach(cb => cb(this.state));
  }

  subscribe(cb) {
    this.listeners.add(cb);
    return  => this.listeners.delete(cb);
  }
}

Реактивные данные через Proxy

function createReactive(obj) {
  const listeners = new Map();
  const proxy = new Proxy(obj, {
    set(target, prop, value) {
      const old = target[prop];
      target[prop] = value;
      if (old !== value) listeners.get(prop)?.forEach(cb => cb(value, old));
      return true;
    }
  });
  proxy.$watch = (prop, cb) => {
    if (!listeners.has(prop)) listeners.set(prop, new Set);
    listeners.get(prop).add(cb);
    return  => listeners.get(prop).delete(cb);
  };
  return proxy;
}

const user = createReactive({ name: 'Иван' });
user.$watch('name', (v, old) => console.log(`${old}${v}`));
user.name = 'Пётр'; // Иван → Пётр

Где используется в JS-экосистеме

  • EventEmitter (Node.js): fs.createReadStream, http.Server — все унаследованы
  • EventTarget (DOM): addEventListener повсюду
  • RxJS Observable — стримы событий
  • Vue/React reactivity — внутреннее использование observable
  • Signals (Svelte 5, Solid.js, Angular Signals) — single-value observer

Подводные камни

  • EventEmitter в Node — синхронный: emit сразу обходит всех слушателей.
  • EventTarget — асинхронный через dispatchEvent + macrotask.
  • on vs once: once отписывается после первого срабатывания.
  • Неконсистентность Node API: EventEmitter.on(ee, 'data') (static) возвращает async iterator; ee.on(...) (instance) — подписку.
  • Зависимость от порядка подписчиков — архитектурный дефект, по автору.
  • Сложно тестировать — две связанные событиями абстракции нельзя изолировать.
  • Утечки памяти — забытые подписки накапливаются. Всегда сохраняй unsubscribe и вызывай в cleanup.
  • Ошибка в подписчике ломает остальных без try/catch.
  • Бесконечный цикл уведомлений — A подписан на B, B подписан на A.

Главные тезисы автора

  • «Одна абстракция порождает события, другая наблюдает за ними».
  • «Не нужно делать observable и observer как в GoF — listener это просто функция» (в JS).
  • EventEmitter для backend (Node), EventTarget для frontend (DOM).
  • Критика асинхронности через EventEmitter: «event-emitter подобен go-to».
  • «События делают зацепление неявным» — кажется, что снизили coupling, на самом деле просто спрятали.
  • «Если код на event-emitter — это тестировать сложнее» — нельзя изолировать.
  • Signals = single-value Observer — современная вариация.

🎓 Источники

См. также