matches, closest и поиск вверх по дереву

поиск≈ 7 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Селектор умеет не только искать. querySelector отвечает на вопрос «что лежит внутри меня?», но в обработчике события чаще нужен противоположный: «а этот элемент — вообще тот самый, и к чему он относится?». На него отвечают matches, closest и contains.

Разделы темы:·

Тот же самый селектор, которым вы ищете элементы, можно приложить к уже готовому элементу и спросить: подходит? Это и делает elem.matches(selector) — возвращает true или false и при этом ничего не ищет в дереве.

Проверяется селектор целиком, вместе с комбинаторами: #menu > li.item истинно, только если элемент действительно прямой ребёнок #menu. А если селектор синтаксически неверный, matches бросит исключение, а не вернёт false — опечатка не притворится «не подошло».

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

closest — это matches, поставленный в цикл. Браузер примеряет селектор к самому элементу, потом к родителю, деду и так далее до корня документа. Первое совпадение возвращается, до верха без совпадений — null.

Возврат null — обычное дело, а не сбой, поэтому хвататься за результат без проверки нельзя: elem.closest(".card").id на элементе вне карточки уронит код. У открепленного от документа элемента подъём идёт по тем же правилам, только до вершины его собственного поддерева: подойдёт она селектору — вернётся она, не подойдёт — null.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

closest — это не «родитель». Он сначала примеряет селектор к самому элементу, и в делегировании это ровно то, что нужно: клик по кнопке даёт кнопку, а не то, что над ней.

Метод появился не сразу, и подъём писали руками. Заодно видно, что внутри ничего волшебного: цикл по parentElement и matches на каждом шаге.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

contains смотрит в противоположную сторону — вниз. parent.contains(node) отвечает, лежит ли узел где-то в поддереве, на любой глубине. И да, узел содержит сам себя: panel.contains(panel) — это true.

Селектор тут не нужен вовсе, аргумент — любой узел, включая текстовый. Это чистая проверка родства, отсюда и главная идиома: document.body.contains(el) — элемент ещё на странице или уже удалён и живёт только в вашей переменной.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
МетодКуда смотритЧто возвращает
elem.matches(sel)никуда — только сам элементtrue / false
elem.closest(sel)сам элемент, затем предкиэлемент или null
elem.querySelector(sel)только потомкиэлемент или null
parent.contains(node)сам узел и потомкиtrue / false

Сто строк списка — сто обработчиков? Нет. События всплывают, поэтому хватает одного слушателя на контейнере: он поймает клики по всем потомкам, включая те, что появятся через час после подписки. Это и есть делегирование — приём, которому отведена своя статья; здесь он нужен как главное место работы closest.

Но у делегата есть неприятная деталь: event.target — самый глубокий элемент под курсором. Клик по кнопке с иконкой и подписью внутри даст span, а не кнопку. Поэтому первая строка почти любого делегата — подъём от точки клика к смысловому элементу через closest, а вторая — выход, если подниматься было не к чему.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

Обратная сторона: делегат срабатывает на всё внутри контейнера, и отбор ложится на вас — на matches и closest. Ещё он не поймает события, которые не всплывают, вроде focus и blur: для них есть всплывающие двойники focusin и focusout.

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

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Страховка — 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 — номер строки. Клик по любому другому месту таблицы не должен выводить ничего.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Точка клика — это кнопка или ячейка, поэтому сначала поднимаемся к кнопке, а от неё — к строке.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Строка if (!button) return; обязательна: клик по ячейке даёт null, и обращение к dataset без проверки уронило бы обработчик. Если таблица может оказаться вложенной в другую разметку с такими же кнопками, добавьте страховку if (!grid.contains(button)) return;.

Фильтр по matches в форме

Один обработчик input на всю форму. Выводите name поля, только если это числовое поле; текстовые ввод игнорируют. Здесь подниматься некуда — нужен ответ про сам элемент.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Тут достаточно matches: вопрос булев и адресован самому элементу. closest формально сработал бы, но лез бы по предкам без всякой пользы — input не бывает вложен в input. Обратите внимание на bubbles: true в тесте: без всплытия событие до формы просто не дойдёт.