Делегирование событий

Делегирование событий (Event Delegation) — паттерн, при котором обработчик вешается на общего родителя, а внутри определяется, какой именно дочерний элемент вызвал событие. Работает благодаря всплытию.

Зачем нужно

Вместо навешивания отдельного обработчика на каждый из 100 элементов списка, один обработчик на контейнере обрабатывает все клики. Это экономит память, работает с динамически добавленными элементами и упрощает код.

Где используется

  • Списки (todo-list, таблицы, навигация)
  • Динамические компоненты (элементы добавляются после загрузки)
  • Вкладки (tabs), аккордеоны, меню
  • Любой UI с повторяющимися интерактивными элементами

Предпосылки

События, Всплытие и погружение, Поиск элементов

Базовый пример

// Вместо навешивания обработчика на каждый <li>:
// ❌ Плохо
document.querySelectorAll('li').forEach(li => {
  li.addEventListener('click', () => {
    console.log(li.textContent);
  });
});

// ✅ Хорошо — один обработчик на <ul>
document.querySelector('ul').addEventListener('click', (e) => {
  const li = e.target.closest('li');
  if (!li) return; // клик не по li
  console.log(li.textContent);
});

closest — ключевой метод

// closest ищет ближайшего предка (или сам элемент) по селектору
// Необходим, потому что target может быть вложенным элементом

// <ul id="menu">
//   <li data-action="save"><span class="icon">💾</span> Сохранить</li>
//   <li data-action="delete"><span class="icon">🗑️</span> Удалить</li>
// </ul>

document.querySelector('#menu').addEventListener('click', (e) => {
  // e.target может быть <span class="icon">, а не <li>!
  const li = e.target.closest('li');
  if (!li) return;

  const action = li.dataset.action;
  console.log('Действие:', action); // "save" или "delete"
});

data-атрибуты для действий

// <div id="toolbar">
//   <button data-action="bold">B</button>
//   <button data-action="italic">I</button>
//   <button data-action="underline">U</button>
//   <button data-action="link">🔗</button>
// </div>

const actions = {
  bold:       => document.execCommand('bold'),
  italic:     => document.execCommand('italic'),
  underline:  => document.execCommand('underline'),
  link:       => {
    const url = prompt('URL:');
    if (url) document.execCommand('createLink', false, url);
  }
};

document.querySelector('#toolbar').addEventListener('click', (e) => {
  const button = e.target.closest('[data-action]');
  if (!button) return;

  const action = button.dataset.action;
  if (actions[action]) {
    actions[action];
  }
});

Динамические элементы

// Делегирование работает с элементами, добавленными ПОСЛЕ навешивания обработчика

const list = document.querySelector('#todo-list');

// Обработчик навешен ДО добавления элементов
list.addEventListener('click', (e) => {
  // Удаление
  const deleteBtn = e.target.closest('.delete-btn');
  if (deleteBtn) {
    deleteBtn.closest('li').remove();
    return;
  }

  // Переключение
  const checkbox = e.target.closest('.toggle');
  if (checkbox) {
    checkbox.closest('li').classList.toggle('completed');
    return;
  }
});

// Новые элементы автоматически обрабатываются
function addTodo(text) {
  const li = document.createElement('li');
  li.innerHTML = `
    <span class="toggle">☐</span>
    <span class="text">${text}</span>
    <button class="delete-btn">×</button>
  `;
  list.appendChild(li); // обработчик уже работает для этого элемента!
}

addTodo('Купить молоко');
addTodo('Выучить JavaScript');

Делегирование для таблиц

// <table id="users-table">
//   <tr data-id="1"><td>Иван</td><td>ivan@mail.com</td><td><button class="edit">✏️</button></td></tr>
//   <tr data-id="2"><td>Мария</td><td>maria@mail.com</td><td><button class="edit">✏️</button></td></tr>
// </table>

document.querySelector('#users-table').addEventListener('click', (e) => {
  // Клик по кнопке редактирования
  if (e.target.closest('.edit')) {
    const row = e.target.closest('tr');
    const userId = row.dataset.id;
    editUser(userId);
    return;
  }

  // Клик по строке — выделение
  const row = e.target.closest('tr');
  if (row) {
    document.querySelectorAll('tr.selected').forEach(r => r.classList.remove('selected'));
    row.classList.add('selected');
  }
});

Паттерн «поведение» через data-атрибуты

// Универсальный обработчик для всей страницы
document.addEventListener('click', (e) => {
  // data-toggle-class="active"
  const toggleEl = e.target.closest('[data-toggle-class]');
  if (toggleEl) {
    const cls = toggleEl.dataset.toggleClass;
    const target = toggleEl.dataset.toggleTarget
      ? document.querySelector(toggleEl.dataset.toggleTarget)
      : toggleEl;
    target.classList.toggle(cls);
  }

  // data-copy="текст"
  const copyEl = e.target.closest('[data-copy]');
  if (copyEl) {
    navigator.clipboard.writeText(copyEl.dataset.copy);
  }
});

// Теперь в HTML:
// <button data-toggle-class="active">Переключить</button>
// <button data-toggle-class="open" data-toggle-target="#menu">Меню</button>
// <button data-copy="Привет!">Копировать</button>

Частые ошибки

1. Использование e.target вместо closest

// <button class="btn"><span class="icon">🗑️</span> Удалить</button>

list.addEventListener('click', (e) => {
  // Плохо — при клике на <span> e.target !== button
  if (e.target.classList.contains('btn')) { /* не сработает! */ }

  // Хорошо
  if (e.target.closest('.btn')) { /* сработает */ }
});

2. Забыть проверку на null

list.addEventListener('click', (e) => {
  const item = e.target.closest('.item');
  // Плохо — если кликнули между элементами
  // item.dataset.id; // TypeError: Cannot read properties of null

  // Хорошо
  if (!item) return;
  console.log(item.dataset.id);
});

3. Слишком высокий уровень делегирования

// Плохо — обработчик на document для одного компонента
document.addEventListener('click', (e) => {
  if (e.target.closest('#my-tiny-widget .btn')) {
    // лишняя проверка на каждый клик
  }
});

// Лучше — на ближайшем контейнере
document.querySelector('#my-tiny-widget').addEventListener('click', (e) => {
  if (e.target.closest('.btn')) { /* ... */ }
});

Практика

  1. Реализуй todo-список с добавлением, удалением и отметкой через делегирование
  2. Создай табы (вкладки) с переключением через один обработчик
  3. Построй таблицу с сортировкой по заголовкам через делегирование
  4. Реализуй паттерн «поведение» с data-атрибутами (toggle, hide, copy)

Связанные темы

Ресурсы