Пошук уроків, статей та іншого контенту
Створите власні події через CustomEvent, передасте дані та організуєте взаємодію компонентів.
Користувацька подія — це об’єкт Event, який створює та запускає код застосунку. На відміну від стандартних подій браузера, наприклад click або input, користувацькі події описують події предметної області:
cart:item-added;
user:login;
theme:changed;
modal:closed;
editor:content-changed.
Користувацькі події дають змогу компонентам взаємодіяти без прямого посилання один на одного. Один компонент повідомляє про зміну стану, а інші компоненти підписуються на це повідомлення.
Для створення користувацьких подій у браузері використовують конструктор CustomEvent.
const event = new CustomEvent("user:login", {
detail: {
id: 42,
name: "Олена"
}
});Властивість detail містить довільні дані, які передаються разом із подією.
Щоб подія відбулася, її потрібно запустити на певному об’єкті:
window.dispatchEvent(event);Обробник події додається стандартним способом:
window.addEventListener("user:login", (event) => {
console.log(event.detail.name);
});CustomEventСинтаксис конструктора:
new CustomEvent(type, options)де:
type — назва події;
options.detail — дані події;
options.bubbles — чи повинна подія спливати вгору по DOM;
options.cancelable — чи можна скасувати стандартну дію події;
options.composed — чи може подія пройти межу Shadow DOM.
Приклад:
const notificationEvent = new CustomEvent("notification:show", {
detail: {
message: "Дані успішно збережено",
type: "success"
},
bubbles: true,
cancelable: true
});
document.dispatchEvent(notificationEvent);Підписка:
document.addEventListener("notification:show", (event) => {
const { message, type } = event.detail;
console.log(`[${type}] ${message}`);
});Назви подій не мають обмежуватися одним словом. Для великих застосунків зручно використовувати простір імен із двокрапкою:
"cart:item-added"
"cart:item-removed"
"auth:session-expired"
"settings:language-changed"Це зменшує ймовірність конфліктів між компонентами.
detailУ detail можна передати будь-яке значення:
new CustomEvent("value:changed", {
detail: 100
});Проте зазвичай передають об’єкт із названими властивостями:
new CustomEvent("value:changed", {
detail: {
previousValue: 50,
currentValue: 100,
source: "slider"
}
});Такий формат зрозуміліший і легше розширюється.
detail зберігає посилання на переданий об’єкт, а не створює його копію:
const payload = {
status: "ready"
};
const event = new CustomEvent("state:changed", {
detail: payload
});
payload.status = "error";
console.log(event.detail.status); // "error"Якщо обробники не повинні змінювати передані дані, можна:
передавати незмінювані об’єкти;
заморожувати об’єкт через Object.freeze;
створювати копію перед передаванням.
const state = Object.freeze({
status: "ready"
});
const event = new CustomEvent("state:changed", {
detail: state
});Object.freeze захищає лише від безпосередньої зміни властивостей цього об’єкта. Для глибоко вкладених структур потрібне окреме глибоке копіювання або глибоке заморожування.
Розглянемо застосунок із трьома частинами:
форма додає товар у кошик;
кошик зберігає список товарів;
індикатор показує кількість товарів.
Компоненти не повинні викликати методи один одного напряму. Форма генерує подію cart:item-added, а кошик та індикатор реагують на неї.
<!doctype html>
<html lang="uk">
<head>
<meta charset="UTF-8">
<title>Користувацькі події</title>
</head>
<body>
<form id="product-form">
<label>
Товар
<input id="product-name" name="name" value="Навушники" required>
</label>
<label>
Ціна
<input
id="product-price"
name="price"
type="number"
min="0"
step="0.01"
value="1499"
required
>
</label>
<button type="submit">Додати в кошик</button>
</form>
<p>
Товарів у кошику:
<strong id="cart-count">0</strong>
</p>
<ul id="cart-list"></ul>
<script>
const productForm = document.querySelector("#product-form");
const cartCount = document.querySelector("#cart-count");
const cartList = document.querySelector("#cart-list");
const cart = [];
productForm.addEventListener("submit", (event) => {
event.preventDefault();
const formData = new FormData(productForm);
const name = String(formData.get("name")).trim();
const price = Number(formData.get("price"));
if (!name || !Number.isFinite(price) || price < 0) {
return;
}
const product = {
id: crypto.randomUUID(),
name,
price
};
// Форма повідомляє застосунок про додавання товару.
document.dispatchEvent(new CustomEvent("cart:item-added", {
detail: {
product
}
}));
productForm.reset();
});
document.addEventListener("cart:item-added", (event) => {
const { product } = event.detail;
cart.push(product);
// Оновлюємо список товарів.
const item = document.createElement("li");
item.textContent = `${product.name} — ${product.price.toFixed(2)} грн`;
cartList.append(item);
// Повідомляємо інші компоненти про зміну кількості.
document.dispatchEvent(new CustomEvent("cart:changed", {
detail: {
count: cart.length,
items: [...cart]
}
}));
});
document.addEventListener("cart:changed", (event) => {
cartCount.textContent = String(event.detail.count);
});
</script>
</body>
</html>У цьому прикладі:
форма знає лише про подію cart:item-added;
компонент кошика слухає cart:item-added;
індикатор кількості слухає cart:changed;
форма не має посилання на індикатор;
індикатор не має посилання на форму.
Це зменшує зв’язаність компонентів.
Якщо подію запустити на дочірньому елементі з bubbles: true, її зможуть перехопити батьківські елементи:
const button = document.querySelector("button");
button.dispatchEvent(new CustomEvent("panel:action", {
bubbles: true,
detail: {
action: "save"
}
}));Обробник на контейнері:
document.querySelector("#panel").addEventListener("panel:action", (event) => {
console.log(event.detail.action);
});Без bubbles: true подія буде доступна лише на об’єкті, на якому її запустили, та його безпосередніх обробниках:
button.dispatchEvent(new CustomEvent("panel:action", {
detail: {
action: "save"
}
}));Спливання дає змогу використовувати делегування:
<div id="editor">
<button data-action="bold">Жирний</button>
<button data-action="italic">Курсив</button>
</div>const editor = document.querySelector("#editor");
editor.addEventListener("editor:command", (event) => {
console.log("Команда:", event.detail.command);
});
editor.addEventListener("click", (event) => {
const button = event.target.closest("[data-action]");
if (!button || !editor.contains(button)) {
return;
}
button.dispatchEvent(new CustomEvent("editor:command", {
bubbles: true,
detail: {
command: button.dataset.action
}
}));
});Обробник editor:command розташований на контейнері, а не на кожній кнопці окремо.
Якщо подія створена з cancelable: true, обробник може викликати preventDefault().
const event = new CustomEvent("order:submit", {
cancelable: true,
detail: {
total: 0
}
});
const allowed = document.dispatchEvent(event);
if (allowed) {
console.log("Замовлення можна надсилати");
} else {
console.log("Замовлення скасовано");
}Значення dispatchEvent:
true — подію не було скасовано;
false — один з обробників викликав preventDefault().
Приклад перевірки:
document.addEventListener("order:submit", (event) => {
if (event.detail.total <= 0) {
event.preventDefault();
console.log("Неможливо оформити порожнє замовлення");
}
});
const submitEvent = new CustomEvent("order:submit", {
cancelable: true,
detail: {
total: 0
}
});
if (document.dispatchEvent(submitEvent)) {
console.log("Виконуємо надсилання замовлення");
}Важливо: preventDefault() не зупиняє виконання інших обробників. Він лише позначає подію як скасовану.
Якщо потрібно припинити подальше поширення події, використовують:
event.stopPropagation();Щоб зупинити поширення та не викликати інші обробники на тому самому об’єкті:
event.stopImmediatePropagation();Ці методи мають різне призначення, тому не варто використовувати їх замість preventDefault().
target, currentTarget і detailУ кожної події є кілька важливих властивостей:
event.target — об’єкт, на якому подію було запущено;
event.currentTarget — об’єкт, на якому зараз виконується конкретний обробник;
event.detail — дані користувацької події.
Приклад зі спливанням:
const parent = document.querySelector("#parent");
const child = document.querySelector("#child");
parent.addEventListener("custom:action", (event) => {
console.log(event.target === child); // true
console.log(event.currentTarget === parent); // true
console.log(event.detail); // передані дані
});
child.dispatchEvent(new CustomEvent("custom:action", {
bubbles: true,
detail: {
source: "child"
}
}));target залишається початковим джерелом події, а currentTarget змінюється залежно від обробника, який зараз виконується.
Користувацькі події не обов’язково запускати на document. Будь-який об’єкт, який реалізує EventTarget, може бути посередником подій.
Найзручніше створити окрему шину подій:
const eventBus = new EventTarget();
const unsubscribe = () => {
eventBus.removeEventListener("user:logout", handleLogout);
};
function handleLogout(event) {
console.log("Користувач вийшов:", event.detail.userId);
}
eventBus.addEventListener("user:logout", handleLogout);
eventBus.dispatchEvent(new CustomEvent("user:logout", {
detail: {
userId: 42
}
}));
unsubscribe();Такий підхід не прив’язує події до DOM. Він корисний для:
сервісів;
модулів стану;
компонентів, які не мають спільного DOM-контейнера;
тестування бізнес-логіки.
Однак глобальну шину подій не слід перетворювати на приховане сховище всього стану застосунку. Якщо подій стає надто багато, їхній потік важко відстежувати. Для складного стану потрібна чітка модель даних і передбачувані правила оновлення.
Кожен виклик addEventListener створює підписку. Якщо компонент видаляється, його обробники потрібно видаляти, інакше можливі:
витоки пам’яті;
виклик методів уже неіснуючого компонента;
дублювання реакцій після повторного монтування.
Класичний спосіб — зберігати посилання на обробник:
const eventBus = new EventTarget();
function handleUpdate(event) {
console.log(event.detail);
}
eventBus.addEventListener("data:updated", handleUpdate);
// Пізніше, під час знищення компонента:
eventBus.removeEventListener("data:updated", handleUpdate);Не можна видалити підписку, створену через іншу анонімну функцію:
eventBus.addEventListener("data:updated", () => {
console.log("Оновлення");
});
// Це інша функція, тому підписка не буде видалена.
eventBus.removeEventListener("data:updated", () => {
console.log("Оновлення");
});AbortControllerСучасний варіант — передати signal під час підписки:
const eventBus = new EventTarget();
const controller = new AbortController();
eventBus.addEventListener("data:updated", (event) => {
console.log("Отримано:", event.detail);
}, {
signal: controller.signal
});
eventBus.addEventListener("user:changed", (event) => {
console.log("Користувач:", event.detail);
}, {
signal: controller.signal
});
// Видаляє всі обробники, які використовують цей signal.
controller.abort();Це особливо зручно для компонентів, які мають багато підписок: під час знищення достатньо викликати abort() один раз.
Shadow DOM створює межу між внутрішнім DOM компонента та зовнішнім документом. Подія з bubbles: true може поширюватися всередині shadow tree, але для виходу за його межі потрібна опція composed: true.
class StatusCard extends HTMLElement {
connectedCallback() {
const shadowRoot = this.attachShadow({ mode: "open" });
const button = document.createElement("button");
button.textContent = "Готово";
button.addEventListener("click", () => {
this.dispatchEvent(new CustomEvent("status:changed", {
bubbles: true,
composed: true,
detail: {
status: "ready"
}
}));
});
shadowRoot.append(button);
}
}
customElements.define("status-card", StatusCard);
document.addEventListener("status:changed", (event) => {
console.log("Статус компонента:", event.detail.status);
});Тут подію запускають на самому custom element через this.dispatchEvent. Вона:
спливає через DOM завдяки bubbles: true;
проходить межу Shadow DOM завдяки composed: true.
Якщо подія не повинна бути видимою за межами компонента, не вказуйте composed: true.
detailcomposed: true робить дані з detail доступними зовнішньому коду. Тому не слід без потреби передавати через публічні події:
токени доступу;
паролі;
внутрішні секрети;
великі приватні структури стану.
Публічна подія має передавати лише мінімальний набір даних, потрібний споживачам.
Користувацька подія має не лише назву, а й контракт — домовленість про її структуру.
Наприклад:
document.dispatchEvent(new CustomEvent("profile:updated", {
detail: {
userId: 42,
fields: ["name", "avatar"]
}
}));Для такого контракту варто зафіксувати:
коли запускається подія;
на якому об’єкті вона запускається;
структуру detail;
чи спливає подія;
чи може вона бути скасована;
чи доступна вона за межами Shadow DOM;
чи можуть обробники змінювати дані.
Хороша подія описує факт, який уже відбувся:
"cart:item-added"
"profile:updated"
"modal:closed"Гірше використовувати подію як приховану команду без чіткого контракту:
"do-something"
"process"
"update"Для команд краще явно називати дію або використовувати окремий механізм команд.
dispatchEvent викликає обробники синхронно. Це означає, що код після dispatchEvent виконається лише після завершення обробників:
const target = new EventTarget();
target.addEventListener("task:started", () => {
console.log("Обробник");
});
console.log("До події");
target.dispatchEvent(new CustomEvent("task:started"));
console.log("Після події");Результат:
До події
Обробник
Після подіїdispatchEvent не робить подію асинхронною і не повертає Promise. Якщо обробка має виконуватися асинхронно, обробник може бути async, але dispatchEvent не очікуватиме його результат:
target.addEventListener("data:load", async () => {
await fetch("/data");
console.log("Дані завантажено");
});
target.dispatchEvent(new CustomEvent("data:load"));
console.log("Цей код не очікує fetch");Якщо програмі потрібно дочекатися результату, краще явно використовувати функцію, яка повертає Promise, а не намагатися передавати результат через подію.
detailПомилка:
const event = new CustomEvent("product:added");
console.log(event.detail.name); // ПомилкаЯкщо дані потрібні обробнику, передайте їх через detail:
const event = new CustomEvent("product:added", {
detail: {
name: "Клавіатура"
}
});bubblesПодія, запущена на дочірньому елементі, не буде доступна батьківському обробнику без bubbles: true.
child.dispatchEvent(new CustomEvent("component:ready", {
bubbles: true
}));preventDefault() без cancelableЯкщо подія не є скасовуваною, виклик preventDefault() не дасть очікуваного результату.
const event = new CustomEvent("form:validate", {
cancelable: true
});detail в обробникуОбробники можуть отримати спільне посилання на об’єкт і випадково змінити його для інших обробників. Не змінюйте event.detail без чіткої домовленості.
Краще:
const data = {
...event.detail
};document.dispatchEvent(...) зручний для простих інтеграцій, але велика кількість глобальних подій ускладнює програму. Використовуйте найбільш локальний об’єкт, який підходить для задачі:
конкретний компонент;
контейнер;
екземпляр EventTarget;
document або window — лише для справді глобальних подій.
Подія добре повідомляє про факт або сповіщає кількох слухачів. Вона не завжди підходить для запиту, який повинен повернути значення.
Для запиту використовуйте функцію:
async function loadUser(id) {
const response = await fetch(`/users/${id}`);
return response.json();
}А після завершення завантаження можна запустити подію:
document.dispatchEvent(new CustomEvent("user:loaded", {
detail: {
user
}
}));CustomEvent дає змогу створювати події з власними назвами.
Дані передаються через event.detail.
dispatchEvent запускає подію на об’єкті EventTarget.
bubbles: true дозволяє події спливати до батьківських елементів.
cancelable: true дозволяє обробнику викликати preventDefault().
composed: true дає змогу події вийти за межі Shadow DOM.
Події допомагають зменшити зв’язаність компонентів.
Підписки потрібно видаляти або скасовувати через AbortController.
Користувацькі події виконуються синхронно.
Для кожної події варто визначити чіткий контракт: назву, джерело, структуру detail і правила поширення.