JavaScript Events

关于 event.preventDefaultevent.stopPropagation 的何时使用以及方法的作用。

在 JavaScript 中想要处理事件很简单,在处理相对扁平的HTML DOM结构的时候更能体现出来。但是,当事件发生 冒泡捕获 的时候,事情就开始变得不简单了。coder们通常会通过使用 event.preventDefaultevent.stopPropagation 来尝试解决问题。比如,当我用 event.preventDefault 解决不了这个问题的时候,就会尝试使用 event.stopPropagation ,如果都不生效,那会尝试同时使用这两个。

在开始之前,需要介绍一下JavaScript中可能的两种事件处理方式(在所有现代浏览器中,因为Internet Explorer 9 之前根本不支持事件捕获)。

事件类型(捕获和冒泡)

所有的现代浏览器都支持事件捕获,但往往很少有人去使用。有趣的是,这是 Netscape 最初支持的唯一事件类型。Netscape 最大的竞争对手 Microsoft Internet Explorer 根本不支持事件捕获,而仅支持另一种称为事件冒泡的事件处理方式。在W3C成立的时候,他们发现了这两种事件处理方式的优点,并且可以通过设置 addEventListener 方法的第三个参数来设置这一次应该用哪一种事件处理方式来处理事件。在最开始,这个参数是一个布尔值,但现在,所有的现代浏览器都支持以一个 options 作为第三个参数,即:可以直接设置一个 true|false 或者 { capture: true|false } 来设置是否需要使用事件捕获。

el.addEventListener('click', function(event){}, false);
el.addEventListener('click', function(event){}, { capture: false });

当直接设置为 true|false 的时候,true 则表示使用事件捕获,false 表示使用事件冒泡。而使用 options 形式的时候,capture 设置为 true 表示使用事件捕获,false 表示使用事件冒泡。同时,这两种形式的参数都是可选的,如果省略了第三个参数的话,则会使用 false 来设置默认值,意味着这个事件使用事件冒泡。事件冒泡/捕获相关的可以通过查看 MDN 文档来了解更多。

事件捕获

如果在处理事件的时候选择了事件捕获,那意味着需要知道事件的起源以及它是如何传播的。

所有的事件都是从windows开始,并且首先经过捕获阶段。当事件被绑定时,会从windows上开始逐级向DOM树下传递事件,直到抵达目标元素节点。如果设置的是事件冒泡,则也会发生这种情况。查看以下示例代码:

<html>
  <body>
     <div id="a">
       <div id="b">
         <div id="c">
           我是 C君~
         </div>
       </div>
    </div>
  </body>

  <script>
    document.querySelector('#c').addEventListener('click', function(ev) {
      console.log('#c 被点击了')
    }, true)
  </script>
</html>

当用于点击 #c 的时候,将会分派一个源自于 window 对象的事件,然后这个事件会通过它的子级传播,如下所示:

windowdocument<html>body#a#b#c ,以此类推,直到到达目标元素节点。即便没有在 windowdocument<html> 或者是 <body> 上监听事件也没关系,事件的起源依旧是从 window 上发出,并向上描述得那样开始传播。在例子中,点击元素事件就会开始传播。这意味着点击事件开始时,浏览器会询问 window 以下几个问题:

"window 在捕获阶段有什么东西在监听点击事件吗?",如果有则会触发对应的处理事件,没有则什么都不会发生。

事件现在传播到 document 了,浏览器会问:“document 在捕获阶段有什么东西在监听点击事件吗?”,如果有则触发,没有则什么也不做。

事件现在到达了 <html> ,浏览器问:“ <html> 在捕获阶段有什么东西在监听点击事件吗?”,如果有则触发,没有则什么也不做。同样的步骤还会在 #a#b 上重复发生,直到到达元素节点 #c 。浏览器会问:“#c 在捕获节点有什么东西在监听点击事件吗?”,这次它得到的回答是肯定的:“Yes!!!” 事件到达目标的这段时间被称之为“目标阶段”。此时,事件处理程序将触发,浏览器将 console.log('#c 被点击了') 。事件到这里就结束了?不,不是的,这个过程根本没有完成。过程还在继续,它的路还要继续走下去,但是它现在要走的路是“返航”(事件冒泡阶段)。整个行为就像是蝙蝠的超声波反弹的过程。

