Вивчай

Події — частина 2

JavaScript event bubbling — камінь кинутий у воду створює концентричні хвиліJavaScript event bubbling — камінь кинутий у воду створює концентричні хвилі

У минулому уроці ми навчилися додавати обробники подій та працювати з об'єктом event. Сьогодні розберемо як саме події рухаються по DOM-дереву, вивчимо потужний патерн event delegation та навчимося працювати з клавіатурними подіями.


Event Bubbling (спливання подій)

Коли ти клікаєш на елемент, подія не залишається тільки на ньому. Вона спливає вгору по DOM-дереву — від елемента до його батька, потім до батька батька, і так до document:

<div class="grandparent">
  <div class="parent">
    <button class="child">Натисни мене</button>
  </div>
</div>
document.querySelector(".grandparent").addEventListener("click", () => {
  console.log("Grandparent клік");
});

document.querySelector(".parent").addEventListener("click", () => {
  console.log("Parent клік");
});

document.querySelector(".child").addEventListener("click", () => {
  console.log("Child клік");
});

// Клік на кнопку виведе ВСІ три:
// "Child клік"
// "Parent клік"
// "Grandparent клік"

Подія "спливає" як бульбашка у воді — від найглибшого елемента до найвищого. Це і є bubbling.

Event Bubbling: подія спливає від button вгору по DOM-деревуEvent Bubbling: подія спливає від button вгору по DOM-дереву

Порада

Bubbling — це не баг, а корисна особливість. Саме завдяки спливанню працює event delegation, який ми розглянемо нижче.


stopPropagation — зупинити спливання

Якщо потрібно зупинити спливання:

document.querySelector(".child").addEventListener("click", (event) => {
  event.stopPropagation(); // подія не піде далі вгору
  console.log("Тільки child");
});

document.querySelector(".parent").addEventListener("click", () => {
  console.log("Parent — не спрацює при кліку на child");
});
Увага

Використовуй stopPropagation обережно і тільки коли дійсно потрібно. Зупинка спливання може зламати інші обробники, які покладаються на bubbling (наприклад, аналітику або event delegation).


Event Capturing (перехоплення)

Насправді подія проходить три фази:

Три фази події: Capturing, Target, BubblingТри фази події: Capturing, Target, Bubbling

  1. Capturing (перехоплення) — подія йде зверху вниз: document -> html -> body -> ... -> target
  2. Target — подія досягає цільового елемента
  3. Bubbling (спливання) — подія йде знизу вгору: target -> ... -> body -> html -> document

За замовчуванням обробники спрацьовують на фазі bubbling. Щоб слухати на фазі capturing, передай третій аргумент true:

const parent = document.querySelector(".parent");
const child = document.querySelector(".child");

// Фаза capturing (зверху вниз)
parent.addEventListener("click", () => {
  console.log("Parent — capturing");
}, true);

// Фаза bubbling (знизу вгору) — за замовчуванням
parent.addEventListener("click", () => {
  console.log("Parent — bubbling");
});

child.addEventListener("click", () => {
  console.log("Child — target");
});

// Клік на child:
// "Parent — capturing"   (фаза 1: зверху вниз)
// "Child — target"       (фаза 2: на цільовому елементі)
// "Parent — bubbling"    (фаза 3: знизу вгору)
Інфо

На практиці capturing використовується рідко. Але розуміти три фази важливо для того, щоб знати, чому обробники іноді спрацьовують у несподіваному порядку.


Event Delegation (делегування подій)

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

Проблема: обробник на кожний елемент

// Поганий підхід — обробник на кожен елемент
const items = document.querySelectorAll(".item");

items.forEach((item) => {
  item.addEventListener("click", () => {
    console.log(item.textContent);
  });
});

// Проблеми:
// 1. 100 елементів = 100 обробників у пам'яті
// 2. Нові елементи (додані через JS) не матимуть обробника!

Рішення: event delegation

// Хороший підхід — один обробник на батьківський елемент
const list = document.querySelector(".list");

list.addEventListener("click", (event) => {
  // Перевіряємо, чи клікнули саме на елемент списку
  if (event.target.classList.contains("item")) {
    console.log(event.target.textContent);
  }
});

Завдяки bubbling клік на будь-якому .item спливе до .list, де його перехопить наш обробник. event.target покаже, на якому саме елементі клікнули.

Переваги delegation

const list = document.querySelector(".list");

// Один обробник працює для ВСІХ елементів — навіть тих, що додадуться пізніше
list.addEventListener("click", (event) => {
  if (event.target.classList.contains("item")) {
    event.target.classList.toggle("item--selected");
  }
});

// Додаємо новий елемент — обробник автоматично працює для нього!
const newItem = document.createElement("li");
newItem.textContent = "Новий елемент";
newItem.classList.add("item");
list.appendChild(newItem);
Порада

