12 листопада 2023 р.

Делегування подій

Спливання та перехоплення дозволяють нам реалізувати один з найпотужніших шаблонів обробки подій під назвою делегування подій.

Ідея в тому, що якщо у нас є багато елементів, які обробляються подібним чином, то замість того, щоб призначати обробник кожному з них, ми ставимо один обробник на їхнього спільного предка.

У обробнику ми отримуємо event.target, щоб побачити, де насправді сталася подія і обробити її.

Давайте подивимося на приклад – діаграму Ба-Гуа, що відображає стародавню китайську філософію.

Ось вона:

HTML виглядає так:

<table>
  <tr>
    <th colspan="3">Таблиця <em>Багуа</em>: Напрямок, Елемент, Колір, Значення</th>
  </tr>
  <tr>
    <td class="nw"><strong>Північний захід</strong><br>Метал<br>Срібний<br>Старійшини</td>
    <td class="n">...</td>
    <td class="ne">...</td>
  </tr>
  <tr>...Ще 2 подібні рядки...</tr>
  <tr>...Ще 2 подібні рядки...</tr>
</table>

Таблиця має лише 9 клітинок, але їх може бути 99 або 9999, не має значення.

Наше завдання – виділити клітинку <td> при кліці на неї.

Замість того, щоб призначати обробник onclick для кожного <td> (їх може бути багато) – ми налаштуємо “універсальний” обробник для елемента <table>.

Він використовуватиме event.target, щоб отримати елемент, на який клікнули, і виділити його.

Код буде виглядати наступним чином:

let selectedTd;

table.onclick = function(event) {
  let target = event.target; // де відбувся клік?

  if (target.tagName != 'TD') return; // не на TD? Тоді нас не цікавить

  highlight(target); // виділити TD
};

function highlight(td) {
  if (selectedTd) { // видалити наявне виділення, якщо таке є
    selectedTd.classList.remove('highlight');
  }
  selectedTd = td;
  selectedTd.classList.add('highlight'); // виділити новий td
}

Такому коду байдуже, скільки клітинок у таблиці. Ми можемо в будь-який час динамічно додавати/видаляти <td>, і виділення все одно працюватиме.

Але все-таки є один недолік.

Клік може статися не на <td>, а всередині нього.

У нашому випадку, якщо ми розглянемо HTML-код, ми побачимо, що всередині <td> є вкладені теги, наприклад <strong>:

<td>
  <strong>Північний захід</strong>
  ...
</td>

Як і слід було чекати, якщо клік відбувається на елементі <strong>, тоді він і стає значенням event.target.

В обробнику table.onclick ми повинні взяти event.target і з’ясувати, чи був клік всередині <td> чи ні.

Ось покращений код:

table.onclick = function(event) {
  let td = event.target.closest('td'); // (1)

  if (!td) return; // (2)

  if (!table.contains(td)) return; // (3)

  highlight(td); // (4)
};

Пояснення:

  1. Метод elem.closest(selector) повертає найближчого предка, який відповідає селектору. У нашому випадку ми шукаємо <td>, який знаходиться вище по дереву від вихідного елемента.
  2. Якщо event.target не знаходиться всередині жодного <td>, тоді виконання функції одразу завершиться, оскільки більше робити нічого.
  3. У разі вкладених таблиць event.target може бути <td>, але знаходитись за межами поточної таблиці. Тож ми перевіряємо, чи це насправді <td> нашої таблиці.
  4. І якщо це так, то виділяємо його.

В результаті ми маємо швидкий, ефективний код для виділення, який не залежить від загальної кількості <td> у таблиці.

Приклад делегування: дії в розмітці

Є й інші варіанти використання делегування подій.

Скажімо, ми хочемо створити меню з кнопками «Зберегти», «Завантажити», «Пошук» і так далі. А ще є об’єкт з методами save, load, search… Як їх поєднати?

Перше, що спадає на думку – це призначити окремий обробник кожній кнопці. Але є більш елегантне рішення. Ми можемо додати один обробник до всього меню та атрибути data-action до кожної кнопки відповідно до методів, які вони викликають:

<button data-action="save">Клікніть, щоб Зберегти</button>

Обробник читає атрибут і виконує відповідний метод. Подивіться на робочий приклад:

