JavaScript 的 closest() 方法的实际用例

Avatar of Andreas Remdt
Andreas Remdt

DigitalOcean 为您的旅程的每个阶段提供云产品。立即开始使用 200 美元的免费积分!

您是否曾经遇到过在 JavaScript 中查找 DOM 节点父级的问题,但不知道要向上遍历多少级才能找到它?例如,让我们看看这个 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);
  // prints "123"
});

在这种情况下,Node.parentNode API 就足够了。它的作用是返回给定元素的父节点。在上面的示例中,evt.target 是被点击的按钮;它的父节点是带有 data 属性的 div。

但如果 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 循环

一种解决方案是使用一个 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);
// prints "123"

此解决方案远非完美。想象一下,如果要使用 ID 或类或任何其他类型的选择器,而不是标签名。至少它允许在父级和我们的源之间有可变数量的子节点。

还有 jQuery

在过去,如果您不想处理为每个应用程序编写类似于上面我们编写的那种函数(说真的,谁想做这种事呢?),那么像 jQuery 这样的库就非常方便(现在它仍然很方便)。它提供了 .closest() 方法,专门用于此目的

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

新方法:使用 Element.closest()

尽管 jQuery 仍然是一种有效的方法(嘿,我们中的一些人 仍然依赖它),但仅仅为了这个方法而将其添加到项目中是过度杀鸡,尤其是在您可以使用原生 JavaScript 获得相同功能的情况下。

这就是 Element.closest 发挥作用的地方

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


console.log(button.closest("div"));
// prints the HTMLDivElement

好了!就这么简单,不需要任何库或额外代码。

Element.closest() 允许我们向上遍历 DOM,直到找到与给定选择器匹配的元素。最棒的是,我们可以传递任何我们也会传递给 Element.querySelectorElement.querySelectorAll 的选择器。它可以是 ID、类、data 属性、标签或任何其他东西。

element.closest("#my-id"); // yep
element.closest(".some-class"); // yep
element.closest("[data-id]:not(article)") // hell yeah

如果 Element.closest 根据给定选择器找到了父节点,它将以与 document.querySelector 相同的方式返回它。否则,如果它没有找到父级,它将返回 null,使其易于与 if 条件一起使用

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


console.log(button.closest(".i-am-in-the-dom"));
// prints HTMLElement


console.log(button.closest(".i-am-not-here"));
// prints null


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

准备好几个现实生活中的例子了吗?让我们开始吧!

用例 1:下拉菜单

我们的第一个演示是一个基本(而且远非完美)的下拉菜单实现,它在点击顶级菜单项之一后打开。注意,即使在下拉菜单内部点击或选择文本时,菜单也会保持打开状态?但是,在外部某个地方点击,它就会关闭。

Element.closest API 用于检测外部点击。下拉菜单本身是一个带有 .menu-dropdown 类的 <ul> 元素,因此在菜单外部任何地方点击都会关闭它。这是因为 evt.target.closest(".menu-dropdown") 的值为 null,因为没有父节点具有此类。

function handleClick(evt) {
  // ...
  
  // if a click happens somewhere outside the dropdown, close it.
  if (!evt.target.closest(".menu-dropdown")) {
    menu.classList.add("is-hidden");
    navigation.classList.remove("is-expanded");
  }
}

handleClick 回调函数内部,一个条件决定要执行的操作:关闭下拉菜单。如果在无序列表内部的其他地方点击,Element.closest 将找到并返回它,从而导致下拉菜单保持打开状态。

用例 2:表格

第二个示例呈现一个表格,该表格显示用户信息,比方说作为仪表板中的一个组件。每个用户都有一个 ID,但我们不显示它,而是将其作为每个 <tr> 元素的 data 属性保存。

<table>
  <!-- ... -->
  <tr data-userid="1">
    <td>
      <input type="checkbox" data-action="select">
    </td>
    <td>John Doe</td>
    <td>[email protected]</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。当我们点击其中任何一个时,我们想要触发一些操作(比如向服务器发送请求),但为此需要用户 ID。

一个点击事件监听器附加到全局窗口对象,因此每当用户在页面上的某个地方点击时,回调函数 handleClick 就会被调用。

function handleClick(evt) {
  var { action } = evt.target.dataset;
  
  if (action) {
    // `action` only exists on buttons and checkboxes in the table.
    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 获取用户 ID

function getUserId(target) {
  // `target` is always a button or checkbox.
  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");


    // do something with `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 重新渲染列表时,它都需要重新创建回调函数,这在处理大量数据时可能会导致性能问题。

在回调函数中,我们只需处理事件,通过提取目标(按钮)并获取包含 data-userid 值的父 <tr> 元素来处理它。

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


  // do something with `userId`
}

用例 4:模态框

最后一个例子是另一个您肯定都遇到过的组件:模态框。模态框通常难以实现,因为它们需要提供很多功能,同时还要保证可访问性和(理想情况下)美观。

我们想要重点关注如何关闭模态框。在这个例子中,可以通过按键盘上的 Esc、点击模态框中的按钮或点击模态框外部的任何地方来实现。

在我们的 JavaScript 代码中,我们想要监听模态框内部的点击事件

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

modal.addEventListener("click", handleModalClick);

模态框默认情况下通过 .is-hidden 实用程序类隐藏。只有当用户点击红色大按钮时,模态框才会通过删除此类而打开。一旦模态框打开,在模态框内部的任何地方点击——除了关闭按钮——都不会意外地关闭它。事件监听器回调函数负责这一功能

function handleModalClick(evt) {
  // `evt.target` is the DOM node the user clicked on.
  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,我在许多应用程序中都大量依赖这个小方法,到目前为止还没有让我失望。

你还有其他有趣的用例吗?请随时告诉我。