Пошук уроків, статей та іншого контенту
Розберете типові проблеми подій, порядок виконання обробників і способи налагодження складної взаємодії.
Складна взаємодія в браузері часто ламається не через одну неправильну умову, а через поєднання кількох механізмів:
подія проходить кілька фаз поширення;
один елемент може мати багато обробників;
обробники можуть змінювати DOM і стан застосунку;
частина коду виконується синхронно, а частина — після завершення поточного стека;
браузер самостійно оптимізує прокручування, введення та промальовування;
події можуть дублюватися через повторне встановлення обробників.
Для ефективного налагодження потрібно розділяти три питання:
Чи виникла подія?
Які обробники її отримали і в якому порядку?
Які зміни стану, DOM або стилів спричинив кожен обробник?
Коли подія виникає на елементі, браузер визначає:
target — елемент, на якому фактично виникла подія;
currentTarget — елемент, обробник якого виконується зараз;
eventPhase — поточну фазу;
bubbles — чи поширюється подія від цілі вгору;
defaultPrevented — чи було скасовано стандартну дію.
Подія проходить такі фази:
Capturing — від window до батьківського елемента цілі.
Target — обробники на самій цілі.
Bubbling — від цілі назад до window.
window
↓ capturing
document
↓
parent
↓
target
↑
parent
↑ bubbling
document
↑
windowОбробник у фазі захоплення реєструють третім аргументом:
element.addEventListener("click", handler, { capture: true });Якщо capture не вказано або він дорівнює false, обробник працює у фазі спливання.
target і currentTargetЦі властивості часто плутають:
event.target не змінюється під час поширення події;
event.currentTarget змінюється для кожного обробника.
<div id="panel">
<button id="saveButton">Зберегти</button>
</div>
<script>
const panel = document.querySelector("#panel");
panel.addEventListener("click", (event) => {
console.log("target:", event.target.id);
console.log("currentTarget:", event.currentTarget.id);
});
</script>Якщо натиснути кнопку, обидва значення будуть різними:
target: saveButton
currentTarget: panelЦе основа делегування подій: обробник встановлюють на спільному предку, а конкретний елемент визначають через event.target або closest().
<div id="outer">
<button id="inner">Натиснути</button>
</div>
<script>
const outer = document.querySelector("#outer");
const inner = document.querySelector("#inner");
outer.addEventListener("click", () => {
console.log("outer capture");
}, { capture: true });
inner.addEventListener("click", () => {
console.log("inner target");
});
outer.addEventListener("click", () => {
console.log("outer bubble");
});
document.addEventListener("click", () => {
console.log("document capture");
}, { capture: true });
</script>Під час натискання буде виведено:
document capture
outer capture
inner target
outer bubbleПорядок залежить не лише від DOM-ієрархії, а й від фази, у якій зареєстровано обробник.
Якщо на одному елементі зареєстровано кілька обробників для тієї самої події та фази, вони виконуються в порядку реєстрації.
const button = document.querySelector("button");
button.addEventListener("click", () => {
console.log("перший");
});
button.addEventListener("click", () => {
console.log("другий");
});
button.addEventListener("click", () => {
console.log("третій");
});Результат:
перший
другий
третійВодночас порядок між різними підсистемами може бути неочевидним. Наприклад, один обробник може змінити DOM, інший — оновити стан, а третій — запустити асинхронну операцію.
stopPropagation() і stopImmediatePropagation()stopPropagation() зупиняє перехід події до інших елементів, але не зупиняє інші обробники на поточному елементі.
button.addEventListener("click", (event) => {
event.stopPropagation();
console.log("цей обробник виконається");
});
button.addEventListener("click", () => {
console.log("і цей також виконається");
});stopImmediatePropagation() зупиняє і подальше поширення, і виконання наступних обробників на поточному елементі.
button.addEventListener("click", (event) => {
event.stopImmediatePropagation();
console.log("наступні обробники не виконаються");
});Зупиняти поширення без чіткої причини небезпечно: це може непомітно зламати обробники на предках або інтеграції інших компонентів.
Делегування дає змогу обробляти події динамічних дочірніх елементів одним обробником.
<ul id="tasks">
<li data-id="101">
<span>Перша задача</span>
<button data-action="complete">Готово</button>
</li>
<li data-id="102">
<span>Друга задача</span>
<button data-action="complete">Готово</button>
</li>
</ul>
<script>
const tasks = document.querySelector("#tasks");
tasks.addEventListener("click", (event) => {
const button = event.target.closest("button[data-action]");
if (!button || !tasks.contains(button)) {
return;
}
const item = button.closest("li[data-id]");
const taskId = item.dataset.id;
item.classList.toggle("completed");
console.log(`Задача ${taskId} змінена`);
});
</script>Перевірка tasks.contains(button) важлива, якщо в DOM можуть існувати вкладені структури або подія прийшла з іншої частини сторінки.
event.targetЯкщо всередині кнопки є span, event.target може бути саме span, а не кнопка:
<button>
<span>Видалити</span>
</button>Надійніший варіант:
const button = event.target.closest("button");Але closest() може повернути елемент, який не належить потрібному контейнеру. Тому для делегування варто перевіряти межі компонента.
Щоб видалити обробник, потрібно передати ту саму функцію та ті самі параметри capture.
function handleResize() {
console.log(window.innerWidth);
}
window.addEventListener("resize", handleResize);
window.removeEventListener("resize", handleResize);Це не спрацює:
window.addEventListener("resize", () => {
console.log(window.innerWidth);
});
window.removeEventListener("resize", () => {
console.log(window.innerWidth);
});Дві стрілкові функції створюються як різні об'єкти.
onceЯкщо обробник потрібен лише один раз, використовуйте once:
const openButton = document.querySelector("#open");
openButton.addEventListener("click", () => {
console.log("Це повідомлення з'явиться лише один раз");
}, { once: true });Це надійніше, ніж вручну видаляти обробник усередині нього.
AbortControllerОдин контролер може керувати кількома обробниками:
const controller = new AbortController();
const { signal } = controller;
window.addEventListener("resize", handleResize, { signal });
document.addEventListener("keydown", handleKeydown, { signal });
function handleResize() {
console.log("зміна розміру");
}
function handleKeydown(event) {
console.log("натиснута клавіша:", event.key);
}
function destroyComponent() {
controller.abort();
}Після abort() обидва обробники буде видалено. Це особливо корисно для компонентів, які монтуються та демонтуються багато разів.
preventDefault() і стандартна діяpreventDefault() скасовує стандартну дію браузера, але не зупиняє поширення події.
form.addEventListener("submit", (event) => {
event.preventDefault();
console.log("Форму не буде надіслано стандартним способом");
});Поширена помилка — викликати preventDefault() для події, яка не має стандартної дії, або в обробнику, що не призначений для скасування поведінки.
Перевірити результат можна так:
console.log(event.defaultPrevented);Іноді важливо викликати preventDefault() до асинхронної операції:
form.addEventListener("submit", async (event) => {
event.preventDefault();
const data = new FormData(form);
await saveData(data);
});Не слід покладатися на виклик preventDefault() після await: браузер міг уже продовжити стандартну дію.
Для подій, пов'язаних із прокручуванням і дотиками, браузер може використовувати passive: true.
window.addEventListener("touchstart", handleTouch, {
passive: true
});
function handleTouch() {
// У цьому обробнику не можна викликати preventDefault().
}Пасивний обробник повідомляє браузеру, що код не буде блокувати стандартну прокрутку. Це може покращити плавність взаємодії.
Якщо потрібно скасувати стандартну дію, пасивність слід вимкнути:
window.addEventListener("touchmove", (event) => {
event.preventDefault();
}, {
passive: false
});Таке рішення потрібно застосовувати обережно: блокування прокручування на кожній події дотику легко погіршує взаємодію.
Для складної події корисно тимчасово встановити обробники на різних рівнях DOM:
function trace(label, options = {}) {
return (event) => {
console.log(label, {
phase: event.eventPhase,
target: event.target,
currentTarget: event.currentTarget,
defaultPrevented: event.defaultPrevented,
timeStamp: event.timeStamp
});
};
}
document.addEventListener("click", trace("document capture"), {
capture: true
});
document.addEventListener("click", trace("document bubble"));Значення eventPhase:
1 — capturing;
2 — target;
3 — bubbling.
Для тимчасового налагодження можна використовувати console.group():
button.addEventListener("click", (event) => {
console.group("Обробка натискання");
console.log("target:", event.target);
console.log("currentTarget:", event.currentTarget);
console.log("phase:", event.eventPhase);
console.trace("Стек викликів");
console.groupEnd();
});console.trace() показує стек синхронного виклику, але не пояснює майбутні асинхронні кроки. Для них використовуйте окремі повідомлення до та після await.
У DevTools браузера можна:
призупинити виконання на подіях конкретного типу;
переглянути всі обробники обраного елемента;
перевірити слухачів у панелі елемента;
простежити стек викликів;
дослідити зміни DOM і стилів.
Якщо подія виконується багато разів, безумовна точка зупинки стане незручною. У такому разі використовуйте умовну точку або тимчасове логування з лічильником.
let count = 0;
window.addEventListener("mousemove", (event) => {
count += 1;
if (count % 20 === 0) {
console.log("Кожна 20-та подія", {
x: event.clientX,
y: event.clientY
});
}
});Обробник події виконується синхронно. Але Promise.then(), queueMicrotask() і setTimeout() продовжують виконання в різний час.
button.addEventListener("click", () => {
console.log("1. початок обробника");
queueMicrotask(() => {
console.log("3. мікрозадача");
});
setTimeout(() => {
console.log("4. таймер");
}, 0);
console.log("2. кінець обробника");
});Результат:
1. початок обробника
2. кінець обробника
3. мікрозадача
4. таймерМікрозадачі виконуються після завершення поточного синхронного коду, але до наступної macrotask, якою в цьому прикладі є таймер.
Це може призвести до неочікуваного стану:
let value = 0;
button.addEventListener("click", () => {
value = 1;
Promise.resolve().then(() => {
console.log("мікрозадача бачить:", value);
});
value = 2;
});Мікрозадача побачить 2, оскільки вона виконується після всього поточного обробника.
Якщо дія не повинна запускатися паралельно, потрібен захист від повторного запуску:
const button = document.querySelector("#save");
let isSaving = false;
button.addEventListener("click", async () => {
if (isSaving) {
return;
}
isSaving = true;
button.disabled = true;
try {
await saveData();
console.log("Дані збережено");
} catch (error) {
console.error("Помилка збереження:", error);
} finally {
isSaving = false;
button.disabled = false;
}
});
async function saveData() {
await new Promise((resolve) => setTimeout(resolve, 500));
}Саме встановлення isSaving = true має відбутися до першої асинхронної операції. Інакше два швидкі обробники можуть пройти перевірку одночасно.
Під час пошуку за введеним текстом старіший запит може завершитися після новішого й перезаписати правильний результат.
const input = document.querySelector("#search");
let requestId = 0;
input.addEventListener("input", async (event) => {
const currentRequestId = ++requestId;
const query = event.target.value.trim();
if (!query) {
return;
}
const result = await search(query);
if (currentRequestId !== requestId) {
return;
}
renderResults(result);
});
async function search(query) {
await new Promise((resolve) => setTimeout(resolve, Math.random() * 500));
return [`Результат для "${query}"`];
}
function renderResults(results) {
console.log(results);
}Лічильник версії дозволяє проігнорувати застарілий результат.
Для запитів, які підтримують скасування, краще використовувати AbortController:
let controller = null;
input.addEventListener("input", async (event) => {
controller?.abort();
controller = new AbortController();
const query = event.target.value.trim();
if (!query) {
return;
}
try {
const response = await fetch(`/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal
});
const data = await response.json();
renderResults(data);
} catch (error) {
if (error.name !== "AbortError") {
console.error(error);
}
}
});Події scroll, resize, pointermove, mousemove та input можуть надходити дуже часто. Обробник кожної події не повинен:
виконувати складні обчислення;
багаторазово читати й змінювати layout;
створювати велику кількість об'єктів;
запускати зайві мережеві запити;
спричиняти непотрібні перерендерення.
requestAnimationFrameДля візуальних оновлень зручно синхронізувати роботу з кадром браузера:
let frameRequested = false;
let latestX = 0;
let latestY = 0;
window.addEventListener("pointermove", (event) => {
latestX = event.clientX;
latestY = event.clientY;
if (frameRequested) {
return;
}
frameRequested = true;
requestAnimationFrame(() => {
frameRequested = false;
document.documentElement.style.setProperty(
"--pointer-x",
`${latestX}px`
);
document.documentElement.style.setProperty(
"--pointer-y",
`${latestY}px`
);
});
});За один кадр буде виконано максимум одне оновлення, навіть якщо за цей час надійде багато подій.
debounce запускає функцію після паузи в потоці подій. Це корисно для пошуку, валідації або збереження після завершення введення.
function debounce(callback, delay) {
let timerId;
return (...args) => {
clearTimeout(timerId);
timerId = setTimeout(() => {
callback(...args);
}, delay);
};
}
const handleSearch = debounce((event) => {
console.log("Пошук:", event.target.value);
}, 300);
input.addEventListener("input", handleSearch);throttle дозволяє виконувати функцію не частіше визначеного інтервалу. Це може бути корисно для аналітики або неперервного відстеження стану.
function throttle(callback, interval) {
let lastTime = 0;
return (...args) => {
const now = performance.now();
if (now - lastTime < interval) {
return;
}
lastTime = now;
callback(...args);
};
}
window.addEventListener("scroll", throttle(() => {
console.log("Поточна позиція:", window.scrollY);
}, 100), { passive: true });Для анімацій зазвичай краще використовувати requestAnimationFrame, а не довільний інтервал у мілісекундах.
Layout thrashing виникає, коли код чергує читання та запис геометрії DOM:
for (const item of items) {
const width = item.offsetWidth;
item.style.width = `${width + 10}px`;
}Після запису стилю наступне читання може змусити браузер синхронно перерахувати layout.
Краще спочатку виконати всі читання, а потім усі записи:
const measurements = items.map((item) => item.offsetWidth);
items.forEach((item, index) => {
item.style.width = `${measurements[index] + 10}px`;
});Ще краще — змінювати клас або CSS-змінну, якщо це можливо:
document.querySelector("#panel").classList.add("expanded");Для позиціонування та анімацій перевагу часто мають властивості transform і opacity, оскільки вони зазвичай не вимагають повторного layout усієї сторінки.
Обробка натискання миші не повинна бути єдиним способом взаємодії. Інтерактивні елементи слід створювати семантично:
<button type="button" id="menuButton">Меню</button>Такий елемент уже підтримує клавіатуру, фокус і стандартну поведінку. Не варто замінювати кнопку на div лише через стилізацію.
Якщо потрібно обробляти клавіатурну подію, перевіряйте клавішу та не ламайте стандартну поведінку без потреби:
document.addEventListener("keydown", (event) => {
if (event.key !== "Escape") {
return;
}
closeDialog();
});
function closeDialog() {
console.log("Діалог закрито");
}Для keydown важливо враховувати:
event.key — логічна клавіша, наприклад "Escape" або "Enter";
event.code — фізична клавіша, наприклад "KeyA";
повторне спрацювання під час утримання клавіші — event.repeat.
document.addEventListener("keydown", (event) => {
if (event.key === "ArrowDown" && !event.repeat) {
console.log("Перше натискання стрілки вниз");
}
});Під час оптимізації не слід покладатися лише на відчуття. Використовуйте вимірювання:
function measure(name, callback) {
const start = performance.now();
try {
return callback();
} finally {
const duration = performance.now() - start;
console.log(`${name}: ${duration.toFixed(2)} мс`);
}
}
button.addEventListener("click", () => {
measure("Оновлення списку", () => {
updateList();
});
});
function updateList() {
const fragment = document.createDocumentFragment();
for (let index = 0; index < 1000; index += 1) {
const item = document.createElement("li");
item.textContent = `Елемент ${index}`;
fragment.append(item);
}
document.querySelector("ul").replaceChildren(fragment);
}Для довгих завдань можна використати PerformanceObserver:
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log("Довге завдання:", entry.duration.toFixed(2), "мс");
}
});
observer.observe({ type: "longtask", buffered: true });Довгий обробник блокує головний потік і затримує:
введення користувача;
виконання інших обробників;
обчислення стилів;
промальовування;
виконання таймерів та інших задач.
Якщо роботу не можна виконати швидко, її варто розділити на частини або перенести у Web Worker, якщо вона не потребує безпосереднього доступу до DOM.
Нижче наведено компонент списку, який:
використовує делегування;
підтримує динамічно створені елементи;
не допускає повторного збереження;
очищає обробники через AbortController;
трасує важливі дії.
<form id="taskForm">
<input id="taskInput" name="task" required>
<button type="submit">Додати</button>
</form>
<ul id="taskList"></ul>
<script>
const form = document.querySelector("#taskForm");
const input = document.querySelector("#taskInput");
const list = document.querySelector("#taskList");
const controller = new AbortController();
const { signal } = controller;
let nextId = 1;
let isSubmitting = false;
form.addEventListener("submit", handleSubmit, { signal });
list.addEventListener("click", handleListClick, { signal });
async function handleSubmit(event) {
event.preventDefault();
if (isSubmitting) {
return;
}
const title = input.value.trim();
if (!title) {
return;
}
isSubmitting = true;
const submitButton = form.querySelector("button");
submitButton.disabled = true;
try {
const task = await createTask(title);
appendTask(task);
form.reset();
input.focus();
} catch (error) {
console.error("Не вдалося створити задачу:", error);
} finally {
isSubmitting = false;
submitButton.disabled = false;
}
}
function handleListClick(event) {
const deleteButton = event.target.closest("[data-action='delete']");
if (!deleteButton || !list.contains(deleteButton)) {
return;
}
const item = deleteButton.closest("[data-task-id]");
if (!item) {
return;
}
console.debug("Видалення задачі", {
id: item.dataset.taskId,
target: event.target,
currentTarget: event.currentTarget
});
item.remove();
}
function appendTask(task) {
const item = document.createElement("li");
item.dataset.taskId = task.id;
const title = document.createElement("span");
title.textContent = task.title;
const deleteButton = document.createElement("button");
deleteButton.type = "button";
deleteButton.dataset.action = "delete";
deleteButton.textContent = "Видалити";
item.append(title, " ", deleteButton);
list.append(item);
}
async function createTask(title) {
await new Promise((resolve) => {
setTimeout(resolve, 300);
});
return {
id: nextId++,
title
};
}
// Викликайте цю функцію під час демонтування компонента.
function destroy() {
controller.abort();
}
</script>Якщо функцію ініціалізації викликають кілька разів, обробник може виконуватися стільки ж разів.
Проблемний код:
function init() {
button.addEventListener("click", handleClick);
}Можливі рішення:
викликати init() лише один раз;
мати функцію destroy();
використовувати AbortController;
реєструвати обробник із { once: true }, якщо це відповідає логіці.
Функцію, яку потрібно видалити, зберігайте в змінній або оголошуйте окремо.
stopPropagation()Це приховує проблему замість того, щоб правильно організувати межі компонента. Спочатку перевірте делегування, селектори та фазу події.
target і currentTargetУ делегованому обробнику currentTarget — це контейнер, а не натиснута кнопка.
preventDefault() без перевіркиСкасування стандартної поведінки може зламати:
надсилання форми;
навігацію;
прокручування;
введення тексту;
роботу клавіатури.
Обробник, який виконується сотні мілісекунд, блокує весь головний потік. Вимірюйте час і розділяйте роботу на менші частини.
Старий запит або застарілий обробник не повинен змінювати інтерфейс після того, як стан уже оновлено новішою операцією.
Чергування getBoundingClientRect(), offsetWidth та змін стилів може спричинити дорогі синхронні перерахунки.
Визначте, яка саме подія запускає проблему.
Перевірте target, currentTarget, eventPhase і defaultPrevented.
Додайте логування на ціль, батьківський елемент і document.
Перевірте, чи не зареєстровано обробник кілька разів.
Перевірте capture, once, passive і signal.
З'ясуйте, чи не зупиняється подія через stopPropagation().
Перевірте порядок синхронного коду, мікрозадач і таймерів.
Додайте захист від повторних асинхронних операцій.
Виміряйте тривалість обробника через performance.now().
Перевірте, чи немає зайвих змін DOM або layout.
Подія проходить фази захоплення, цілі та спливання.
target — джерело події, а currentTarget — елемент поточного обробника.
Порядок виконання залежить від DOM-ієрархії, фази та порядку реєстрації.
stopPropagation() і stopImmediatePropagation() мають різний масштаб дії.
Делегування спрощує роботу з динамічним DOM, але потребує перевірки меж компонента.
once, AbortController і правильне збереження посилань на функції допомагають керувати життєвим циклом.
preventDefault() скасовує стандартну дію, але не зупиняє поширення.
Для частих подій використовуйте requestAnimationFrame, debounce або throttle залежно від задачі.
Асинхронні обробники можуть створювати гонки та повторні операції.
Оптимізацію потрібно підтверджувати вимірюваннями, а не лише візуальними спостереженнями.