Це дʼелегантно! Або що можна робити з bubbling та capturing

Дана стаття розрахована на початківців і має на меті познайомити їх з основними ідеями, описаними в матеріялі. Майте бога в серці, не намагайтесь звернути мою увагу на те, що я не написав про те, чи про інше. Цей матеріял містить рівно те і рівно стільки, скільки я планував написати. Дякую.

Веброзробка в основі своїй покликана для створення користувацьких інтерфейсів для взаємодії користувача з вашим продуктом. І складаються ці інтерфейси з різноманітних елементів, однак саме інтерфейсами роблять їх саме різноманітні кнопочки, інпути та інші елементи, які цю саму взаємодію й забезпечують. А інформацію про саму взаємодію невпинно та сумлінно переносять сотні DOM-подій, що створюються бравзером у відповідь на усі дії користувача.

Тицьнув мишкою? Тримай подію. Написав непристойність у полі введення повідомлення? Тримай цілий мішок різноманітних подій. Це все відбувається в реальному часі, і бравзер створює просто нереальну кількість подій у відповідь на кожнісіньку дію користувача. Однак ми не помічаємо цього, бо на більшість цих подій не створено обробники, та й самі події це частина DOM-інтерфейсу, робота якого надійно прихована від сторонніх очей під капотом.

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

document.querySelector('button').forEach(button => {
  button.addEventListener('click', event => {…})
})

Якщо у вас буде 1 000 кнопок, то відповідно й створиться 1 000 екземплярів обробника. І воно хоч і не те, аби багацько памʼяті займало, але, як-то кажуть, байт до байта, то й гігабайт назбирається.

Або ще можна просто назбирати зайвого сміття, буквально — якщо не прибирати правильно обробники подій, то вони будуть просто нагромаджуватися в памʼяті, спричиняючи так звані протікання памʼяті, коли там залишаються дані, які неможливо прибрати.

button.addEventListener('click', () => {…});
document.body.innerHTML = '';

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

А от нині давайте розберемо один підхід, який дозволяє уникнути вищезгаданих проблем, а саме — делегацію подій.

Для початку просте пояснення, що коїться в момент, до прикладу, натискання кнопки. Отже, бравзер чудово знає, хто відповідальний за подію, і створює обʼєкт DOM Event, однак замість того, аби обмежити його видимість лише цим елементом, він прицільно кидає цю подію від самісінького вершечка DOM-дерева аж до нашої кнопки. І обʼєкт зі свистом пролітає крізь усі батьківські елементи, кожен з яких має нагоду на нього відреагувати, так би мовити, «зловити», або ж capture. І так, ця фаза називається capturing phase.

Якщо коротко, можна уявити собі це ніби ви живете на першому поверсі хмарочоса, а на сотому поверсі працює піцерія, з якої ви замовили собі доставку. Доставлять вам попоїсти ліфтом, однак зважайте, що на кожному поверсі ліфт можуть зупинити і поцікавитись, а що ж там в коробці їде і куди. І кожен буде знати, що це саме ваше замовлення, бо на коробці писатиме великими літерами ваше імʼя. Що прикро — на кожному з поверхів зможуть перехопити вашу піцу, використавши простеньку кодову фразу:

event.stopPropagation()

І тоді ви свого обіду не дочекаєтесь.

Якщо ж таки дочекаєтесь, то тоді розпочнеться наступна фаза — bubbling phase, або ж фаза спливання події, яку можна для зручности називати підняттям події. Це якби ви робили замовлення не телефоном, а передавали записку тим же ліфтом. І так само на кожному поверсі вашу записку можуть перехопити і з цікавістю перечитати. Або й не пропустити далі за допомогою тієї самої кодової фрази.

Єдиний момент, що цей приклад трохи притягнутий за вуха, бо на ділі спочатку спрацьовує фаза спуску події, а вже потім — підйому. Тут швидше підійшла би аналогія з, наприклад, Еціо Авдиторе чи Едвардом Кенвеєм, що виконує стрибок віри до своєї жертви, а потім, після виконання завдання, намагається втекти від сторожі назад на вежу.

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

Тому варто цей процес оптимізувати, вірно? І замість обробляти усі замовлення на особистому рівні чи не краще це робити якось узагальнено? Звичайно ж.

Ілюстрація capturing phase

Приблизно так я уявляю собі capturing phase з точки зору обʼєкта події

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

Всі знають, що таке TODO-list, тому не вдаватимусь в деталі. Зазначу лише, що картки там зазвичай структурно і функціонально однакові і вміють робити одне й те саме. Тому нас не дуже цікавить, яка саме кнопка ініціювала подію, нас цікавить, як на це правильно зреагувати.

Ось дуже спрощений приклад картки:

<li>
  <button>Редагувати</button>
  <button>Видалити</button>
  <button>Завершити</button>
</li>

І карток може бути прямо багацько. І кнопок, відповідно, теж. Перше, що нам прийде в голову, це зробити щось таке:

<li id=":id">
  <button id="edit:id">Редагувати</button>
  <button id="delete:id">Видалити</button>
  <button id="complete:id">Завершити</button>
</li>

<script>
  document.querySelectorAll('li').forEach(li => {
    const id = li.id;
    document.getElementById(`edit:${id}`, () => {…})
    document.getElementById(`delete:${id}`, () => {…})
    document.getElementById(`complete:${id}`, () => {…})
  });