事件冒泡

程序执行到这以后,浏览器会问:“#c 在冒泡阶段有什么东西在监听点击事件吗?”,“Yes!!!”,事件触发,但需要注意的是,这么做是可能的,事件监听在点击(或者是其他事件类型)都存在 捕获冒泡 阶段。如果在两个阶段都绑定了事件处理程序(比如设置了两次 addEventListener ,一次为 capture: true ,一次为 capture: false )的话,那么事件就会一起执行,只是他们执行的阶段不同(一次在捕获,一次在冒泡)。

接下来事件传播的方向将会和捕获相反,改为由下而上传递,#c 已经执行事件完毕了,接下来浏览器会问 #b :“#b 在冒泡阶段有什么东西在监听点击事件吗?”,有则触发事件,没有则什么也不做。就这样事件会逐级向上询问,直到 window ,“window 上有什么东西在监听点击事件吗?”,“没有!快滚,再问我打死你!!!”(抖个机灵~)。这是一段非常漫长的过程,从这个过程中也能想通为什么页面元素一多,嵌套层级很深的时候,页面就会慢的卡顿、响应慢了。

那现在不妨动手试试?

<html>
  <body>
     <div id="a">
       <div id="b">
         <div id="c">
           我是 C君~
         </div>
       </div>
    </div>
  </body>

  <script>
    document.addEventListener('click', function(ev) {
      console.log('document 捕获')
    }, true)
    document.documentElement.addEventListener('click', function(ev) {
      console.log('html 捕获')
    }, true)
    document.body.addEventListener('click', function(ev) {
      console.log('body 捕获')
    }, true)
    document.querySelector('#a').addEventListener('click', function(ev) {
      console.log('#a 捕获')
    }, true)
    document.querySelector('#b').addEventListener('click', function(ev) {
      console.log('#b 捕获')
    }, true)
    document.querySelector('#c').addEventListener('click', function(ev) {
      console.log('#c 捕获')
    }, true)

    document.addEventListener('click', function(ev) {
      console.log('document 冒泡')
    }, false)
    document.documentElement.addEventListener('click', function(ev) {
      console.log('html 冒泡')
    }, false)
    document.body.addEventListener('click', function(ev) {
      console.log('body 冒泡')
    }, false)
    document.querySelector('#a').addEventListener('click', function(ev) {
      console.log('#a 冒泡')
    }, false)
    document.querySelector('#b').addEventListener('click', function(ev) {
      console.log('#b 冒泡')
    }, false)
    document.querySelector('#c').addEventListener('click', function(ev) {
      console.log('#c 冒泡')
    }, false)
  </script>
</html>

点击元素以后,控制台将输入:

document 捕获
html 捕获
body 捕获
#a 捕获
#b 捕获
#c 捕获
#c 冒泡
#b 冒泡
#a 冒泡
body 冒泡
html 冒泡
document 冒泡

event.stopPropagation()

了解过事件是如何捕获和冒泡了以后,我们可以看向 stopPropagation 了。

stopPropagation() 可以在(大部分)本地DOM事件上调用该方法,为什么要说大部分?因为像 focus blur load scroll 等一类的事件不属于这一类。但依旧可以调用 event.stopPropagation() 方法,只是它们什么也不会发生,因为事件根本不会传播。

stopPropagation() 的作用

它所做的事情则是当事件到达该方法内部以后,阻止事件继续向下传递,会从当前位置截断。这个时候再去点击 #c 则会打印出如下:

document 捕获
html 捕获
body 捕获
#a 捕获
#b 捕获

能发现就连冒泡阶段的事件都被阻止了,因为事件在进行到 #b 捕获阶段时,就已经被停止了事件传播,链条在此断开了所以不会继续向下执行。

