Пошук уроків, статей та іншого контенту
Розберете делегування подій і створите обробники для динамічних дочірніх елементів.
Делегування подій — це техніка, за якої обробник встановлюють не на кожен дочірній елемент окремо, а на спільного батьківського елемента.
Наприклад, замість того щоб додавати обробник до кожної кнопки списку, можна встановити один обробник на сам список:
list.addEventListener('click', handler);Коли користувач натискає кнопку, подія спочатку виникає на кнопці, а потім піднімається вгору DOM-деревом до її батьківських елементів. Цей процес називається спливанням події — bubbling.
Делегування особливо корисне, коли:
елементів багато;
дочірні елементи створюються динамічно;
потрібно зменшити кількість обробників;
усі елементи мають спільного батька й реагують на однакову подію.
target і currentTargetПід час обробки події браузер передає об'єкт Event. Два його важливі властивості:
event.target — елемент, на якому подія виникла;
event.currentTarget — елемент, на якому зараз виконується обробник.
Розглянемо структуру:
<ul id="tasks">
<li>
<button class="delete-button">Видалити</button>
</li>
</ul>Якщо обробник встановлено на ul, а користувач натиснув кнопку:
const tasks = document.querySelector('#tasks');
tasks.addEventListener('click', (event) => {
console.log(event.target); // button.delete-button
console.log(event.currentTarget); // ul#tasks
});event.target може бути дочірнім елементом кнопки. Наприклад, якщо всередині кнопки є span, ціллю події стане саме span:
<button class="delete-button">
<span>Видалити</span>
</button>Тому перевірка на кшталт:
if (event.target.classList.contains('delete-button')) {
// ...
}може бути ненадійною. У цьому випадку ціллю може виявитися span, а не сама кнопка.
Нехай є список завдань:
<ul id="task-list">
<li data-task-id="1">
<span>Вивчити DOM</span>
<button type="button" class="delete-task">Видалити</button>
</li>
<li data-task-id="2">
<span>Повторити події</span>
<button type="button" class="delete-task">Видалити</button>
</li>
</ul>Замість окремого обробника для кожної кнопки встановимо один обробник на список:
const taskList = document.querySelector('#task-list');
taskList.addEventListener('click', (event) => {
const deleteButton = event.target.closest('.delete-task');
if (!deleteButton) {
return;
}
const task = deleteButton.closest('[data-task-id]');
if (!task) {
return;
}
task.remove();
});Метод closest(selector) шукає найближчого предка, який відповідає CSS-селектору. Сам елемент також перевіряється.
У прикладі:
визначається кнопка, на яку натиснули;
знаходиться батьківський елемент завдання;
це завдання видаляється.
Метод closest() може знайти елемент поза контейнером, на якому встановлено обробник. Зазвичай це не проблема, якщо структура DOM правильна, але безпечніше перевірити, що знайдений елемент справді належить списку:
taskList.addEventListener('click', (event) => {
const deleteButton = event.target.closest('.delete-task');
if (!deleteButton || !taskList.contains(deleteButton)) {
return;
}
deleteButton.closest('li')?.remove();
});Метод contains() перевіряє, чи є елемент дочірнім або самим контейнером.
У більшості звичайних випадків достатньо перевірки if (!deleteButton). Додаткова перевірка корисна під час роботи зі складною структурою DOM або зі слотами вебкомпонентів.
Одне з головних застосувань делегування — робота з елементами, які з'являються після встановлення обробника.
<button id="add-task" type="button">Додати завдання</button>
<ul id="task-list"></ul>
<script>
const addTaskButton = document.querySelector('#add-task');
const taskList = document.querySelector('#task-list');
let taskNumber = 1;
taskList.addEventListener('click', (event) => {
const deleteButton = event.target.closest('.delete-task');
if (!deleteButton) {
return;
}
deleteButton.closest('li')?.remove();
});
addTaskButton.addEventListener('click', () => {
const task = document.createElement('li');
task.dataset.taskId = String(taskNumber);
task.innerHTML = `
<span>Нове завдання ${taskNumber}</span>
<button type="button" class="delete-task">Видалити</button>
`;
taskList.append(task);
taskNumber += 1;
});
</script>Обробник click встановлюється на порожній список ще до появи завдань. Коли нове завдання додається через append(), його кнопка вже автоматично бере участь у делегуванні.
Без делегування довелося б після кожного додавання окремо налаштовувати обробник для нової кнопки.
Нижче наведено повний приклад із такими можливостями:
додавання завдань;
видалення завдання;
позначення завдання як виконаного;
редагування тексту;
робота з динамічно створеними елементами.
<!doctype html>
<html lang="uk">
<head>
<meta charset="UTF-8">
<title>Делегування подій</title>
<style>
.completed {
text-decoration: line-through;
color: #777;
}
li {
margin: 8px 0;
}
button {
margin-left: 8px;
}
</style>
</head>
<body>
<form id="task-form">
<label>
Нове завдання:
<input id="task-input" name="task" required>
</label>
<button type="submit">Додати</button>
</form>
<ul id="task-list"></ul>
<script>
const taskForm = document.querySelector('#task-form');
const taskInput = document.querySelector('#task-input');
const taskList = document.querySelector('#task-list');
let nextTaskId = 1;
taskForm.addEventListener('submit', (event) => {
event.preventDefault();
const title = taskInput.value.trim();
if (!title) {
return;
}
const task = document.createElement('li');
task.dataset.taskId = String(nextTaskId);
const text = document.createElement('span');
text.className = 'task-text';
text.textContent = title;
const completeButton = document.createElement('button');
completeButton.type = 'button';
completeButton.className = 'complete-task';
completeButton.textContent = 'Виконано';
const editButton = document.createElement('button');
editButton.type = 'button';
editButton.className = 'edit-task';
editButton.textContent = 'Редагувати';
const deleteButton = document.createElement('button');
deleteButton.type = 'button';
deleteButton.className = 'delete-task';
deleteButton.textContent = 'Видалити';
task.append(text, completeButton, editButton, deleteButton);
taskList.append(task);
taskInput.value = '';
taskInput.focus();
nextTaskId += 1;
});
taskList.addEventListener('click', (event) => {
const button = event.target.closest('button');
if (!button || !taskList.contains(button)) {
return;
}
const task = button.closest('li');
if (!task) {
return;
}
if (button.matches('.delete-task')) {
task.remove();
return;
}
if (button.matches('.complete-task')) {
task.classList.toggle('completed');
return;
}
if (button.matches('.edit-task')) {
const textElement = task.querySelector('.task-text');
const newTitle = prompt('Нова назва завдання:', textElement.textContent);
if (newTitle !== null && newTitle.trim() !== '') {
textElement.textContent = newTitle.trim();
}
}
});
</script>
</body>
</html>У цьому прикладі на всіх кнопках списку працює один обробник. Він визначає, з якою кнопкою взаємодіяв користувач, за допомогою matches():
if (button.matches('.delete-task')) {
// ...
}Метод matches(selector) повертає true, якщо елемент відповідає CSS-селектору.
Делегування працює не лише для click. Його можна застосовувати до подій, які спливають від дочірніх елементів.
Наприклад, для input:
const form = document.querySelector('#profile-form');
form.addEventListener('input', (event) => {
if (!event.target.matches('input[name="username"]')) {
return;
}
console.log('Поточне значення:', event.target.value);
});Це зручно для форм із полями, які додаються динамічно.
Для change:
const filters = document.querySelector('#filters');
filters.addEventListener('change', (event) => {
if (!event.target.matches('input[type="checkbox"]')) {
return;
}
console.log('Стан прапорця:', event.target.checked);
});Однак не всі події спливають однаково. Наприклад, focus і blur не спливають. Для делегування їхніх аналогічних сценаріїв можна використати focusin і focusout, які спливають:
const form = document.querySelector('#profile-form');
form.addEventListener('focusin', (event) => {
if (event.target.matches('input')) {
event.target.classList.add('focused');
}
});
form.addEventListener('focusout', (event) => {
if (event.target.matches('input')) {
event.target.classList.remove('focused');
}
});Подія mouseenter також не спливає. Для делегування наведення зазвичай використовують mouseover, але потрібно враховувати, що вона може виникати під час переходу між дочірніми елементами.
stopPropagation() і stopImmediatePropagation()Метод stopPropagation() зупиняє подальше поширення події DOM-деревом.
const modal = document.querySelector('#modal');
modal.addEventListener('click', (event) => {
event.stopPropagation();
});Якщо подія виникла всередині modal, вона не підніметься до батьківських елементів.
Метод stopImmediatePropagation() робить більше: він зупиняє і поширення події, і виконання інших обробників на тому самому елементі.
Ці методи слід застосовувати обережно. Вони можуть зламати делегування, встановлене на контейнері або на document. Часто замість зупинки поширення краще перевірити ціль події та просто завершити виконання поточного обробника.
Подія проходить DOM у кілька етапів:
захоплення — від window до цільового елемента;
обробка на цільовому елементі;
спливання — від цільового елемента назад до window.
За замовчуванням обробник працює під час фази спливання. Щоб зареєструвати його під час захоплення, третім аргументом передають { capture: true }:
document.addEventListener(
'click',
(event) => {
console.log('Подія на фазі захоплення');
},
{ capture: true }
);Для звичайного делегування найчастіше достатньо фази спливання. Фаза захоплення потрібна в особливих випадках — наприклад, коли подію перехоплюють або зупиняють дочірні обробники.
Делегування не є обов'язковим для кожної події. Не варто застосовувати його бездумно, якщо:
елементи не мають спільного стабільного контейнера;
кожному елементу потрібна складна унікальна логіка;
подія не спливає, а використання альтернативи ускладнює код;
між контейнером і ціллю є компоненти, що зупиняють поширення події;
обробник на батьківському елементі створює зайві перевірки для дуже різних сценаріїв.
Також не слід делегувати всі події на document, якщо можна використати ближчий контейнер. Обмежений контейнер:
taskList.addEventListener('click', handler);зазвичай зрозуміліший і безпечніший, ніж глобальний:
document.addEventListener('click', handler);event.targetПомилковий варіант:
if (event.target.className === 'delete-task') {
// ...
}Він може не спрацювати, якщо всередині кнопки є інший елемент.
Надійніший варіант:
const button = event.target.closest('.delete-task');
if (!button) {
return;
}Якщо структура елемента зміниться, parentElement може вказувати вже не на потрібний вузол:
event.target.parentElement.remove();Краще явно описати очікувану структуру:
event.target.closest('[data-task-id]')?.remove();Обробник, встановлений на контейнері, зникне разом із цим контейнером. Якщо список повністю замінюється, потрібно або встановлювати обробник на новий список, або делегувати подію на стабільнішого предка.
nullclosest() може повернути null. Тому потрібно перевіряти результат перед використанням:
const task = event.target.closest('.task');
if (!task) {
return;
}
task.remove();Або використовувати optional chaining, якщо цього достатньо:
event.target.closest('.task')?.remove();stopPropagation()Зупинка поширення може перешкодити обробникам батьківських елементів. Спочатку спробуйте розв'язати задачу за допомогою перевірки селектора та раннього return.
Для більшості сценаріїв можна використовувати такий шаблон:
const container = document.querySelector('.container');
container.addEventListener('click', (event) => {
const element = event.target.closest('.target');
if (!element || !container.contains(element)) {
return;
}
// Логіка для знайденого дочірнього елемента
});Якщо обробник має реагувати на кілька типів елементів, використовуйте matches() або кілька перевірок:
container.addEventListener('click', (event) => {
const button = event.target.closest('button');
if (!button || !container.contains(button)) {
return;
}
if (button.matches('.save-button')) {
// Збереження
} else if (button.matches('.cancel-button')) {
// Скасування
}
});Делегування подій — це встановлення одного обробника на спільному контейнері замість багатьох дочірніх елементів.
Воно ґрунтується на спливанні подій.
event.target — початковий елемент події, а event.currentTarget — елемент із поточним обробником.
closest() допомагає знайти потрібний елемент навіть тоді, коли подія виникла на його внутрішньому дочірньому елементі.
Делегування автоматично працює для дочірніх елементів, створених після встановлення обробника.
Для надійності перевіряйте результат closest() і належність елемента контейнеру.
Не всі події спливають: для focus і blur часто використовують focusin і focusout.
stopPropagation() слід застосовувати обережно, оскільки він може зламати делегування.