Пошук уроків, статей та іншого контенту
Розберіться, як події, цикл подій і асинхронні обробники формують архітектуру застосунків.
Подієва архітектура — це підхід, у якому частини програми взаємодіють через події.
Одна частина застосунку повідомляє:
«Сталася певна подія».
Інші частини можуть підписатися на цю подію та виконати потрібні дії.
Наприклад, після реєстрації користувача можуть відбутися такі події:
HTTP-обробник отримує дані форми.
Створюється користувач.
Виникає подія userRegistered.
Один обробник надсилає вітальний лист.
Інший обробник записує інформацію в журнал.
Компонент, який породжує подію, не обов’язково має знати про всі дії, які виконуються після неї.
Node.js активно використовує подієву модель:
мережеві запити;
читання та запис файлів;
таймери;
робота з потоками;
події користувацьких об’єктів.
Подія зазвичай має:
ім’я — наприклад, data, close, error;
обробник — функцію, яку потрібно викликати;
дані — додаткові значення, пов’язані з подією.
У Node.js для створення власних подій часто використовують клас EventEmitter із вбудованого модуля node:events.
Метод .on() додає обробник, який виконується щоразу, коли відбувається подія.
const { EventEmitter } = require('node:events');
const app = new EventEmitter();
app.on('userRegistered', (user) => {
console.log(`Користувача ${user.name} зареєстровано`);
});
app.emit('userRegistered', {
name: 'Олена',
});Результат:
Користувача Олена зареєстрованоМетод .emit() створює подію та передає її обробникам.
Метод .once() виконує обробник лише один раз:
const { EventEmitter } = require('node:events');
const server = new EventEmitter();
server.once('started', () => {
console.log('Сервер запущено');
});
server.emit('started');
server.emit('started');Результат:
Сервер запущеноДруга подія не викликає обробник, оскільки він уже був видалений після першого виконання.
Розглянемо просту систему реєстрації користувача.
Компонент реєстрації відповідає лише за створення користувача та повідомлення про результат:
const { EventEmitter } = require('node:events');
const userEvents = new EventEmitter();
userEvents.on('userRegistered', (user) => {
console.log(`Надсилання вітального листа для ${user.email}`);
});
userEvents.on('userRegistered', (user) => {
console.log(`Запис події в журнал: ${user.email}`);
});
function registerUser(name, email) {
const user = {
id: Date.now(),
name,
email,
};
// Повідомляємо інші частини застосунку про створення користувача
userEvents.emit('userRegistered', user);
return user;
}
const user = registerUser('Олена', 'olena@example.com');
console.log(`Створено користувача з ідентифікатором ${user.id}`);Тут:
registerUser() створює користувача;
подія userRegistered повідомляє про результат;
окремі обробники виконують різні дії;
функції реєстрації не потрібно змінювати, якщо ми додаємо новий обробник.
Це зменшує зв’язаність між частинами програми.
EventEmitterОбробники подій, додані через .on() або .once(), викликаються синхронно в момент виклику .emit().
const { EventEmitter } = require('node:events');
const events = new EventEmitter();
events.on('ready', () => {
console.log('Обробник події');
});
console.log('До події');
events.emit('ready');
console.log('Після події');Результат:
До події
Обробник події
Після подіїТому довга операція всередині обробника може заблокувати виконання наступного коду.
Якщо обробник має виконувати асинхронну роботу, її зазвичай запускають у ньому явно:
const { EventEmitter } = require('node:events');
const events = new EventEmitter();
events.on('userRegistered', async (user) => {
// Імітуємо асинхронну операцію
await new Promise((resolve) => setTimeout(resolve, 500));
console.log(`Лист для ${user.email} підготовлено`);
});
events.emit('userRegistered', {
email: 'olena@example.com',
});
console.log('Основний код продовжує виконання');Сам EventEmitter не очікує завершення проміса, повернутого обробником. Якщо потрібно керувати результатами або помилками асинхронних операцій, це слід продумати окремо.
Node.js виконує JavaScript в одному основному потоці. Це означає, що одночасно виконується один фрагмент JavaScript-коду.
Щоб програма могла працювати з багатьма мережевими запитами та операціями введення-виведення, Node.js використовує:
стек викликів — код, який виконується зараз;
середовище Node.js — запускає таймери та операції введення-виведення;
черги зворотних викликів — зберігають функції, готові до виконання;
цикл подій — перевіряє, чи з’явилися нові функції для виконання.
Спрощена послідовність така:
Node.js виконує синхронний код.
Асинхронна операція передається середовищу Node.js.
Основний код продовжує виконання.
Після завершення асинхронної операції її обробник потрапляє в чергу.
Цикл подій додає обробник до виконання, коли стек викликів вільний.
console.log('1. Початок');
setTimeout(() => {
console.log('3. Таймер завершено');
}, 0);
console.log('2. Кінець синхронного коду');Результат:
1. Початок
2. Кінець синхронного коду
3. Таймер завершеноНавіть таймер із затримкою 0 не виконується миттєво. Його обробник буде виконаний після завершення поточного синхронного коду.
У Node.js часто зустрічаються різні типи завдань:
синхронний код;
обробники промісів (Promise, async/await);
таймери;
операції введення-виведення.
Для початкового розуміння важливо запам’ятати:
синхронний код виконується першим;
обробники промісів виконуються після поточного синхронного коду;
таймери не переривають синхронне виконання.
console.log('1. Синхронний код');
setTimeout(() => {
console.log('4. Обробник таймера');
}, 0);
Promise.resolve().then(() => {
console.log('3. Обробник промісу');
});
console.log('2. Кінець синхронного коду');Результат:
1. Синхронний код
2. Кінець синхронного коду
3. Обробник промісу
4. Обробник таймераПісля завершення синхронного коду Node.js обробляє готові мікрозавдання, зокрема проміси, а потім переходить до наступних фаз циклу подій, де можуть виконуватися таймери.
У більшості прикладних задач не потрібно вручну керувати фазами циклу подій. Достатньо розуміти, що асинхронна операція не блокує потік, якщо її обробник не містить довгої синхронної роботи.
Неблокувальна операція дозволяє Node.js продовжувати виконання іншого коду, поки операція введення-виведення триває.
Наприклад, асинхронне читання файлу:
const fs = require('node:fs');
console.log('Початок читання');
fs.readFile(__filename, 'utf8', (error, content) => {
if (error) {
console.error('Не вдалося прочитати файл:', error.message);
return;
}
console.log(`Файл прочитано, символів: ${content.length}`);
});
console.log('Програма продовжує працювати');Можливий результат:
Початок читання
Програма продовжує працювати
Файл прочитано, символів: ...Точна кількість символів залежить від вмісту файлу.
Виклик fs.readFile() запускає операцію читання, але не зупиняє виконання наступного рядка. Функція, передана третім аргументом, буде викликана після завершення читання.
Для EventEmitter подія з іменем error має особливе значення. Якщо емітер створює таку подію, але для неї немає обробника, Node.js може завершити процес.
Тому для емітерів, які можуть повідомляти про помилки, потрібно додати обробник:
const { EventEmitter } = require('node:events');
const service = new EventEmitter();
service.on('error', (error) => {
console.error('Помилка сервісу:', error.message);
});
service.emit('error', new Error('Не вдалося підключитися до сервісу'));Обробник помилки не означає, що проблема автоматично виправлена. Він лише дає програмі можливість коректно відреагувати:
записати помилку в журнал;
повторити операцію;
повідомити користувача;
завершити роботу контрольовано.
Події корисні, коли:
одна дія може запускати кілька незалежних реакцій;
компонент не повинен знати про всі залежні компоненти;
потрібно додавати нові реакції без зміни основної логіки;
події природно описують стан системи: connected, data, closed, userRegistered.
Наприклад, після події orderCreated можна окремо:
оновити журнал;
надіслати повідомлення;
підготувати дані для статистики.
Для простої послідовної операції звичайний виклик функції часто зрозуміліший за подію. Події не потрібно використовувати всюди.
Довгий синхронний код блокує виконання інших обробників:
events.on('data', () => {
// Довгий синхронний цикл блокує основний потік
for (let index = 0; index < 10_000_000_000; index += 1) {
// Навмисно повільна операція
}
});Поки цей обробник працює, Node.js не може виконувати інші JavaScript-обробники в цьому потоці.
Не слід виконувати великі синхронні обчислення в обробниках подій без необхідності.
Кожен виклик .on() додає нового слухача. Якщо додавати слухач під час кожного запиту й не видаляти його, їх кількість постійно зростатиме.
events.on('data', handler);Слід перевірити, чи справді потрібен новий слухач, або його потрібно зареєструвати лише один раз.
Асинхронний код може завершитися помилкою. Потрібно обробляти помилки через:
аргумент error у callback-стилі;
try...catch навколо await;
обробник події error для EventEmitter.
emit()Метод .emit() повідомляє слухачів про подію, але не повертає результати їхньої асинхронної роботи.
Якщо потрібно отримати результат і дочекатися кількох операцій, краще явно викликати асинхронні функції та працювати з їхніми промісами.
Подієва архітектура будує взаємодію компонентів навколо подій.
Подія має ім’я, обробники та, за потреби, дані.
Для власних подій у Node.js використовують EventEmitter.
.on() додає багаторазовий обробник, а .once() — одноразовий.
Обробники EventEmitter викликаються синхронно під час .emit().
Цикл подій дозволяє Node.js виконувати асинхронні операції без блокування основного коду.
Таймери та операції введення-виведення виконуються після завершення поточного синхронного коду.
Події допомагають зменшити зв’язаність компонентів, але їх не варто використовувати замість простих прямих викликів без потреби.
Для подій із помилками потрібно додавати обробник error.