event.stopImmediatePropagation()

这是个什么方法?奇奇怪怪的。它类似于 stopPropagation,但作用不是阻止事件传播到后代(捕获)或祖先(冒泡),此方法进适用于将多个事件处理程序绑定到单个元素的时候。由于 addEventListener() 支持多种事件类型,所以完全可以将多个事件处理函数绑定到单个元素。发生这种情况的时候,(大多数浏览器)事件处理程序按照他们绑定的顺序执行,调用 stopImmediatePropagation 可以防止其后续事件的执行,看看实例:

<html>
  <body>
    <div id="a">我是A元素</div>
  </body>
  <script>
    document.querySelector('#a').addEventListener('click', ev{
        console.log('事件处理函数1')
    })
    document.querySelector('#a').addEventListener('click', ev → {
        ev.stopImmediatePropagation()
        console.log('事件处理函数2')
    })
    document.querySelector('#a').addEventListener('click', ev → {
        console.log('事件处理函数3')
    })
  </script>
</html>

这么做的话,点击元素以后,控制台会输出:

事件处理函数1
事件处理函数2

因为第二个事件处理函数中调用了 ev.stopImmediatePropagation() 方法,所以第三个事件处理程序永远不会运行,但如果改为 ev.stopPropagation() ,第三个方法将会继续向下执行。

event.preventDefault()

stopPropagation() 是阻止“向下”(捕获)或“向上”(冒泡)传播的方法,那 preventDefault() 方法是做什么的?好像它也做了一样的事?但其实不是的,他俩经常被混淆,实际上他们彼此之间并没有太大关系。当你看到 preventDefault() 的时候,你需要想的是 “什么行为” 这个词,然后联想到“阻止默认行为(操作)”。

问题由此而来,默认操作是什么?嗯。。。这个问题其实很难回答,因为它的执行是依赖这篇文章所讨论的 Element + Event 的组合。更让人满脸问号的是,有时候根本没有默认操作!举个简单例子:

<a id="banlify" href="https://google.com">click me</a>

点击以上链接的时候会发生什么?是的,跳转链接。在这种情况下,元素是一个锚点,事件是一个点击事件。<a> + click 的时候,页面将会跳转至链接,如果我不想跳转的话怎么办?那就需要 preventDefault() 来帮助你了。

document.querySelect('#banlify').addEventListener('click', ev{
    ev.preventDefault()
    console.log('我触发了点击,但并不想跳转')
}, false)

yep!确实阻止了事件的传递。在以上的例子中,浏览器页面不会跳转,只会在控制台打印一段 我触发了点击,但并不像跳转 文字。响应的默认事件有很多,比如说 <form> 元素的 submit 事件,我们可以利用preventDefault() 来阻止表单的默认提交事件。又或者是说 <input> 元素的 keypress (或者是 keydown | keyup )事件,我们可以当输入字符达到一定上限时阻止用户继续输入。

玩点花的

如果在一个事件中,同时调用以上三个方法时将会发生什么?

function preventEverything(e) {
  e.preventDefault();
  e.stopPropagation();
  e.stopImmediatePropagation();
}

document.addEventListener('click', preventEverything, true);
document.addEventListener('keydown', preventEverything, true);
document.addEventListener('mousedown', preventEverything, true);
document.addEventListener('contextmenu', preventEverything, true);
document.addEventListener('mousewheel', preventEverything, { capture: true, passive: false });

以上代码将会使得当前页面毫无作用,你什么也做不了。因为所有的事件起源于 window ,所以所有的 clickkeydownmousedowncontextmenumousewheel 和其他元素的事件监听和任何程序都将是死路一条。除此之外,还调用了 stopImmediatePropagation() ,后续在引入这个代码片段的页面也将会变得毫无响应。

有一点需要注意的是,stopPropagation()stopImmediatePropagation() 并不是使得页面变得无效的原因,它们做的事情只是阻止事件到达原本的目标而已。同时还调用了 preventDefault() 这才是使整个页面无效的真正原因。