Skip to main content

Патерн спостерігач

Патерн Observer (спостерігач) - поведінковий патерн проектування, де суб'єкт тримає список спостерігачів і автоматично сповіщає їх про зміни свого стану.

Теорія

TL;DR

  • Аналогія: YouTube-канал надсилає сповіщення підписникам про нові відео, не зберігаючи їхні адреси напряму
  • Суб'єкт розсилає зміни всім слухачам, не знаючи їхніх типів чи кількості
  • Використовуй, коли 2+ об'єктів потребують синхронізації стану без жорстких посилань між собою
  • Базова механіка: subscribe(), notify(), unsubscribe()
  • Node.js EventEmitter і React useEffect - обидві реалізації Observer

Базовий приклад

javascript
class Subject { constructor() { this.observers = []; } subscribe(obs) { this.observers.push(obs); } unsubscribe(obs) { this.observers = this.observers.filter(o => o !== obs); } notify(data) { this.observers.forEach(obs => obs.update(data)); } } class Logger { update(data) { console.log(`Отримано: ${data}`); } } const subject = new Subject(); subject.subscribe(new Logger()); subject.notify('user logged in'); // Отримано: user logged in

Subject тримає масив посилань на спостерігачів. notify() перебирає їх і викликає update() кожного. Subject не імпортує Logger - не знає про нього нічого. Ось і вся ідея.

Головна відмінність

Прямий виклик методів жорстко прив'язує відправника до конкретних об'єктів. Якщо сервіс викликає logger.log() напряму, не можна замінити logger не торкаючись сервісу. Observer змінює цю логіку: суб'єкт не знає хто слухає. Спостерігачів додають і видаляють в рантаймі, суб'єкт продовжує працювати без змін. Саме це означає слабке зв'язування на практиці.

Коли використовувати

  • Один об'єкт змінюється, багато реагує: користувач входить у систему, треба оновити UI, скинути кеш і відправити аналітику. Один виклик notify() замість трьох ручних.
  • Кількість спостерігачів змінюється в рантаймі: плагіни, фіча-флаги, динамічні дашборди. Жорстко прописаний список обробників зламається, щойно зміняться вимоги.
  • Потрібна слабка синхронізація між модулями: MVC-в'юхи спостерігають за моделлю, компоненти Redux підписуються на стор.
  • Пропусти патерн, якщо вистачає одного простого колбека або слухач завжди рівно один і ніколи не змінюється.

Як notify працює всередині

В JavaScript notify() за замовчуванням ітерує масив спостерігачів синхронно. Кожен виклик obs.update() проходить через ланцюжок прототипів за допомогою динамічного диспетчеризування. Node.js EventEmitter будується на цьому й направляє асинхронну роботу через libuv, щоб emit не блокував event loop. useEffect у React іде далі: компонент підписується на масив залежностей, і React запускає ефект після коміту рендеру до DOM. Обидва - та сама ідея на різних рівнях абстракції.

Типові помилки

Забута відписка спричиняє витоки пам'яті. Спостерігач залишається в масиві суб'єкта, збирач сміття не може його зібрати. На серверах, що живуть довго, це накопичується до сотень мегабайт.

javascript
// Неправильно: спостерігач залишається в пам'яті назавжди subject.subscribe(obs); // Правильно: завжди прибирай за собою subject.subscribe(obs); const cleanup = () => subject.unsubscribe(obs); // У React: return cleanup із useEffect

Синхронний notify у рекурсивних ланцюжках викликає переповнення стека. Якщо update() спостерігача знову запускає notify() - це рекурсія. V8 обмежує стек викликів приблизно 10k кадрами.

javascript
// Неправильно: subject.notify() -> obs.update() -> subject.notify() -> crash // Правильно: розірвати ланцюг асинхронно notify(data) { setImmediate(() => this.observers.forEach(obs => obs.update(data))); }

Передача мутабельних об'єктів створює баги зі спільним станом. Два спостерігачі отримують посилання на один об'єкт, обидва його змінюють - стан стає непередбачуваним. React strict mode виявляє це швидко.

javascript
// Неправильно: обидва поділяють одне посилання subject.notify({ users: usersArray }); // Правильно: передай знімок subject.notify({ users: [...usersArray] });

Відсутність обробки помилок ламає весь broadcast. Один спостерігач кидає помилку - решта не отримують сповіщення. Це вже призводило до втрати даних у продакшні.

javascript
notify(data) { this.observers.forEach(obs => { try { obs.update(data); } catch (err) { console.error('Observer error:', err); } }); }

