您是否曾经遇到过在 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.querySelector
或 Element.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,我在许多应用程序中都大量依赖这个小方法,到目前为止还没有让我失望。
你还有其他有趣的用例吗?请随时告诉我。
我最近使用
closest
使整个产品卡片可点击。以下是一个简化的示例
感谢分享这个示例。的确,这是该方法的另一个用例,我相信还有很多其他的用例 :)
不要使用 map,兄弟。使用 forEach。
@Ackman
针对你的“使用
forEach
而不是map
” 的评论…我经过仔细考虑选择了
map
- 请参阅 https://codeburst.io/javascript-map-vs-foreach-f38111822c0f不错… 虽然我更倾向于使用 forEach 而不是 map,我知道 map 比 foreEach 快,但 map 的目的是将数据转换为其他内容,接收数据并对其进行操作,而 foreEach 更倾向于产生副作用,比如日志记录,或者在你的情况下,使卡片可点击。
为什么推荐 jQuery 而不是 polyfill?MDN 为此提供了很好的 polyfill 函数。
是的,MDN 确实为此提供了一个非常好的 polyfill。但是,这个想法是想展示这个 API 出现之前的情况,当时 jQuery 被广泛使用(现在仍然如此),提供了相同的功能。
有些人可能会觉得这很有趣,但我们的目的不是展示
Element.closest
的替代方案或 polyfill。为什么推荐 jQuery 而不是 polyfill?MDN 为此提供了很好的 polyfill 函数。
感谢分享这个示例。的确,这是该方法的另一个用例,我相信还有很多其他的用例….
我在 Google Tag Manager 中经常使用它来对 UI 元素进行事件跟踪。
一个很好的例子是,假设你有一个旋转的横幅,其中包含一个 CTA 按钮。你想知道哪个横幅位置获得的交互最多。在 GTM 中,你可以设置一个自定义变量来获取该位置。
(我们使用一个旋转器,它会创建幻灯片 HTML 的副本,因为幻灯片在移动,所以你无法使用任何兄弟节点计数来获取位置,因此需要使用数据属性。)
感谢分享这个有趣的用例,Zach!
@Roy: 在 React 中使用 Foreach?没有,map 是唯一好的解决方案。在 JSX 中,除了 map 之外没有其他解决方案。
我认为“window.addEventListener” 太昂贵了。
在用例 1 和 4 中,具有“tabindex”的元素可以获得焦点。
Element.addEventListener(“blur”,()=>{
if(document.activeElement !== Element || document.activeElement !== childrens){
// 隐藏某些内容
}
})
更有趣的是,你可以使用逗号分隔的字符串作为选择器列表来匹配。
element.closest('.foo, .bar')
将匹配具有类名“foo”或“bar”的元素。哈哈,这真的很酷!我不知道它可以这样做,感谢分享。