# 对 JS 事件流的深入理解

JS 事件循环 (opens new window)中,我们接触了很多 JS 自己触发的事件。但是当我们在网页上进行某些类型的交互时,也会触发事件,比如在某些内容上的点击、鼠标经过某个特定元素或按下键盘上的某些按键。当一个节点产生一个事件时,该事件会在元素结点与根节点之间按特定的顺序传播,路径所经过的节点都会收到该事件,这个传播过程称为 DOM 事件流。

# 什么是事件

JavaScript 和 HTML 之间的交互是通过事件实现的。事件,就是文档或浏览器窗口发生的一些特定的交互瞬间。可以使用监听器(或事件处理程序)来预定事件,以便事件发生时执行相应的代码。通俗的说,这种模型其实就是一个观察者模式。(事件是对象主题,而这一个个的监听器就是一个个观察者)

# 什么是事件流

事件流描述的就是从页面中接收事件的顺序。而早期的 IE 和 Netscape 提出了完全相反的事件流概念,IE 事件流是事件冒泡,而 Netscape 的事件流就是事件捕获。

事件流的执行顺序:

  • 父级捕获
  • 子级捕获
  • 子级冒泡
  • 父级冒泡

# 事件冒泡事件捕获

IE 提出的事件流是事件冒泡,即从下至上,从目标触发的元素逐级向上传播,直到 window 对象。

img

而 Netscape 的事件流就是事件捕获,即从 document 逐级向下传播到目标元素。由于 IE 低版本浏览器不支持,所以很少使用事件捕获。

img

后来 ECMAScript 在 DOM2 中对事件流进行了进一步规范,基本上就是上述二者的结合。

DOM2 级事件规定的事件流包括三个阶段: (1)事件捕获阶段 (2)处于目标阶段 (3)事件冒泡阶段

img

# DOM 事件处理

DOM 节点中有了事件,那我们就需要对事件进行处理,而 DOM 事件处理分为 4 个级别:DOM0 级事件处理,DOM1 级事件处理,DOM2 级事件处理和 DOM3 级事件处理。

img

其中 DOM1 级事件处理标准中并没有定义相关的内容,所以没有所谓的 DOM1 事件处理;DOM3 级事件在 DOM2 级事件的基础上添加了更多的事件类型。

  • DOM0 级事件,直接在 html 元素上绑定 on-event,比如 onclick,取消的话,dom.onclick = null,同一个事件只能有一个处理程序,后面的会覆盖前面的。
  • DOM2 级事件,通过 addEventListener 注册事件,通过 removeEventListener 来删除事件,一个事件可以有多个事件处理程序,按顺序执行,捕获事件和冒泡事件
  • DOM3 级事件,增加了事件类型,比如 UI 事件,焦点事件,鼠标事件

# DOM0

DOM0 级事件具有极好的跨浏览器优势,会以最快的速度绑定。第一种方式是内联模型(行内绑定),将函数名直接作为 html 标签中属性的属性值。

<div onclick="btnClick()">click</div>
<script>
function btnClick(){
    console.log("hello");
}
</script>
1
2
3
4
5
6

内联模型的缺点是不符合 w3c 中关于内容与行为分离的基本规范。第二种方式是脚本模型(动态绑定),通过在 JS 中选中某个节点,然后给节点添加 onclick 属性。

<div id="btn">点击</div>
<script>
var btn=document.getElementById("btn");
btn.onclick=function(){
    console.log("hello");
}
</script>
1
2
3
4
5
6
7

点击输出hello,没有问题;如果我们给元素添加两个事件

<div id="btn">点击</div>
<script>
var btn=document.getElementById("btn");
btn.onclick=function(){
    console.log("hello");
}
btn.onclick=function(){
    console.log("hello again");
}
</script>
1
2
3
4
5
6
7
8
9
10

这时候只有输出hello again,很明显,第一个事件函数被第二个事件函数给覆盖掉,所以脚本模型的缺点是同一个节点只能添加一次同类型事件。让我们把 div 扩展到 3 个。

<div id="btn3">
  btn3
  <div id="btn2">
    btn2
    <div id="btn1">
      btn1
    </div>
  </div>
</div>
<script>
  let btn1 = document.getElementById('btn1')
  let btn2 = document.getElementById('btn2')
  let btn3 = document.getElementById('btn3')
  btn1.onclick = function() {
    console.log(1)
  }
  btn2.onclick = function() {
    console.log(2)
  }
  btn3.onclick = function() {
    console.log(3)
  }
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

img

当我们点击 btn3 的时候输出 3,那当我们点击 btn1 的时候呢?

img

我们发现最先触发的是最底层 btn1 的事件,最后才是顶层 btn3 的事件,因此很明显是事件冒泡。DOM0 级只支持冒泡阶段。

img

# DOM2

进一步规范之后,有了 DOM2 级事件处理程序,其中定义了两个方法:

  1. addEventListener() ---添加事件侦听器
  2. removeEventListener() ---删除事件侦听器

函数均有 3 个参数, 第一个参数是要处理的事件名 第二个参数是作为事件处理程序的函数 第三个参数是一个 boolean 值,默认 false 表示使用冒泡机制,true 表示捕获机制。

<div id="btn">点击</div>