</script>

Чи це буде працювати? Абсолютно так! Чи буде це створювати проблеми, розглянуті вище? Так само, абсолютно так! Тож, як їх уникнути? А дуже просто.

Для цього нам потрібно додати обробник подій на батьківський елемент, і вже там, централізовано, обробляти ті самі кліки. Я відразу покажу фінальний код, а тоді розберемося, що до чого:

<ul id="todoList">
  …
  <li id=":id">
    <button data-action="edit">Редагувати</button>
    <button data-action="delete">Видалити</button>
    <button data-action="complete">Завершити</button>
  </li>
  …
</ul>

<script>
  function edit() {}
  function remove() {}
  function complete() {}

  document.getElementById('todoList').addEventListener('click', event => {
    const action = event.target.dataset.action;
    const todoItem = event.target.closest('li');
    switch (action) {
      case 'edit':
        edit(todoItem);
        return;
      case 'delete':
        remove(todoItem);
        return;
      case 'complete':
        complete(todoItem);
        return;
      default:
        return;
    }
  });
</script>
  1. Обробник тепер на одному елементі — батьківському ul.
  2. Для визначення конкретного елемента списку мені достатньо узяти батьківський елемент кнопки, що була натиснута.
  3. Тепер мене не цікавить, яка саме кнопка була натиснута, мене цікавить лише її дія, action, та батьківський елемент списку, з яким ми й будемо щось робити.
  4. Логіка роботи з елементом тепер в окремих функціях замість безпосередньо в обробниках, що дозволяє відокремити її від поведінки подій в принципі, і перевикористати деінде.
  5. Сам обробник не залежить на конкретний елемент списку, для нього неважливо, скільки тудушок зараз є, чи видалили, а чи додали якийсь елемент. Він суто приймає сигнал і виконує дії з ініціатором цього сигналу.

Цей обробник працює у фазі bubbling, і це є найпоширеніший підхід, адже дозволяє додатково обробляти події на нижчих рівнях і, до прикладу, не пропускати цю подію наверх за певних обставин.

До речі, якщо у нас в кнопці буде якийсь інший елемент, хай навіть іконка, то якщо клікнуть в неї, то event.target буде не кнопка, а цей елемент. Аби цьому запобігти, можна використати, наприклад, наступний CSS:

<button>
  <svg class="icon"></svg>
</button>

<style>
svg {
  pointer-events: none;
}
</style>

Елемент з цією властивістю перестає реагувати на події курсора повністю.

У фазі capturing делегація подій теж цілком собі можлива, різниця лише в тому, що ми можемо зупинити подію на верхньому рівні і не пропускати її нижче.

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

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось17
До обраногоВ обраному2
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Хороший приклад з піцою

Напишу в правильному порядку. Уявіть собі, як ви робите замовлення. Спочатку ваше замовлення приймає людина (Capture Phase). Потім воно передається до кухаря (Target Phase). Після цього готову піцу забирає кур’єр (Bubbling Phase), і, зрештою, ви отримуєте свою піцу. Ідельний, але не зовсім оскільки capture і bubbling — проходять по тих самих елементах в DOM, тому кур’єр повинен бути десь на етапі замовлення можна сказати, що людина, яка приймає замовлення, і є кур’єром )

Як наприклад таке уточнення до вашего опису? Це може бути замовлення в закладі: замоляєш в офіціанта і він же приносить тобі їжу.

А сенс від цих addEventListener коли джуни зараз відразу пірнають в реакт жс ))

Як що ти фронт, то майже ніколи використовуєш addEventListener безпосередньо, наприклад в Angular ти будешь юзати rxjs (fromEvent) для цього

є фронтенд-розробники, а є фреймворк-абізяни

Не варто бути надто категоричним — писати фронтенд на чистому JS зараз не завжди виправдано, якщо тільки це не простий лендінг. Звісно, розуміти основи важливо, але ж недарма маємо такі інструменти, як React, Vue та Angular :)

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

А потім в тебе з’являється необхідність зробити дропдаун, який закривається по кліку поза елементом. І добрий вечір.

А в наступний момент ти розумієш, що щось пішло не так, але івент в реакті-то несправжній! І ти мучиш stopPropagation через addEventListener.

Якщо у вас буде 1 000 кнопок

Тутачки доречно згадати міліон чекбоксів

onemillioncheckboxes.com

Кльовий текст, весело читати, дякую

«Ви хотіли пошуткувать, але у вас не вийшло.» (ц)
Цікаво, а пацани з 8-Б зацінять?

ну в 8-Б уже не на паскалі сидять тому можливо
UPD: В 8-А зацінили, 8-Б не в захваті

на доу не вистачає алгоритмічної стрічки щоб замість «топ 10 помилок джунів в реакт жс 30» можна було бачити топ коментарі Кожаєва наприклад
коли не зайдеш оці Сергії Бабічі розказують як побудувати «особистий бренд» хоча до «бренду» условного Кожаєва їм далеко

Класний приклад і творчий опис (все в твоєму стилі) Дякую!

Радий, що сподобалось!

Обробник тепер на одному елементі — батьківському ul.

Доречі, ось цей приклад з тим що вішати слухача на батьківський елемент я зустрічав зовсім не часто. Так що дуже корисний приклад навіть і не для новачків.

Ну не знаю, в часи домінування jQuery був доволі популярний підхід

Підписатись на коментарі