<div id="menu">
  <button data-action="save">Зберегти</button>
  <button data-action="load">Завантажити</button>
  <button data-action="search">Пошук</button>
</div>

<script>
  class Menu {
    constructor(elem) {
      this._elem = elem;
      elem.onclick = this.onClick.bind(this); // (*)
    }

    save() {
      alert('збереження');
    }

    load() {
      alert('завантаження');
    }

    search() {
      alert('пошук');
    }

    onClick(event) {
      let action = event.target.dataset.action;
      if (action) {
        this[action]();
      }
    };
  }

  new Menu(menu);
</script>

Зауважте, що this.onClick прив’язаний до this у (*). Це важливо, тому що інакше this в ньому посилатиметься на елемент DOM (elem), а не на об’єкт Menu, і this[action] буде не тим, який нам потрібен.

Отже, які переваги дає нам тут делегування?

  • Нам не потрібно писати код, щоб призначити обробник кожній кнопці. Достатньо створити один метод і помістити його в розмітку.
  • Структура HTML гнучка, ми можемо в будь-який момент додати/видалити кнопки.

Ми також можемо використовувати класи .action-save, .action-load, але підхід з використанням атрибутів data-action вважається семантично кращим. Крім того, його можна використовувати в правилах CSS.

Шаблон “поведінки”

Делегування подій можна також використовувати для додавання певної «поведінки» елементам декларативно, за допомогою спеціальних атрибутів та класів.

Шаблон складається з двох частин:

  1. Ми додаємо спеціальний атрибут до елемента, який описує його поведінку.
  2. За допомогою делегування ставиться один обробник на документ, що відстежує усі події і, якщо елемент має атрибут, виконує відповідну дію.

Поведінка: Лічильник

Наприклад, тут атрибут data-counter додає до кнопок поведінку: “збільшити значення при кліці”:

Лічильник: <input type="button" value="1" data-counter>
Ще один лічильник: <input type="button" value="2" data-counter>

<script>
  document.addEventListener('click', function(event) {

    if (event.target.dataset.counter != undefined) { // якщо атрибут існує...
      event.target.value++;
    }

  });
</script>

Якщо ми натиснемо кнопку – її значення збільшується. Проте тут важливі не кнопки, а загальний підхід.

Атрибутів із data-counter може бути скільки завгодно. Ми можемо в будь-який момент додати до HTML нові. Використовуючи делегування подій, ми фактично «розширили» HTML, додали атрибут, який описує нову поведінку.

Завжди використовуйте метод addEventListener для обробників на рівні документу

Коли ми присвоюємо обробник події об’єкта document, ми завжди повинні використовувати addEventListener, а не document.on<event>, оскільки останній спричинить конфлікти: нові обробники перезапишуть старі.

Для реальних проєктів вважається нормальною наявність великої кількості обробників на document, встановлених різними частинами коду.

Поведінка: Перемикач

Ще один приклад поведінки. Клікніть на елементі з атрибутом data-toggle-id, щоб показати/сховати елемент із заданим id:

<button data-toggle-id="subscribe-mail">
  Показати форму підписки
</button>

<form id="subscribe-mail" hidden>
  Ваша пошта: <input type="email">
</form>

<script>
  document.addEventListener('click', function(event) {
    let id = event.target.dataset.toggleId;
    if (!id) return;

    let elem = document.getElementById(id);

    elem.hidden = !elem.hidden;
  });
</script>

Ще раз відзначимо, що саме ми зробили. Тепер, щоб додати функціональність перемикання до елемента – навіть не потрібно знати JavaScript, достатньо просто використати атрибут data-toggle-id.

Це дуже зручно – не потрібно писати JavaScript для кожного такого елемента. Просто використовуйте поведінку. Обробник на рівні документу дозволяє працювати з будь-яким елементом сторінки.

Ми також можемо об’єднати кілька видів поведінки в одному елементі.

Шаблон «поведінка» може бути альтернативою мініфрагментам JavaScript.

Підсумки

Делегування подій – це дійсно круто! Це один з найбільш корисних шаблонів для подій DOM.

Він часто використовується для додавання однакової обробки для багатьох подібних елементів, але не тільки для цього.

Алгоритм:

  1. Додайте один обробник на контейнер.
  2. У обробнику – перевірте вихідний елемент event.target.
  3. Якщо подія відбулася всередині елемента, який нас цікавить, обробіть подію.

