阻止事件传播的危害

Avatar of Philip Walton
Philip Walton 发表

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

以下是 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),但实际上结果却大不相同。以下是每种情况下实际发生的情况

  1. 从内联事件处理程序返回false可以阻止浏览器导航到链接地址,但它不会阻止事件在 DOM 中传播。
  2. 从 jQuery 事件处理程序返回false可以阻止浏览器导航到链接地址,并且它会阻止事件在 DOM 中传播。
  3. 从常规 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 模态框这样简单的事情,还是像事件跟踪分析这样关键的事情,访问事件对象都很重要。如有疑问,请不要停止传播。