matches, closest и поиск вверх по дереву
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Селектор умеет не только искать. querySelector отвечает на вопрос «что лежит внутри меня?», но в обработчике события чаще нужен противоположный: «а этот элемент — вообще тот самый, и к чему он относится?». На него отвечают matches, closest и contains.
Тот же самый селектор, которым вы ищете элементы, можно приложить к уже готовому элементу и спросить: подходит? Это и делает elem.matches(selector) — возвращает true или false и при этом ничего не ищет в дереве.
Проверяется селектор целиком, вместе с комбинаторами: #menu > li.item истинно, только если элемент действительно прямой ребёнок #menu. А если селектор синтаксически неверный, matches бросит исключение, а не вернёт false — опечатка не притворится «не подошло».
closest — это matches, поставленный в цикл. Браузер примеряет селектор к самому элементу, потом к родителю, деду и так далее до корня документа. Первое совпадение возвращается, до верха без совпадений — null.
Возврат null — обычное дело, а не сбой, поэтому хвататься за результат без проверки нельзя: elem.closest(".card").id на элементе вне карточки уронит код. У открепленного от документа элемента подъём идёт по тем же правилам, только до вершины его собственного поддерева: подойдёт она селектору — вернётся она, не подойдёт — null.
closest — это не «родитель». Он сначала примеряет селектор к самому элементу, и в делегировании это ровно то, что нужно: клик по кнопке даёт кнопку, а не то, что над ней.
Метод появился не сразу, и подъём писали руками. Заодно видно, что внутри ничего волшебного: цикл по parentElement и matches на каждом шаге.
contains смотрит в противоположную сторону — вниз. parent.contains(node) отвечает, лежит ли узел где-то в поддереве, на любой глубине. И да, узел содержит сам себя: panel.contains(panel) — это true.
Селектор тут не нужен вовсе, аргумент — любой узел, включая текстовый. Это чистая проверка родства, отсюда и главная идиома: document.body.contains(el) — элемент ещё на странице или уже удалён и живёт только в вашей переменной.
| Метод | Куда смотрит | Что возвращает |
|---|---|---|
| elem.matches(sel) | никуда — только сам элемент | true / false |
| elem.closest(sel) | сам элемент, затем предки | элемент или null |
| elem.querySelector(sel) | только потомки | элемент или null |
| parent.contains(node) | сам узел и потомки | true / false |
Сто строк списка — сто обработчиков? Нет. События всплывают, поэтому хватает одного слушателя на контейнере: он поймает клики по всем потомкам, включая те, что появятся через час после подписки. Это и есть делегирование — приём, которому отведена своя статья; здесь он нужен как главное место работы closest.
Но у делегата есть неприятная деталь: event.target — самый глубокий элемент под курсором. Клик по кнопке с иконкой и подписью внутри даст span, а не кнопку. Поэтому первая строка почти любого делегата — подъём от точки клика к смысловому элементу через closest, а вторая — выход, если подниматься было не к чему.
Один слушатель вместо сотни — это не только про память. Главное, что подписка не привязана к конкретным элементам: строки, подгруженные позже, работают сразу, а удалённые не оставляют за собой висящих обработчиков.
Обратная сторона: делегат срабатывает на всё внутри контейнера, и отбор ложится на вас — на matches и closest. Ещё он не поймает события, которые не всплывают, вроде focus и blur: для них есть всплывающие двойники focusin и focusout.
closest ничего не знает о вашем контейнере: он спокойно пройдёт сквозь него и найдёт совпадение выше по дереву. В делегировании это уже баг — обработчик среагирует на то, что к нему не относится.
Страховка — container.contains(found): нашли что-то вне своего поддерева, значит, событие не наше. И ещё мелочь на будущее: event.target не обязан быть элементом (например, у слушателя на document), а closest живёт только на элементах, поэтому в общем коде уместна проверка event.target instanceof Element.
- matches(sel) — булев вопрос к самому элементу, без всякого поиска по дереву; неверный селектор бросает исключение, а не возвращает false.
- closest(sel) идёт вверх, начиная с самого элемента, и возвращает null, если совпадения нет — результат всегда проверяют.
- contains(node) смотрит вниз и считает узел содержащимся в самом себе; document.body.contains(el) — проверка «элемент ещё на странице».
- Каркас делегата: один слушатель на контейнере, event.target.closest("...") и ранний выход по null.
- closest не знает границ вашего контейнера — страхуйте находку через container.contains(found).
1/3У кнопки класс buy, она лежит внутри блока с классом card. Что вернёт button.closest(".buy")?
2/3Клик пришёл по span внутри кнопки. Как в делегированном обработчике на контейнере получить саму кнопку?
3/3Что вернёт panel.contains(panel)?
Один обработчик на всю таблицу
В таблице у каждой строки есть data-row, а в последней ячейке — кнопка .del. Повесьте один обработчик на таблицу: при клике по кнопке выводите удалить N, где N — номер строки. Клик по любому другому месту таблицы не должен выводить ничего.
Точка клика — это кнопка или ячейка, поэтому сначала поднимаемся к кнопке, а от неё — к строке.
Строка if (!button) return; обязательна: клик по ячейке даёт null, и обращение к dataset без проверки уронило бы обработчик. Если таблица может оказаться вложенной в другую разметку с такими же кнопками, добавьте страховку if (!grid.contains(button)) return;.
Фильтр по matches в форме
Один обработчик input на всю форму. Выводите name поля, только если это числовое поле; текстовые ввод игнорируют. Здесь подниматься некуда — нужен ответ про сам элемент.
Тут достаточно matches: вопрос булев и адресован самому элементу. closest формально сработал бы, но лез бы по предкам без всякой пользы — input не бывает вложен в input. Обратите внимание на bubbles: true в тесте: без всплытия событие до формы просто не дойдёт.