以下是 Philip Walton (@philwalton) 的客座文章。他将解释为什么不应轻易阻止事件传播,并且可能应该完全避免这样做。
如果您是前端开发人员,那么在您的职业生涯中,您可能不得不构建一个弹出窗口或对话框,在用户点击页面上的其他任何位置后自行关闭。如果您在网上搜索找出执行此操作的最佳方法,则很可能遇到过此 Stack Overflow 问题:如何检测元素外部的点击?。
以下是评分最高的答案建议
$('html').click(function() {
// Hide the menus if visible.
});
$('#menucontainer').click(function(event){
event.stopPropagation();
});
如果尚不清楚此代码的作用,以下是快速概述:如果点击事件传播到<html>
元素,则隐藏菜单。如果点击事件源自#menucontainer
内部,则停止该事件,以便它永远不会到达<html>
元素,因此只有#menucontainer
外部的点击才会隐藏菜单。
上面的代码简单、优雅且巧妙。然而,不幸的是,这绝对是糟糕的建议。
此解决方案大致相当于通过关闭浴室的水来修复漏水的淋浴。它有效,但完全忽略了页面上的任何其他代码可能需要了解该事件的可能性。
尽管如此,它仍然是此问题中获得赞成票最多的答案,因此人们认为它是合理的建议。
可能出现什么问题?
您可能在心里想:现在还有谁会自己编写这样的代码呢?我使用像 Bootstrap 这样的经过良好测试的库,所以我不需要担心,对吧?
不幸的是,不是这样。阻止事件传播不仅仅是糟糕的 Stack Overflow 答案所推荐的;它也存在于当今使用的一些最流行的库中。
为了证明这一点,让我向您展示在 Ruby on Rails 应用程序中使用 Bootstrap 创建错误是多么容易。Rails 附带一个名为 jquery-ujs 的 JavaScript 库,它允许开发人员通过data-remote
属性声明性地向链接添加远程 AJAX 调用。
在以下示例中,如果您打开下拉菜单并点击框架中的其他任何位置,则下拉菜单应自行关闭。但是,如果您打开下拉菜单然后点击“远程链接”,它将不起作用。
查看 Philip Walton (@philipwalton) 在 CodePen 上创建的笔 阻止传播演示。
发生此错误是因为负责关闭下拉菜单的 Bootstrap 代码正在侦听文档上的点击事件。但是,由于 jquery-ujs 在其data-remote
链接处理程序中阻止了事件传播,因此这些点击永远不会到达文档,因此 Bootstrap 代码永远不会运行。
此错误最糟糕的部分是 Bootstrap(或任何其他库)绝对无法做任何事情来防止它。如果您正在处理 DOM,那么您始终受页面上运行的任何其他编写不良的代码的支配。
事件的问题
像 JavaScript 中的许多事物一样,DOM 事件是全局的。而且众所周知,全局变量会导致混乱且耦合的代码。
修改单个、短暂的事件最初可能看起来无害,但它存在风险。当您更改人们期望的行为以及其他代码依赖的行为时,您将遇到错误。这只是时间问题。
而且根据我的经验,这些类型的错误是最难追踪的。
为什么人们要阻止事件传播?
我们知道互联网上有一些糟糕的建议,它们提倡不必要地使用stopPropagation
,但这并不是人们这样做的唯一原因。
开发人员经常在没有意识到的情况下阻止事件传播。
返回 false
在从事件处理程序返回false
时会发生什么,这方面存在很多混淆。考虑以下三种情况
<!-- An inline event handler. -->
<a href="http://google.com" onclick="return false">Google</a>
// A jQuery event handler.
$('a').on('click', function() {
return false;
});
// A native event handler.
var link = document.querySelector('a');
link.addEventListener('click', function() {
return false;
});
这三个示例似乎都在做完全相同的事情(只是返回false
),但实际上结果却大不相同。以下是每种情况下实际发生的情况
- 从内联事件处理程序返回
false
可以阻止浏览器导航到链接地址,但它不会阻止事件在 DOM 中传播。 - 从 jQuery 事件处理程序返回
false
可以阻止浏览器导航到链接地址,并且它会阻止事件在 DOM 中传播。 - 从常规 DOM 事件处理程序返回
false
完全没有任何作用。
当您期望某些事情发生而它没有发生时,这令人困惑,但您通常会立即发现它。一个更大的问题是,当您期望某些事情发生而它确实发生了,但产生了意外且未被注意到的副作用时。那就是噩梦般的错误的来源。
在 jQuery 示例中,返回false
的行为与其他两个事件处理程序有任何不同这一点并不完全清楚,但事实确实如此。在幕后,jQuery 实际上正在调用以下两个语句
event.preventDefault();
event.stopPropagation();
由于围绕return false
的混淆,以及它在 jQuery 处理程序中阻止事件传播的事实,我建议不要使用它。最好明确您的意图并直接调用这些事件方法。
注意:如果您将 jQuery 与 CoffeeScript 一起使用(它会自动返回函数的最后一个表达式),请确保不要以任何计算结果为布尔值false
的表达式结束您的事件处理程序,否则您将遇到相同的问题。
性能
您偶尔会读到一些建议(通常是很久以前写的),这些建议建议出于性能原因阻止传播。
在 IE6 甚至更旧的浏览器时代,复杂的 DOM 确实会降低网站速度。并且由于事件在整个 DOM 中传播,因此节点越多,所有内容的速度就越慢。
quirksmode.org 的 Peter Paul Koch 在一篇关于该主题的旧文章中推荐了这种做法
如果您的文档结构非常复杂(有很多嵌套的表格等),则可以通过关闭冒泡来节省系统资源。浏览器必须遍历事件目标的每个祖先元素才能查看它是否具有事件处理程序。即使没有找到任何处理程序,搜索仍然需要时间。
但是,使用当今的现代浏览器,您从阻止事件传播中获得的任何性能提升都可能不会被您的用户注意到。这是一个微优化,绝对不是您的性能瓶颈。
我建议不要担心事件在整个 DOM 中传播的事实。毕竟,它是规范的一部分,而且浏览器在执行它方面已经变得非常出色。
该怎么做
作为一般规则,阻止事件传播永远不应该是解决问题的方案。如果您有一个包含多个事件处理程序的站点,这些处理程序有时会相互干扰,并且您发现阻止传播使所有内容都能正常工作,那么这是一件坏事。它可能会解决您的直接问题,但它可能还会造成您没有意识到的另一个问题。
阻止传播应该被认为像取消事件一样,并且应该仅以该意图使用。也许您想阻止表单提交或不允许页面区域获得焦点。在这些情况下,您阻止传播是因为您不希望事件发生,而不是因为您在 DOM 中注册了更高层的事件处理程序。
在上面“如何检测点击事件发生在元素外部?”的例子中,调用stopPropagation
的目的并不是完全消除点击事件,而是为了避免页面上其他代码的运行。
除了这是一个坏主意因为它改变了全局行为之外,它也是一个坏主意因为它将菜单隐藏逻辑放在了两个不同且无关的地方,使其比必要时脆弱得多。
一个更好的解决方案是拥有一个单一的事件处理程序,其逻辑完全封装,并且其唯一的责任是确定对于给定的事件是否应该隐藏菜单。
事实证明,这个更好的选择最终也需要更少的代码。
$(document).on('click', function(event) {
if (!$(event.target).closest('#menucontainer').length) {
// Hide the menus.
}
});
上面的处理程序监听文档上的点击事件,并检查事件目标是否为#menucontainer
或是否具有#menucontainer
作为父级。如果不是,则表示点击事件源于#menucontainer
外部,因此,如果菜单可见,则可以隐藏它们。
默认阻止?
大约一年前,我开始编写一个事件处理库来帮助解决这个问题。您无需停止事件传播,只需将事件标记为“已处理”。这将允许在 DOM 更高层注册的事件侦听器检查事件,并根据事件是否已被“处理”来确定是否需要进一步操作。这个想法是,您可以“停止事件传播”,而无需真正停止它。
事实证明,我最终不需要这个库。在 100% 的情况下,当我发现自己想要检查事件是否已被“处理”时,我注意到之前的侦听器已调用了preventDefault
。并且 DOM API 已经提供了一种检查方法:defaultPrevented
属性。
为了帮助阐明这一点,让我举一个例子。
假设您正在向文档添加一个事件侦听器,该侦听器将使用 Google Analytics 跟踪用户何时点击指向外部域的链接。它可能看起来像这样
$(document).on('click', 'a', function(event) {
if (this.hostname != 'css-tricks.com') {
ga('send', 'event', 'Outbound Link', this.href);
}
});
这段代码的问题在于,并非所有链接点击都会将您带到其他页面。有时 JavaScript 会拦截点击,调用preventDefault
并执行其他操作。上面描述的data-remote
链接就是此类示例。另一个示例是 Twitter 分享按钮,它会打开一个弹出窗口而不是转到 twitter.com。
为了避免跟踪此类点击,可能很想停止事件传播,但检查事件的defaultPrevented
是一个更好的方法。
$(document).on('click', 'a', function(event) {
// Ignore this event if preventDefault has been called.
if (event.defaultPrevented) return;
if (this.hostname != 'css-tricks.com') {
ga('send', 'event', 'Outbound Link', this.href);
}
});
由于在点击处理程序中调用preventDefault
始终会阻止浏览器导航到链接的地址,因此您可以 100% 确定如果defaultPrevented
为真,则用户没有去任何地方。换句话说,此技术既比stopPropagation
更可靠,也不会产生任何副作用。
结论
希望本文能帮助您以新的视角看待 DOM 事件。它们不是可以随意修改的孤立片段。它们是全局的、相互关联的对象,通常会影响比您最初意识到的更多的代码。
为了避免错误,几乎总是最好让事件保持原样并按照浏览器的预期进行传播。
如果您不确定该怎么做,只需问问自己以下问题:其他一些代码(现在或将来)是否可能想要知道此事件发生了?答案通常是肯定的。无论是像 Bootstrap 模态框这样简单的事情,还是像事件跟踪分析这样关键的事情,访问事件对象都很重要。如有疑问,请不要停止传播。
此解决方案的额外好处是#menucontainer在绑定时不必存在于 DOM 中。与 Stackoverflow 中的建议答案相反。
关键引用
“当您更改人们期望的行为以及其他代码依赖的行为时,您将遇到错误。”
不是“可能”,不是“也许”,而是“将要”。未发现的错误仍然是错误。
Philip,您是否忘记了向最后两个 jQuery 函数示例传递“event”?
是的,您是对的。感谢您发现错误。
感谢 Philip 更新了“event”的错误。
“event”是一个全局变量,除非在处理程序内部,否则为“undefined”。
再说一次,prototype.js 拥有您需要的一切。
您的点击事件处理程序必须确定接收点击的元素。假设它存储在变量“obj”中。然后你只需向上遍历
Prototype.js 为此提供了一个完整的解决方案:Event.findElement()。
当然,在更通用的事件处理程序中,您可能会对所有需要在外部点击时隐藏的对象使用虚拟 CSS 类,例如自建的下拉框。即使对于鼠标移动事件,这速度也足够快,并且向下兼容到 IE6(如果您真的必须……)。Prototype.js 提供了一个 mouseleave polyfill,但“up()”和“findElement()”方法用途更广。
这之所以能够足够快,是因为大多数 JS 引擎会预编译原型扩展(在本例中为 up() 方法)。如果每次事件发生时都必须“手动”解析 DOM,速度就会慢得多。而这正是 JS 程序员应该熟悉原型扩展理念并放弃基于类或函数的方法的主要原因之一。您无需为了此解决方案而使用 Prototype.js;如果您需要自己实现,up() 原型扩展只需要几行代码。
啊,好吧,我相信 jQuery 也有一些类似的解决方案……
这是 jQuery 代码的纯 JavaScript 等价物
它好吗?
@Valtteri:不,你弄反了——当点击发生在
#menucontainer
外部时,菜单应该隐藏——但是,使用menucontainer.contains(event.target)
,您正在检查事件的目标是否为#menucontainer
的后代。我也使用过类似于最初 Stackoverflow 解决方案的内容,在我遇到第三方代码阻止传播并因此使下拉菜单保持打开状态的问题之前,我发现它很优雅。
但是,如果我们可以忽略 IE8 及更早版本,则有一种解决第三方代码弄乱事件的方法。我们可以使用事件捕获在任何其他元素接收事件之前在文档级别处理事件
通过将 true 作为第三个参数传递,事件处理程序将注册到捕获阶段。捕获意味着,事件首先从文档向下传递到事件目标,然后是冒泡阶段,这是从目标向上返回到文档的常用方法。捕获和冒泡的顺序在上面提到的quirksmode 文章中进行了说明。
对于 IE8 及更早版本,也有一种混乱的方法:侦听
mouseup
而不是click
,因为后者触发得稍微早一点……但是,最佳解决方案仍然是按照文章所说的去做:不要停止传播。
您可以使用透明覆盖层以允许用户点击模态框外部以进行关闭,而无需使用事件停止传播
演示: http://codepen.io/anon/pen/Iicae
在这里,用户认为他们正在点击模态框外部的文档部分,实际上他们正在点击覆盖整个页面的
.overlay
元素。哼。Markdown 坏了,或者是因为评论中的
strip_tags()
:(上面的空选择器应该是 $(‘<div class=”overlay”></div>’)
是的,透明覆盖层确实是好的做法,但它们存在问题。Z-index。如果您希望使用局部 z-index 以防止它们变得太大,则会造成损害。或者您应该确保透明覆盖层和弹出菜单的任何父元素都没有(尤其是不打算)局部定位和设置 z-index。
我使用 jQuery 的 focusout 事件来隐藏表单,并且运行良好,或者我错过了什么?
我总是使用以下代码来实现此行为
@CBroe:是的,我的意思是这段代码
(使用真实的 & 符号)
我一直拒绝在文档上使用事件来通知其他打开的元素。
有点像 Kadhim,我一直都在菜单上使用 blur 事件。然后冒泡就不是问题了。
我一直想知道在内联 JS、jQuery 和传统 JS 中 `return false` 的区别。感谢您以及本文的其余部分。
非常棒的文章!因为您从检测元素外部的点击开始,所以我推荐这样做 http://bassta.bg/2013/08/detect-click-event-outside-element/
var $box = $(“.box”);
完全安全,并且您不会阻止事件传播。在与其他人合作时,您必须确保不会阻止任何事件传播/冒泡
非常棒的文章。从我的项目中删除了所有 StopPropagation 实例。 https://github.com/dekajp/google-closure-grid
虽然从本质上我同意您的前提,即不应随意阻止事件冒泡,但我必须不同意您提出的创建异常处理代码的混乱网络更好。此外,您提出的问题“现在或将来,其他一些代码是否可能想知道此事件发生了?答案通常是肯定的。”也不准确。答案不是“通常”是,而是“可能是”是。
我从经验中吸取教训,因为我刚刚花了一天时间将代码重写为远离您建议的方式。有 4 个不同的菜单/弹出窗口,如果用户单击它们外部,则需要关闭(顺便说一句,这是他们唯一需要做的事情,并且没有其他代码需要知道它们的状态)。在编写全局、始终开启的侦听器时,我被迫为所有其他需要执行其他操作的点击编写了数十甚至数百个异常。我认为,仅在打开菜单/弹出窗口时才侦听“关闭点击”更有意义。
“看起来您打开了菜单 - 告诉我您何时完成”比“您在页面上的某个位置单击了!您单击了这个吗?没有。是这个吗?没有。是这个吗?没有。是这个吗?没有。这个怎么样?没有。这个?没有。这个?没有。等等等等”更有意义。