Де зустрічається

  • React: масив залежностей у useEffect спостерігає за пропсами/станом; функція прибирання = відписка
  • Node.js EventEmitter: req.on('data', handler) спостерігає за чанками потоку в модулі http
  • Redux: store.subscribe() сповіщає підключені компоненти при dispatch; RTK Query використовує ту саму ідею для стану API
  • RxJS: Observable.subscribe() - це Observer плюс скасування через Subscription
  • Реактивність Vue: обчислювані властивості (computed properties) спостерігають за реактивними даними й перераховуються при змінах
  • Проти Pub-Sub: Observer використовує прямі посилання (суб'єкт тримає реф на спостерігачів), Pub-Sub додає брокера для повністю розв'язаного роутингу за темами

Питання для співбесіди

Q: Як реалізувати Observer без класів у 10 рядків?
A: Через замикання (closure). const createSubject = () => { let obs = []; return { subscribe: f => obs.push(f), unsubscribe: f => { obs = obs.filter(o => o !== f); }, notify: d => obs.forEach(f => f(d)) }; };. Той самий контракт, без накладних витрат класів.

Q: Яка різниця між Observer і Pub-Sub?
A: Observer прямий: суб'єкт тримає посилання на спостерігачів. Pub-Sub додає брокера посередині. Підписники реєструються на тему, а видавець не знає хто слухає. Redis pub-sub і MQTT так і працюють. Observer - для синхронізації стану всередині процесу, Pub-Sub - для розподілених систем.

Q: Чим відрізняється useEffect у React від класичного Observer?
A: Класичний Observer сповіщає синхронно і негайно. React групує зміни і запускає ефекти після коміту рендеру до DOM. Функція прибирання відповідає відписці. Масив залежностей визначає що саме спостерігає компонент.

Q: Як обробляти витоки EventEmitter у Node.js-кластерах?
A: Слухачі для кожного форка накопичуються без прибирання. Для одноразових подій використовуй emitter.once() - він видаляється автоматично. Для постійних слухачів реєструй process.on('exit', cleanup). В тестах викликай removeAllListeners() при teardown. Патерн once() джуніори зазвичай пропускають, сеньйори згадують одразу.

Приклади

Базовий Observer на TypeScript

typescript
interface IObserver { update(subject: ISubject): void; } interface ISubject { attach(observer: IObserver): void; detach(observer: IObserver): void; notify(): void; } class UserStore implements ISubject { private observers: IObserver[] = []; private loggedIn: boolean = false; attach(observer: IObserver): void { this.observers.push(observer); } detach(observer: IObserver): void { this.observers = this.observers.filter(o => o !== observer); } notify(): void { for (const observer of this.observers) { try { observer.update(this); } catch (err) { console.error('Observer error:', err); } } } login(): void { this.loggedIn = true; console.log('UserStore: user logged in'); this.notify(); } isLoggedIn(): boolean { return this.loggedIn; } } class NavbarObserver implements IObserver { update(subject: ISubject): void { if (subject instanceof UserStore && subject.isLoggedIn()) { console.log('Navbar: showing user menu'); } } } class AnalyticsObserver implements IObserver { update(subject: ISubject): void { if (subject instanceof UserStore && subject.isLoggedIn()) { console.log('Analytics: tracking login event'); } } } const store = new UserStore(); store.attach(new NavbarObserver()); store.attach(new AnalyticsObserver()); store.login(); // UserStore: user logged in // Navbar: showing user menu // Analytics: tracking login event

UserStore викликає notify() після зміни стану. Обидва спостерігачі реагують незалежно і не знають один про одного. Щоб додати третій (наприклад, інвалідацію кешу) - UserStore чіпати не потрібно.

Node.js EventEmitter з правильним прибиранням

EventEmitter - стандартна реалізація Observer у Node.js. Витік пам'яті тут легко пропустити на завантаженому сервері.

javascript
const { EventEmitter } = require('events'); const dataStream = new EventEmitter(); function startListening() { const handleData = (data) => { console.log('Отримано чанк:', data); }; dataStream.on('data', handleData); // Завжди повертай функцію прибирання return () => dataStream.off('data', handleData); } const stop = startListening(); dataStream.emit('data', 'chunk 1'); // Отримано чанк: chunk 1 dataStream.emit('data', 'chunk 2'); // Отримано чанк: chunk 2 stop(); // Відписка dataStream.emit('data', 'chunk 3'); // Нічого не виводиться

Без виклику stop() функція handleData залишається в масиві слухачів назавжди. На сервері, що обробляє тисячі запитів, це швидко накопичується. Бачив такий патерн, що з'їдав 200MB хіпу за одну продакшн-сесію.

React useEffect як Observer

Масив залежностей у React - це Observer, вбудований у фреймворк. Компонент підписується на зміни конкретних значень, React управляє циклом notify/unsubscribe.

jsx
import { useEffect, useState } from 'react'; function PriceDisplay({ productId }) { const [price, setPrice] = useState(null); useEffect(() => { let active = true; // Захист від застарілих оновлень async function fetchPrice() { const data = await fetch(`/api/prices/${productId}`).then(r => r.json()); if (active) setPrice(data.price); } fetchPrice(); // Прибирання: відписка при зміні productId або розмонтуванні return () => { active = false; }; }, [productId]); // productId - суб'єкт, за яким спостерігаємо return <div>Ціна: {price ?? 'Завантаження...'}</div>; }

Коли productId змінюється, React запускає прибирання (відписка від старого значення), потім знову запускає ефект (підписка на нове). Без прибирання застарілий запит може оновити стан після того, як компонент вже перейшов на інший продукт.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?