<script>
var btn=document.getElementById("btn");
btn.addEventListener("click",hello,false);
btn.addEventListener("click",helloagain,false);
function hello(){
    console.log("hello");
}
function helloagain(){
    console.log("hello again");
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13

这时候两个事件处理程序都能够成功触发,说明可以绑定多个事件处理程序,但是注意,如果定义了一摸一样时监听方法,是会发生覆盖的,即同样的事件和事件流机制下相同方法只会触发一次,

<div id="btn">点击</div>

<script>
var btn=document.getElementById("btn");
btn.addEventListener("click",hello,false);
btn.addEventListener("click",hello,false);
function hello(){
    console.log("hello");
}
</script>
1
2
3
4
5
6
7
8
9
10

这时候 hello 只会执行一次;让我们把 div 扩展到 3 个。

<div id="btn3">
    btn3
    <div id="btn2">
        btn2
        <div id="btn1">
            btn1
        </div>
    </div>
</div>
<script>
    let btn1 = document.getElementById('btn1');
    let btn2 = document.getElementById('btn2');
    let btn3 = document.getElementById('btn3');
    btn1.addEventListener('click',function(){
        console.log(1)
    }, true)
    btn2.addEventListener('click',function(){
        console.log(2)
    }, true)
    btn3.addEventListener('click',function(){
        console.log(3)
    }, true)
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

img

这时候看到顺序和 DOM0 中的顺序反过来了,最外层的 btn 最先触发,因为 addEventListener 最后一个参数是 true,捕获阶段进行处理。

img

那么冒泡和捕获阶段谁先执行呢?我们给每个元素分别绑定了冒泡和捕获两个事件。

btn1.addEventListener(
  'click',
  function() {
    console.log('btn1捕获')
  },
  true
)
btn1.addEventListener(
  'click',
  function() {
    console.log('btn1冒泡')
  },
  false
)

btn2.addEventListener(
  'click',
  function() {
    console.log('btn2捕获')
  },
  true
)
btn2.addEventListener(
  'click',
  function() {
    console.log('btn2冒泡')
  },
  false
)

btn3.addEventListener(
  'click',
  function() {
    console.log('btn3捕获')
  },
  true
)
btn3.addEventListener(
  'click',
  function() {
    console.log('btn2冒泡')
  },
  false
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

img

我们看到先执行捕获阶段的处理程序,后执行冒泡阶段的处理程序,我们把顺序换一下再看运行结果:

btn1.addEventListener(
  'click',
  function() {
    console.log('btn1冒泡')
  },
  false
)
btn1.addEventListener(
  'click',
  function() {
    console.log('btn1捕获')
  },
  true
)

btn2.addEventListener(
  'click',
  function() {
    console.log('btn2冒泡')
  },
  false
)
btn2.addEventListener(
  'click',
  function() {
    console.log('btn2捕获')
  },
  true
)

btn3.addEventListener(
  'click',
  function() {
    console.log('btn3冒泡')
  },
  false
)
btn3.addEventListener(
  'click',
  function() {
    console.log('btn3捕获')
  },
  true
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

img

我们发现在触发的目标元素上不区分冒泡还是捕获,按绑定的顺序来执行。

# 阻止冒泡

有时候我们需要点击事件不再继续向上冒泡,我们在 btn2 上加上 stopPropagation 函数,阻止程序冒泡。

btn1.addEventListener(
  'click',
  function() {
    console.log('btn1冒泡')
  },
  false
)
btn1.addEventListener(
  'click',
  function() {
    console.log('btn1捕获')
  },
  true
)

btn2.addEventListener(
  'click',
  function() {
    console.log('btn2冒泡')
  },
  false
)
btn2.addEventListener(
  'click',
  function(ev) {
    ev.stopPropagation()
    console.log('btn2捕获')
  },
  true
)

btn3.addEventListener(
  'click',
  function() {
    console.log('btn3冒泡')
  },
  false
)
btn3.addEventListener(
  'click',
  function(e) {
    console.log('btn3捕获')
  },
  true
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

img

可以看到 btn2 捕获阶段执行后不再继续往下执行。

img

# 事件委托

如果有多个 DOM 节点需要监听事件的情况下,给每个 DOM 绑定监听函数,会极大的影响页面的性能,因为我们通过事件委托来进行优化,事件委托利用的就是冒泡的原理。

<ul>
    <li>1</li>
    <li>2</li>
    <li>3</li>
    <li>4</li>
    <li>5</li>
</ul>
<script>
    var li_list = document.getElementsByTagName('li')
    for(let index = 0;index<li_list.length;index++){
        li_list[index].addEventListener('click', function(ev){
            console.log(ev.currentTarget.innerHTML)
        })
    }
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

正常情况我们给每一个 li 都会绑定一个事件,但是如果这时候 li 是动态渲染的,数据又特别大的时候,每次渲染后(有新增的情况)我们还需要重新来绑定,又繁琐又耗性能;这时候我们可以将绑定事件委托到 li 的父级元素,即 ul。

var ul_dom = document.getElementsByTagName('ul')
ul_dom[0].addEventListener('click', function(ev) {
  console.log(ev.target.innerHTML)
})
1
2
3
4

上面代码中我们使用了两种获取目标元素的方式,target 和 currentTarget,那么他们有什么区别呢:

  • target 返回触发事件的元素,不一定是绑定事件的元素
  • currentTarget 返回的是绑定事件的元素

因此我们总结一下事件委托的优点:

  1. 提高性能:每一个函数都会占用内存空间,只需添加一个事件处理程序代理所有事件,所占用的内存空间更少。
  2. 动态监听:使用事件委托可以自动绑定动态添加的元素,即新增的节点不需要主动添加也可以一样具有和其他元素一样的事件。

在线客服