Переваги:

  • Спрощує ініціалізацію та економить пам’ять: не потрібно додавати багато обробників.
  • Менше коду: під час додавання або видалення елементів не потрібно додавати/видаляти обробники.
  • Модифікації DOM: ми можемо масово додавати/видаляти елементи за допомогою innerHTML тощо.

Звичайно, делегування має свої обмеження:

  • По-перше, подія повинна спливати. Деякі події не спливають. Крім того, низькорівневі обробники не повинні використовувати event.stopPropagation().
  • По-друге, делегування може збільшити навантаження на центральний процесор, оскільки обробник на рівні контейнера реагує на події в будь-якому місці контейнера, незалежно від того, цікавлять вони нас чи ні. Але зазвичай навантаження незначне, тому ми не беремо його до уваги.

Завдання

важливість: 5

Є список повідомлень із кнопками видалення [x]. Зробіть так, щоб кнопки працювали.

В результаті має працювати наступним чином:

P.S. У контейнері має бути лише один прослуховувач подій, використовуйте делегування.

Відкрити пісочницю для завдання.

важливість: 5

Створіть дерево, яке показує/приховує дочірні вузли при кліці:

Вимоги:

  • Тільки один обробник подій (використовуйте делегування)
  • Клік поза заголовком вузла (на порожньому місці) не має нічого робити.

Відкрити пісочницю для завдання.

Рішення складається з двох частин.

  1. Обертаємо кожен заголовок дерева в <span>. Тоді ми можемо додати їм CSS стилі на :hover і обробляти клік саме по тексту, тому що ширина <span> повністю співпадає з шириною тексту.
  2. Встановлюємо обробник на кореневий вузол tree та обробляємо кліки на елементах <span>, які містять заголовки.

Відкрити рішення в пісочниці.

важливість: 4

Make the table sortable: clicks on <th> elements should sort it by corresponding column.

Each <th> has the type in the attribute, like this:

<table id="grid">
  <thead>
    <tr>
      <th data-type="number">Вік</th>
      <th data-type="string">Ім’я</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>5</td>
      <td>Іван</td>
    </tr>
    <tr>
      <td>12</td>
      <td>Ганна</td>
    </tr>
    ...
  </tbody>
</table>

У наведеному вище прикладі перший стовпець містить числа, а другий – рядки. Функція сортування повинна обробляти сортування відповідно до типу.

Повинні підтримуватися лише типи "string" та "number".

Робочий приклад:

P.S. Таблиця може бути великою, з будь-якою кількістю рядків і стовпців.

Відкрити пісочницю для завдання.

важливість: 5

Створіть JS-код для поведінки спливаючої підказки.

При наведенні миші на елемент з data-tooltip, підказка має з’явитися над ним і ховатися при переході на інший елемент.

Приклад HTML з підказками:

<button data-tooltip="підказка довша за елемент">Мала кнопка</button>
<button data-tooltip="HTML<br>підказка">Ще одна кнопка</button>

Повинно працювати так:

У цьому завданні ми припускаємо, що всі елементи з data-tooltip містять лише текст всередині. Немає вкладених тегів (поки що).

Деталі:

  • Відстань між елементом і підказкою має бути 5px.
  • Підказка повинна бути відцентрована відносно елемента, якщо це можливо.
  • Підказка не повинна перетинати краї вікна. Зазвичай вона має бути над елементом, але якщо елемент знаходиться у верхній частині сторінки і немає місця для підказки, то під ним.
  • Вміст підказки вказується в атрибуті data-tooltip. Це може бути довільний HTML.

Тут вам знадобляться дві події:

  • mouseover спрацьовує, коли курсор переходить на елемент.
  • mouseout спрацьовує, коли курсор покидає елемент.

Будь ласка, використовуйте делегування подій: налаштуйте два обробники на document, щоб відстежувати всі “заходи” і “виходи” курсору на елементи з атрибутом data-tooltip і керувати підказками звідти.

Після реалізації поведінки люди, навіть не знайомі з JavaScript, зможуть додавати підказки до елементів.

P.S. Одночасно може відображатися лише одна підказка.

Відкрити пісочницю для завдання.

Навчальна карта