Construction | 张小伦的网络日志

(译)阻止事件传播的危害

Posted on:2014-05-28 21:23
    译文
    JavaScript

原文标题:The Dangers of Stopping Event Propagation
原文链接:http://css-tricks.com/dangers-stopping-event-propagation/

这是 Philip Walton 发布在 css-trick 上的一篇文章。他将会向我们解释为什么不能轻易地阻止事件的传播,而是尽可能的去避免。

如果你是一个前端开发人员,在你的工作中,应该有过这样的经历:编写一个自定义的弹出窗口或者对话框,当用户点击页面中弹窗的之外的其他地方,弹窗隐藏。如果你试着在网上搜索试图找到最好的解决方案,没准你会遇到这个 Stack Overflow 上的一个问题:How to detect a click outside an element?

下面的代码是投票最高的回答推荐的方案:

$('html').click(function() {
  // Hide the menus if visible.
});

$('#menucontainer').click(function(event){
  event.stopPropagation();
});

这个例子可能说的不太明显。简单的解释一下:如果一个鼠标点击事件冒泡到 <\html> 元素上时,隐藏菜单。如果点击事件是在元素 #menucontainer 内部触发的,则阻止在这个事件使得事件永远不会到达 <\html>元素上。因此只有在元素 #menucontainer 外触发点击事件时菜单才会被隐藏。

上面的方案一直以来都是如此的简单优雅。然后,不幸的是,这绝对是一个很可怕的建议! 这种办法就像是:通过关掉浴室的水来解决淋浴漏水问题。这有效果,但是却完完全全忽略了页面其他代码可能需要监听这个事件的可能性。好比你的蓬头漏水了但是你还得用洗手盆的水龙头。

##取消冒泡会导致什么问题(What Can Go Wrong?)?

你可能会说:谁还会自己去写这种代码?我使用经过良好测试的库,比如 Bootstrap,所以我不用担心这个问题,不是吗?

可是,真的是这样的吗?在现在流行的一些库中,也有一些库在处理事件中阻止了事件的传播。为了证明这一点,让我来为你演示一下在 Ruby on Rail 中使用 Bootstrap 搞出一个bug是多么的容易。Rails 使用了一个叫做 jquery-ujs 的JavaScript库,它可以让开发者通过 data-remote 以声明的方式为链接添加远程的 Ajax 调用。在下面的例子中,如果你点开了 Dropdown ,点击窗体的其他地方这个 dropdown 会自动隐藏。但是,如果点开 Dropdown 之后再点击 Remote Link ,dropdown将不会隐藏。

之所以会产生这个bug,是因为在 Bootstrap 框架中,负责监听关闭 下拉菜单的事件绑定在 document 上。但是由于 jquery-ujs 中,它的属性 data-remote 绑定了阻止事件传播的处理程序,这些鼠标点击事件永远都不会到达 document,因此在 Bootstrap 中的代码永远都不会实现。

最糟糕的是这个bug在 Bootstrap(或者其他库和框架)中,是绝对没有办法解决的。如果你在处理 DOM , 你只能任凭那些写的很烂的代码在页面上运行。

###Events 带来的问题(The Problem with Events)

像 JavaScript 中的很多对象一样,DOM 事件也是全局的。众所周知,没有处理好全局变量将会使得代码混乱不堪。

修改一个简单的事件起初可能没有什么害处,但是这会带来风险。当你修改的事件执行过程是其他人需要的或者其他代码所依赖的,代码便会产生bug,只是时间问题而已。 而这种类型的bug是最难跟踪得bug之一。

##为什么要阻止事件的传播(Why Do People Stop Event Propagation?)?

事实上,开发人员总是在不知不觉中阻止了事件的传播

###Return false

当你从一个事件处理程序返回 false 的时候,关于这个过程中发生的事情总是有很多混淆的地方。请思考下面三个例子:

HTML :

<!-- 一个行间的事件处理程序. -->
<a href="http://google.com" onclick="return false">Google</a>

Jquery :

// 一个 jQuery 事件处理程序.
$('a').on('click', function() {
  return false;
});

JavaScript :

//一个原生的事件处理程序
var link = document.querySelector('a');
link.addEventListener('click', function() {
  return false;
});

这三个例子都只做一件相同的事情:返回 false,但是实际上返回的结果却是大相径庭,来看看上述的每个例子到底发生了什么。

  • 行间脚本:阻止链接跳转到指定的地址,但是不会阻止事件在 DOM 中的传播。
  • jQuery:阻止链接跳转到指定的地址,同时阻止事件在 DOM 中的传播。
  • 原生js:不做任何事

当你期待一件事情发生的时候,它并没有发生,你通常可以很快的找到解决方案。而更严重的问题是,当你期待一件事情发生,它确实发生了,但是伴随着意料之外的,并不引人注意的副作用。这就是bug诞生的源头。

在 Jquery 的例子中,return false 的行为相比其他两种处理程序有哪些不同并不是完全清楚,但是确实有所不同。实际上 在这个例子中,jQuery 调用了两条语句

event.preventDefault();
event.stopPropagation();

由于 return false 的混乱,同时 jQuery 中的处理程序阻止了事件的传播,我建议不要使用。更好的做法是明确你的目的,然后直接调用这些事件的方法。

###性能(Performance)

在老式的浏览器中,一个复杂的 DOM 结构真的会减慢你的站点加载速度。同时由于事件的传播需要经过整个 DOM ,所以文档中的节点越多,事件到达目标元素的速度就越慢。

