Перейти к содержанию

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

Карточки каталога создаются заново после отображения. Если назначать каждому новому элементу отдельный обработчик, легко забыть его после очередной замены списка или накопить лишние регистрации. Делегирование событий позволяет слушать постоянный контейнер и определять действие по элементу, на котором началось событие.

Снимок advanced/lesson-26 добавляет кнопку избранного к четырём прежним карточкам. Само избранное пока живёт только в памяти, без сервера и постоянного хранения. Кнопка содержит вложенный span, чтобы рассмотреть важную деталь: исходной целью щелчка может быть текстовый элемент внутри кнопки. Программа не запускалась; результаты ниже ожидаемые.

Контейнер остаётся, дети меняются

Постоянный список #catalog-list создаётся документом. Функция renderCourses заменяет только его детей. Значит, обработчик на самом списке остаётся связанным с тем же объектом, даже если прежние кнопки уничтожены и вместо них появились новые.

В снимке после создания обычных карточек к каждой добавляется настоящая кнопка type="button". Она имеет data-action="favorite", data-course-id и aria-pressed. Последний атрибут отражает состояние переключателя, а не заменяет логику хранения. Видимое название объясняет действие человеку.

const button = document.createElement('button');
button.type = 'button';
button.dataset.action = 'favorite';
button.dataset.courseId = item.dataset.courseId;
button.setAttribute('aria-pressed', String(favorites.has(item.dataset.courseId)));
const label = document.createElement('span');
label.textContent = 'В избранное';
button.append(label);
item.append(button);

Этот фрагмент выполняется внутри обхода созданных list.children; item является текущей карточкой, а favorites — набором выбранных идентификаторов. Полный app.js содержит определение render и один вызов. Не добавляйте кнопку без этой связи к произвольной карточке из другого проекта.

Мы сохраняем ссылки курса обычными ссылками. Клик по названию должен выполнять переход, а не переключать избранное. Поэтому обработчик ищет конкретную кнопку по data-action, а не считает любой щелчок внутри карточки действием сохранения.

Поиск подходящего предка

Событие click распространяется от вложенного элемента к контейнеру. В обработчике event.target может быть span, а event.currentTarget является списком. У списка нет идентификатора конкретного курса, поэтому нужно найти ближайшую кнопку от исходной цели.

list.addEventListener('click', event => {
  if (!(event.target instanceof Element)) return;
  const button = event.target.closest('button[data-action="favorite"]');
  if (!button || !list.contains(button)) return;
  const id = button.dataset.courseId;
  if (!courses.some(course => course.id === id)) return;
  if (favorites.has(id)) favorites.delete(id);
  else favorites.add(id);
  button.setAttribute('aria-pressed', String(favorites.has(id)));
  status.textContent = `В избранном: ${favorites.size}.`;
});

closest рассматривает сам элемент и его предков, пока не найдёт совпадение. Это позволяет щелчку по вложенной подписи всё равно попасть в действие кнопки. Проверка Element задаёт возможность использовать этот метод. Поведение описано в MDN о closest.

Проверка принадлежности списку ограничивает область действия. Сам метод поиска предка не знает границы нашего компонента. Если структура позже усложнится, найденный предок не должен случайно относиться к внешнему контейнеру. Делегирование полезно только тогда, когда область обработчика ясна.

Идентичность и состояние

Перед изменением избранного программа убеждается, что id существует в данных. Значение DOM не является заменой модели. Карточку можно изменить инструментами разработчика, и неизвестный идентификатор не должен незаметно создавать произвольную запись состояния.

Set содержит уникальные идентификаторы. При первом щелчке курс добавляется, при втором удаляется. Размер набора показывает число выбранных курсов, а не число событий. Если трижды нажать одну кнопку, ожидается одно выбранное значение, потому что третье действие снова включает его.

После действия обновляется только состояние нажатой кнопки и сообщение. Это сохраняет фокус на её узле. Полная отрисовка всего списка после каждого переключения также могла бы показать правильный текст, но уничтожила бы кнопку, на которой находился фокус. Не выбирайте замену DOM, не учитывая сценарий управления клавиатурой.

При самостоятельном вызове render новый набор кнопок получает состояние из favorites. Следовательно, источником остаются данные, а не прежний атрибут удалённого узла. Если хранить состояние только в DOM, очередная замена карточек вернула бы все кнопки в исходное положение.