Event delegation працює для динамічних списків. Не треба додавати обробники кожному новому елементу. Це також економить пам'ять.

closest() — для вкладених елементів

Якщо всередині .item є інші елементи, event.target може вказувати на дочірній елемент. Метод closest() допоможе:

<ul class="list">
  <li class="item">
    <span class="item__name">Яблуко</span>
    <span class="item__price">25 грн</span>
  </li>
</ul>
list.addEventListener("click", (event) => {
  // Знаходить найближчий батьківський .item (або сам елемент)
  const item = event.target.closest(".item");

  if (item) {
    console.log("Клік на:", item.querySelector(".item__name").textContent);
  }
});

Keyboard Events (клавіатурні події)

ПодіяКоли спрацьовує
keydownКлавішу натиснули (спрацьовує першою)
keyupКлавішу відпустили
document.addEventListener("keydown", (event) => {
  console.log("Клавіша:", event.key);   // "a", "Enter", "Escape", "ArrowUp"
  console.log("Код:", event.code);      // "KeyA", "Enter", "Escape", "ArrowUp"
});

event.key vs event.code

ВластивістьЩо показуєПриклад
event.keyСимвол, який вводиться"a", "A", "Enter"
event.codeФізичну клавішу"KeyA", "KeyA", "Enter"
// event.key залежить від розкладки клавіатури
// event.code — завжди однаковий для фізичної клавіші
document.addEventListener("keydown", (event) => {
  if (event.key === "Escape") {
    console.log("Натиснули Escape — закриваємо модальне вікно");
  }

  if (event.key === "Enter") {
    console.log("Натиснули Enter — підтверджуємо дію");
  }
});

Клавіші-модифікатори

document.addEventListener("keydown", (event) => {
  if (event.ctrlKey && event.key === "s") {
    console.log("Ctrl+S — зберігаємо!");
  }

  if (event.shiftKey && event.key === "Enter") {
    console.log("Shift+Enter — новий рядок");
  }
});
ВластивістьКлавіша
event.ctrlKeyCtrl (або Cmd на Mac)
event.shiftKeyShift
event.altKeyAlt (або Option на Mac)
event.metaKeyCmd на Mac / Win на Windows

preventDefault — скасування дії за замовчуванням

Деякі події мають дію за замовчуванням від браузера. preventDefault() скасовує її:

// Клік на посилання — за замовчуванням браузер переходить за href
const link = document.querySelector("a");

link.addEventListener("click", (event) => {
  event.preventDefault(); // не переходимо за посиланням
  console.log("Клік на посилання, але переходу не буде");
});
// Submit форми — за замовчуванням перезавантажує сторінку
const form = document.querySelector("form");

form.addEventListener("submit", (event) => {
  event.preventDefault(); // не перезавантажуємо сторінку
  console.log("Обробляємо форму через JavaScript");
});
Порада

preventDefault() дуже часто використовується з формами. У сучасних додатках ми обробляємо форми через JavaScript (відправляємо дані через fetch), а не через стандартну перезавантаження сторінки.


Практика: делегований список з клавіатурою

<div class="todo-app">
  <input type="text" class="todo-input" placeholder="Нове завдання..." />
  <ul class="todo-list"></ul>
</div>
const input = document.querySelector(".todo-input");
const list = document.querySelector(".todo-list");

// Додавання завдання по Enter
input.addEventListener("keydown", (event) => {
  if (event.key === "Enter" && input.value.trim() !== "") {
    const li = document.createElement("li");
    li.classList.add("todo-item");
    li.textContent = input.value.trim();
    list.appendChild(li);
    input.value = "";
  }
});

// Delegation — видалення по кліку (працює і для нових елементів)
list.addEventListener("click", (event) => {
  if (event.target.classList.contains("todo-item")) {
    event.target.classList.toggle("todo-item--done");
  }
});

// Закриття по Escape
document.addEventListener("keydown", (event) => {
  if (event.key === "Escape") {
    input.value = "";
    input.blur(); // знімаємо фокус з поля
  }
});

Підсумок

  • Bubbling — подія спливає від target вгору по DOM-дереву
  • stopPropagation() — зупиняє спливання (використовуй обережно)
  • Capturing — зворотна фаза (зверху вниз), вмикається третім аргументом true
  • Event delegation — один обробник на батьку замість багатьох на дочірніх
  • closest() — знаходить найближчий батьківський елемент за селектором
  • keydown / keyup — клавіатурні події, event.key для символу
  • preventDefault() — скасовує стандартну дію браузера (перехід, submit)

Що далі?

У наступному уроці вивчимо форми та localStorage — як отримувати дані з форм та зберігати інформацію в браузері.

Інфо

Корисні посилання: