Практические примеры использования метода closest() в JavaScript

Были ли у вас когда-нибудь проблемы с поиском родительского узла DOM в JavaScript, и вы не были уверены, сколько уровней вам нужно пройти, чтобы добраться до него? Взглянем, например, на приведенный ниже HTML-код.

<div data-id="123">
  <button>Click me</button>
</div>

Это довольно просто, правда? Допустим, вы хотите получить значение data-id после того, как пользователь нажмет кнопку.

var button = document.querySelector("button");


button.addEventListener("click", (evt) => {
  console.log(evt.target.parentNode.dataset.id);
  // выводит "123"
});

В этом случае достаточно API Node.parentNode. Он возвращает родительский узел данного элемента. В приведенном выше примере evt.target – это нажатая кнопка. Ее родительский узел — это div с атрибутом data.

Но что, если структура HTML глубже? Она может быть даже динамичной, в зависимости от содержимого.

<div data-id="123">
  <article>
    <header>
      <h1>Some title</h1>
      <button>Click me</button>
    </header>
     <!-- ... -->
  </article>
</div>

Наша работа значительно усложнилась из-за добавления еще нескольких HTML-элементов. Конечно, мы могли бы сделать что-то наподобие element.parentNode.parentNode.parentNode.dataset.id, но… это не изящно, и не пригодно для повторного использования или масштабирования.

Устаревший способ: использование while-loop

Одним из решений является использование цикла while, который выполняется до тех пор, пока не будет найден родительский узел.

function getParentNode(el, tagName) {
  while (el && el.parentNode) {
    el = el.parentNode;
    
    if (el && el.tagName == tagName.toUpperCase()) {
      return el;
    }
  }
  
  return null;
}

Используя пример HTML, приведенный выше, это будет выглядеть следующим образом:

var button = document.querySelector("button");


console.log(getParentNode(button, 'div').dataset.id);
// выводит "123"

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

Не забывайте jQuery

Раньше, если вы не хотели иметь дело с написанием той функции, которую мы выполнили выше для каждого приложения (и давайте будем честными, кому это нужно?), то вам пригодилась бы такая библиотека как jQuery (и она все еще работает). Она предоставляет метод .closest(), предназначенный именно для этого.

$("button").closest("[data-id='123']")

Новый способ: использование Element.closest()

Несмотря на то, что jQuery по-прежнему является допустимым подходом, его добавление в проект только для этого метода является излишним, особенно если вы можете получить то же самое с помощью нативного JavaScript.

И вот тут в игру вступает Element.closest.

var button = document.querySelector("button");


console.log(button.closest("div"));
// выводит HTMLDivElement

Ну вот! Вот как это может быть просто, и без каких-либо библиотек или лишнего кода.

Element.closest() позволяет перемещаться по DOM, пока мы не получим элемент, соответствующий заданному селектору. Замечательно то, что мы можем передать любой селектор, который мы также передали бы Element.querySelectorили Element.querySelectorAll. Это может быть идентификатор, класс, атрибут данных, тег или что-то еще.

element.closest("#my-id"); // да
element.closest(".some-class"); // да
element.closest("[data-id]:not(article)") // черт побери, да!

Если Element.closest находит родительский узел на основе данного селектора, он возвращает его так же, как document.querySelector. В противном случае, если он не находит родителя, то возвращается null, что упрощает использование с условиями if.

var button = document.querySelector("button");


console.log(button.closest(".i-am-in-the-dom"));
// выводит HTMLElement


console.log(button.closest(".i-am-not-here"));
// выводит null


if (button.closest(".i-am-in-the-dom")) {
  console.log("Hello there!");
} else {
  console.log(":(");
}

Готовы к нескольким примерам из реальной жизни? Поехали!

Пример использования 1: раскрывающиеся списки

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

Element.closest API обнаруживает, что клик был за пределами списка. Сам выпадающий список представляет собой элемент <ul> с классом .menu-dropdown, поэтому клик в любом месте вне меню закроет его. Это потому, что значение для evt.target.closest(«.menu-dropdown») будет null, так как у нас нет родительского узла с этим классом.

function handleClick(evt) {
  // ...
  
  // если клик был выполнен где-либо за пределами выпадающего меню, закрываем его.
  if (!evt.target.closest(".menu-dropdown")) {
    menu.classList.add("is-hidden");
    navigation.classList.remove("is-expanded");
  }
}

Внутри функции обратного вызова handleClick условие определяет, что делать: закрыть раскрывающийся список. Если кликнуть где-то еще внутри неупорядоченного списка, Element.closest найдет и вернет его, в результате чего раскрывающийся список останется открытым.

Пример использования 2: таблицы

Во втором примере у нас есть таблица, которая отображает информацию о пользователе, скажем, как компонент в панели инструментов. У каждого пользователя есть идентификатор, но вместо того, чтобы показывать его, мы сохраняем его как атрибут data для каждого элемента <tr>.

<table>
  <!-- ... -->
  <tr data-userid="1">
    <td>
      <input type="checkbox" data-action="select">
    </td>
    <td>John Doe</td>
    <td>john.doe@gmail.com</td>
    <td>
      <button type="button" data-action="edit">Edit</button>
      <button type="button" data-action="delete">Delete</button>
    </td>
  </tr>
</table>

Последний столбец содержит две кнопки для редактирования и удаления пользователя из таблицы. Первая кнопка имеет атрибут data-action edit, а вторая — delete. Когда мы кликаем по любой из них, мы хотим вызвать какое-то действие (например, отправку запроса на сервер), но для этого необходим идентификатор пользователя.

Прослушиватель события клика прикреплен к глобальному объекту окна, поэтому всякий раз, когда пользователь кликает где-нибудь на странице, вызывается функция обратного вызова handleClick.

function handleClick(evt) {
  var { action } = evt.target.dataset;
  
  if (action) {
    // `action` существует только для кнопок и чек-боксов в таблице.
    let userId = getUserId(evt.target);
    
    if (action == "edit") {
      alert(`Edit user with ID of ${userId}`);
    } else if (action == "delete") {
      alert(`Delete user with ID of ${userId}`);
    } else if (action == "select") {
      alert(`Selected user with ID of ${userId}`);
    }
  }
}

Если клик происходит где-то еще, кроме одной из этих кнопок, атрибут data-action не существует, следовательно, ничего не происходит. Однако при нажатии любой кнопки будет определено действие (кстати, это называется делегированием события), и на следующем шаге идентификатор пользователя будет получен путем вызова getUserId.

function getUserId(target) {
  // `target` - это всегда кнопка или чек-бокс.
  return target.closest("[data-userid]").dataset.userid;
}

Эта функция ожидает, что узел DOM является единственным параметром, и при вызове использует его Element.closest для поиска строки таблицы, содержащей нажатую кнопку. Затем он возвращает значение data-userid, которое теперь можно использовать для отправки запроса на сервер.

Пример использования 3: таблицы в React

Давайте остановимся на примере таблицы и посмотрим, как мы справимся с этим в React-проекте. Ниже приведен код компонента, возвращающего таблицу.

function TableView({ users }) {
  function handleClick(evt) {
    var userId = evt.currentTarget
    .closest("[data-userid]")
    .getAttribute("data-userid");


    // делаем что-то с `userId`
  }


  return (
    <table>
      {users.map((user) => (
        <tr key={user.id} data-userid={user.id}>
          <td>{user.name}</td>
          <td>{user.email}</td>
          <td>
            <button onClick={handleClick}>Edit</button>
          </td>
        </tr>
      ))}
    </table>
  );
}

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

<button onClick={() => handleClick(user.id)}>Edit</button>

Хотя это возможный способ решения проблемы, я предпочитаю использовать технику data-userid. Одним из недостатков встроенной стрелочной функции является то, что каждый раз, когда React повторно визуализирует список, ему необходимо снова создавать функцию обратного вызова, что может привести к проблемам с производительностью при работе с большими объемами данных.

В функции обратного вызова мы просто обрабатываем событие, извлекая цель (кнопку) и получая родительский элемент <tr>, содержащий значение data-userid.

function handleClick(evt) {
  var userId = evt.target
  .closest("[data-userid]")
  .getAttribute("data-userid");


  // делаем что-то с `userId`
}

Пример использования 4: модальные окна

Этот последний пример — еще один компонент, с которым, я уверен, вы все когда-то сталкивались: модальные окна. Модальные окна часто сложно реализовать, поскольку они должны обеспечивать множество функций, и при этом быть доступными, а также (в идеале) красивыми.

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

В JavaScript мы хотим отслеживать клики где-нибудь в модальном окне.

var modal = document.querySelector(".modal-outer");

modal.addEventListener("click", handleModalClick);

По умолчанию модальное окно скрыто с помощью служебного класса .is-hidden. Модальное окно открывается, удаляя этот класс, только когда пользователь нажимает большую красную кнопку. И когда модальное окно открыто, клик в любом месте внутри него — за исключением кнопки закрытия — не приведет к его случайному закрытию. За это отвечает функция обратного вызова прослушивателя событий.

function handleModalClick(evt) {
  // `evt.target` - это узел DOM, на который кликнул пользователь.
  if (!evt.target.closest(".modal-inner")) {
    handleModalClose();
  }
}

evt.target — это узел DOM, по которому кликнул пользователь, в данном примере, это весь фон за модальным окном, <div class=»modal-outer»>. Этот узел DOM не находится внутри <div class=»modal-inner»>, поэтому Element.closest() может проходить все, что хочет, и не может его найти. Условие проверяет это и запускает функцию handleModalClose.

Клик где-нибудь внутри узла <div class=»modal-inner»>, скажем по заголовку, создает родительский узел. В этом случае условие не соответствует действительности, оставляя модальное окно в открытом состоянии.

Да, и насчет поддержки браузерами…

Как и в случае с любым классным «новым» JavaScript API, необходимо учитывать поддержку браузерами. Хорошая новость заключается в том, что Element.closest — это не новая функция, и она поддерживается во всех основных браузерах в течение достаточно долгого времени, с огромным охватом поддержки в 94%.

Единственный браузер, не предлагающий никакой поддержки — это Internet Explorer (все версии). Если вам нужно поддерживать IE, — лучше использовать подход с использованием jQuery.

Как видите, есть сразу несколько довольно надежных вариантов использования Element.closest. Такие библиотеки, как jQuery, относительно легко использовались нами в прошлом, теперь же можно использовать обычный JavaScript.

Благодаря хорошей поддержке браузерами и простоте в использовании API, я сильно зависим от этого небольшого метода во многих приложениях и пока не разочарован.

Есть ли у вас другие интересные варианты использования? Не стесняйтесь, расскажите о них в комментариях к этой статье.

Дайте знать, что вы думаете по данной теме материала в комментариях. Мы очень благодарим вас за ваши комментарии, лайки, отклики, дизлайки, подписки!

Данная публикация является переводом статьи «Practical Use Cases for JavaScript’s closest() Method» , подготовленная редакцией проекта.

Меню