Один обработчик и разные действия

Регистрация находится вне render. После каждой отрисовки она не повторяется. В этой задаче один постоянный слушатель удобен потому, что события нужных кнопок всплывают до контейнера. Не объявляйте делегирование подходящим для любых событий без проверки их поведения.

Если появится вторая кнопка «Скрыть курс», у неё должен быть свой код data-action и собственное правило. Условие может выбрать функцию обработки по явному действию. Не разбирайте видимый текст кнопки: перевод или изменение формулировки не должны менять алгоритм.

Также не вызывайте preventDefault для каждого щелчка списка. Это могло бы остановить обычные переходы ссылок, которые к избранному не относятся. Наша кнопка сама по себе не выполняет переход, а её явный тип не отправляет внешнюю форму. Поэтому отменять все стандартные действия контейнера нет причины.

Модель регистрации слушателя и обработчика рассматривается в MDN об addEventListener. Делегирование в нашем примере является организацией этих слушателей и распространения событий, а не отдельной встроенной функцией «сохранить избранное».

Что наблюдать вручную

Ожидаемый первый щелчок по вложенной подписи JavaScript-кнопки добавляет один идентификатор и меняет aria-pressed на истинное значение. Второй возвращает ноль выбранных. Щелчок ссылки названия должен перейти на созданную учебную страницу, не увеличивая избранное.

Для следующего опыта подготовьте в копии снимка отдельную ручную кнопку повторной отрисовки, как описано ниже. После переключения избранного вызовите render в той же жизни документа. Один слушатель должен работать с новыми детьми списка. Не выдаём этот сценарий за выполненный тест: наблюдение браузера ещё требуется.

Повторное отображение и граница компонента

Важно различать обновление детей и замену самого контейнера. Если renderCourses вызвал replaceChildren на прежнем списке, его обработчик остаётся. Если внешний код заменил целиком #catalog-list новым узлом, регистрация осталась на удалённом объекте. Делегирование не переносит слушателя на другой элемент с таким же id. Поэтому постоянство контейнера является частью договора, а не случайной особенностью первой версии разметки.

Для опыта с заменой детей используйте временный вызов render в той же жизни страницы после переключения избранного. Например, после первоначального вызова render() добавьте временный блок в конец app.js копии снимка:

const rerender = document.createElement('button');
rerender.type = 'button';
rerender.textContent = 'Повторить отрисовку';
rerender.addEventListener('click', render);
list.after(rerender);

Он создаёт отдельное ручное действие и использует уже объявленную функцию, без повторного объявления списка или набора. Обновление всего документа создаст новый Set и не продемонстрирует сохранение текущего выбора. Ожидаемый результат повторной отрисовки в памяти — новая кнопка с прежним aria-pressed, а количество слушателей остаётся прежним.

Функция сейчас не обрабатывает вложенный самостоятельный каталог внутри карточки. Если такой компонент появится, простой contains разрешит и его кнопку. Понадобится определить владельца действия, например ближайшую карточку своего списка, и исключить внутренние компоненты. Не расширяйте учебный обработчик до любой произвольной структуры, не уточнив границы делегирования.

У настоящей кнопки есть встроенный сценарий активации клавиатурой, который также приводит к событию click. Поэтому для этого переключателя не добавляем параллельный обработчик каждой клавиши: он мог бы выполнить действие дважды. Однако это ожидаемое поведение всё равно следует наблюдать с фокусом и видимым состоянием в браузере. Ссылка названия сохраняет другую обязанность — переход на страницу курса.

При окончательном удалении всего компонента стоит определить способ снятия регистрации. Здесь страница живёт как один небольшой снимок, поэтому отдельного жизненного цикла нет. Позже контроллер каталога введёт сигнал слушателей. Смысл делегирования останется тем же: одна постоянная граница для динамических дочерних действий, с явным владельцем и данными модели.

Мы получили обработку динамических элементов без регистрации на каждом новом узле. Следующий урок сохранит лишь небольшие настройки представления между посещениями. Это другое содержимое и другой жизненный цикл: постоянное хранилище не обязано превращать каждое временное действие каталога в личный профиль.

Оглавление курса