Peter Paul Koch 中的一篇文章中提到了一个具体的例子:

If your document structure is very complex (lots of nested tables and such) you may save system resources by turning off bubbling. The browser has to go through every single ancestor element of the event target to see if it has an event handler. Even if none are found, the search still takes time.

如果你的文档结构十分复杂(有很多嵌套的表格或者其他类似的结构),你可以通过取消事件冒泡来节约系统的资源。浏览器不得不检查事件的对象元素的每一个祖先元素是否绑定了事件处理程序。即使没有绑定,这个搜索也依旧会花费时间。

然而对于现在浏览器,你通过阻止事件传播得到的性能提升很有可能会被你的用户忽视,也就是说大部分时候,用户察觉不到。

我觉得不用担心事件通过整个 DOM 传播。毕竟,这个是规范的一部分,而且浏览器也能够很好的处理。

##如何代替(What to Do Instead)

首先作为一般的规则,阻止事件的传递不应该是一个问题的解决方法。假设你的页面上有几个事件处理程序,它们之间有时候互相干扰,同时你发现阻止事件的传递可以让代码很好的工作,这是一个很糟糕的事情!这可能可以解决暂时的问题,但是很有可能产生其他的你不知道的问题。

阻止事件的传播应该像取消一个事件一样仔细考虑,并且只有在你想取消一个事件的时候才去阻止事件的传播。可能你想阻止一个表单的提交或者不允许将页面的焦点聚集在某个区域上。在这些情况下,你会选择阻止事件的传递,是因为你不想触发默认的事件,而不是因为你不想有一个注册在 DOM 更深处的事件处理程序。

在之前提到的 Stack Overflow 上的那个问题中,总而言之,其调用 stopPropagation 的目的不是为了摆脱点击事件,而是避免在页面上运行其他代码。

之所以说它是个不好的方案,除了是因为它改变了全局对象的行为,还有它将菜单的隐藏逻辑代码放在了两个不同的而且没有联系的地方,这使得代码变得脆弱。

有一个更好的解决办法就是创建单一的事件处理程序,它的逻辑完全的封闭,这个处理程序唯一的作用就是通过给定的事件决定这个菜单是否隐藏。事实证明这个更好的方案也减少了代码量:

$(document).on('click', function(event) {
  if (!$(event.target).closest('#menucontainer').length) {
    // Hide the menus.
  }
});

上面的事件处理程序只监听在 document 上触发的点击事件,然后检查事件对象是否为 #menucontainer ,或者其子元素。如果不是,那么点击事件是在这个元素外触发的,因此菜单将被隐藏。

###取消默认事件(Default Prevented)?

About a year ago I start writing an event handling library to help deal with this problem. Instead of stopping event propagation, you would simply mark an event as “handled”. This would allow event listeners registered farther up the DOM to inspect an event and, based on whether or not it had been “handled”, determine if any further action was needed. The idea was that you could “stop event propagation” without actually stopping it.

As it turned out, I never ended up needing this library. In 100% of the cases where I found myself wanting to check if an event hand been “handled”, I noticed that a previous listener had called preventDefault. And the DOM API already provides a way to inspect this: the defaultPrevented property.

忽略了原文中的这些,直接跳到重点

DOM API 已经提供了一个属性 defaultPrevented,用以检查当前事件的默认动作是否被取消,也就是是否执行了 event.preventDefault() 方法。为了证明这个。让我举一个例子,想象一下你在文档上添加了一个事件监听器,当用户点击链接到外域的链接是使用谷歌分析跟踪用户。就想这样:

$(document).on('click', 'a', function(event) {
  if (this.hostname != 'css-tricks.com') {
    ga('send', 'event', 'Outbound Link', this.href);
  }
});

这段代码的问题是:不是所有的链接都是将用户引向其他页面的,有时候 JavaScript 会调用 preventDefault 拦截事件,然后去做其他的事情。另一个例子是,Twitter 的分享按钮打开的是一个窗口而不是跳转到 twitter.com。

为了避免跟踪到这些不一样的链接,阻止事件的传播变得很有诱惑力,但是使用 defaultPrevented 来检查事件是一个更好的方法。

$(document).on('click', 'a', function(event) {
  // 如果事件的 preventDefault() 已经被调用,则忽略该事件.
  if (event.defaultPrevented) return;
  if (this.hostname != 'css-tricks.com') {
    ga('send', 'event', 'Outbound Link', this.href);
  }
});

因为在一个点击事件的处理程序上调用 preventDefault() 使得浏览器永远阻止导向链接所指向的地址,如果事件的 defaultPrevent 属性为 true 你可以 100% 地确定用户去不了别的地方。换句话说,这种技术不仅比 stopPropagation 可靠,还不会产生副作用。

###结论(Conclusion)

希望这篇文章可以帮你在一个新的视角思考 DOM 事件。它们不是一些随意修改不会导致可怕后果的彼此孤立的碎片。它们是全局的,相互关联的对象,对代码的影响远远超过了你所意识到的。

为了避免 bug,最好的办法就是不管事件,让它们随浏览器的意愿传播。

如果你不确定要做什么,只要问问你自己下面的问题:是否有可能存在一些代码,无论是现在还是以后可能需要这个事件发生?这个问题的回答一般都是 yes,如果心存疑虑,那么不要阻止事件传播!