事件:事件是用户或浏览器自身执行的某种动作,如 click、load 和 mouseover 都是事件的名字。响应某个事件的函数叫做事件处理函数/事件处理程序/事件句柄。如果想要绑定多个函数,则需要用到事件监听器。

1. 事件绑定的几种方式

1)在 HTML 标签中绑定

<input type="button" id="btn" onclick="fn()">
<!--或者-->
<input type="button" id="btn" onclick="alert(1)">

2)在 JS 代码中绑定

DOM0级事件处理程序

document.getElementById("btn").onclick = function(){ 
	//......
};

说明:无法绑定多个事件处理函数,当绑定多个的时候,前面的会被覆盖,只有最后一个会生效。

IE 事件处理程序

document.getElementById("btn").attachEvent("onclick",function(){
    //......
})

说明:

  • 不属于 w3c 标准,仅 IE8 及以下支持该方法;
  • 事件类型要加 on;
  • 可以绑定多个事件处理函数,会按照绑定顺序一一执行
  • 微软提出的,因此采用的机制也是微软提出的冒泡机制

DOM2级事件处理程序

document.getElementById("btn").addEventListener("click",function(){
    //......
})

说明:

  • 事件类型不加on;
  • 第三个参数不设置的时候,默认为 false,即 useCapture = false,表示该函数会在事件流的冒泡阶段触发;
  • 可以绑定多个事件处理函数,如果都是冒泡或者捕获,则会按照绑定顺序一一执行;如果一个是冒泡,一个是捕获,则捕获先于冒泡执行

2. DOM 事件流

1)什么是事件流?

当触发某个 DOM 元素上面的事件时,会产生一股事件流。事件流会有三个阶段:

  • 捕获阶段:事件流会从根节点流向目标节点,并执行那些声明了在捕获阶段触发的同类型事件处理函数
  • 目标阶段:事件流停在目标节点这里,并执行目标节点上的事件处理函数。这个阶段实际上通常看作是冒泡阶段的一部分
  • 冒泡阶段:事件流会从目标节点再流向根节点,并执行那些声明了在冒泡阶段触发的同类型事件处理函数

2)如何获取事件流的相关信息?

在任意一个事件处理函数中,都可以拿到 event 参数,这个参数包含了和事件流相关的一些信息:

  • type:用于获取事件类型
  • target:实际触发事件的那个对象,或者说事件源对象,它是固定不变的
  • currentTarget:当前事件流经过的对象,是动态变化的
  • stopPropagation():阻止事件继续传播
  • preventDefault():阻止事件的默认行为。移动端用的多

比如下面的代码:

<div id="son1">
	<div id="son2">
		<div id="son3"></div>
	</div>
</div>

<script>
    var son1 = document.getELementById("son1")
    var son2 = document.getELementById("son2")
    var son3 = document.getELementById("son3")
    son1.addEventListener("click",log)
    son2.addEventListener("click",log)
    son3.addEventListener("click",log)
    function log(e){
        console.Log("target:" + e.target.id + " " + "currentTarget:" + e.currentTarget.id)
    }
</script>     

打印结果如下:

可以看到,点击最里面的 son3 后,target 一直不变。而由于冒泡,导致 currentTarget 是动态变化的。

3. 事件处理函数的执行顺序

单个元素绑定了多个事件处理函数,谁先执行呢?嵌套元素绑定了多个事件处理函数,谁先执行呢?

这里直接给出结论:

一个事件流可能经过的元素有两种,一种是目标元素(target),一种是事件流流过的元素(currentTarget)。对于事件流流过的元素,设置为 true 的事件处理函数,一定会在捕获阶段触发,设置为 false 的事件处理函数,一定会在冒泡阶段触发,这点不管在什么浏览器都一样;但对于目标元素则不同,在以前,该元素的事件处理函数的执行顺序,始终是按照绑定顺序执行的,指定 true 或者 false 没有任何影响;但对于现在的 Chrome 来说,指定 true 或者 false 则可以让该函数在捕获阶段或者冒泡阶段触发 —— 也就是说,目标元素可以真正参与到事件流的不同阶段中。

1)单个元素

先来看单个 DOM 元素的情况。以下面的代码为例:

<script>
    window.onload = function(){
   	    let outA = document.getElementById("outA");  
        outA.addEventListener('click',function(){console.log(1)},false)
        outA.addEventListener('click',function(){console.log(2)},true)
        outA.onclick = function(){console.log(5)}
        outA.addEventListener('click',function(){console.log(3)},true)
        outA.addEventListener('click',function(){console.log(4)},true)
    };
</script>
<body>
    <div id="outA"></div>
</body>

如果是旧版的 Chrome 或者现在的 Firefox:

那么无需理会事件捕获或者事件冒泡,事件处理函数是按照绑定顺序一一执行的

如果是 Chrome89 之后的版本:

那么声明了 true 的事件处理函数将在事件流的捕获阶段触发,而声明了 false 的或者 onclick 绑定的,则在冒泡阶段触发。问题来了,false 和 onclick 谁先执行呢?很简单,谁先绑定谁就先执行。

注意下面的代码。尽管 onclick 被覆盖了,但它始终是最先绑定的,这一点不会改变。所以执行的时候先打印 3 后打印 2。

outA.onclick = function(){console.log(1)}
outA.addEventListener('click',function(){console.log(2)},false)
outA.onclick = function(){console.log(3)}

2)嵌套元素

再来看存在嵌套的情况。注意这里所说的嵌套指的是 HTML 结构上的嵌套,不光是指视图上的。

以下面的代码为例:

<script>
    window.onload = function(){
      var outA = document.getElementById("outA");
      var outB = document.getElementById("outB");
      var outC = document.getElementById("outC");
      outC.addEventListener("click",function(){console.log("target1");},false);
      outC.addEventListener("click",function(){console.log("target2");},true);
      outC.addEventListener("click",function(){console.log("target3");},true);
      outC.addEventListener("click",function(){console.log("target4");},false);

      outA.addEventListener("click",function(){console.log("bubble1");},false);
  	  outB.onclick = function(){console.log(11111)}
      outB.addEventListener("click",function(){console.log("bubble2");},false);
      outA.onclick = function(){console.log(22222)}

      outA.addEventListener("click",function(){console.log("capture1");},true);
      outB.addEventListener("click",function(){console.log("capture2");},true);
    };
</script>
<body>
  <div id="outA">
    <div id="outB">
      <div id="outC"></div>       
    </div>
  </div>
</body>

如果是旧版的 Chrome 或者现在的 Firefox:

事件流先经历捕获阶段,这个阶段按照 AB 的顺序执行那些声明了在捕获阶段触发的函数(capture1–>capture2);接着进入目标阶段,这个阶段无视目标节点的绑定类型是事件捕获还是事件冒泡,直接按照绑定顺序一一执行;然后进入冒泡阶段,这个阶段按照 BA 的顺序执行那些声明了在冒泡阶段触发的函数(11111–>bubble2–>bubble1–>22222)。注意 false 和 onclick 仍然没有明确的先后顺序,谁先注册谁就先执行。

如果是 Chrome89 之后的版本:

同样的,事件流先经历捕获阶段,这个阶段按照 ABC 的顺序执行那些声明了在捕获阶段触发的函数(capture1–>capture2–>target2–>target3);接着进入目标阶段,然后进入冒泡阶段,这个阶段按照 CBA 的顺序执行那些声明了在冒泡阶段触发的函数(target1–>target4–>11111–>bubble2–>bubble1–>22222)。注意 false 和 onclick 仍然没有明确的先后顺序,谁先注册谁就先执行。

4. 阻止事件冒泡和捕获

子元素上发生某个事件的时候,我们可能不希望执行父元素上绑定的事件处理函数,这时候可以选择阻止事件捕获和冒泡,避免没有意义的函数调用。
IE8 之前可以通过 window.event.cancelBubble = true 阻止事件的继续传播;IE9+/FF/Chrome 则是通过在事件处理函数里面调用 event.stopPropagation() 阻止事件的继续传播。

以下面代码为例:

<script>
	window.onload = function(){
		let outA = document.getElementById('outA')
         let outB = document.getElementById('outB')
         let outC = document.getElementById('outC')
         outC.addEventListener('click',function(event){
             console.log('target')
             event.stopPropagation()
         },false)
        outA.addEventListener('click',function(){console.log('bubble')},false)
        outA.addEventListener('click',function(){console.log('capture')},true)        
	}
</script>

<div id="outA">
    <div id="outB">
        <div id="outC"></div>
    </div>
</div>

当点击 outC 的时候,打印出 capture–>target,不会打印出 bubble。
因为当事件流经过 outC 上的处理函数时,调用了 event.stopPropagation(),阻止了事件继续传播到冒泡阶段。想要在哪个节点阻止传播,就在哪个节点的事件处理函数中调用 event.stopPropagation()

5. 事件代理/事件委托

1)概述

事件委托又叫事件代理。它本质上是基于事件冒泡的,通过只指定一个事件处理函数,来管理某一类型的所有事件。

我们可以用取快递的例子来理解这个东西:

假设:有三个同事预计会在周一收到快递。为签收快递,有两种办法:一是三个人在公司门口等快递;二是委托给前台代为签收。现实当中,我们大都采用委托的方案(公司也不会容忍那么多员工站在门口就为了等快递)。前台收到快递后,她会判断收件人是谁,然后按照收件人的要求签收,甚至代为付款。这种方案还有一个优势,那就是即使公司里来了新员工(不管多少),前台也会在收到寄给新员工的快递后核实并代为签收。

这里其实还有两层意思的:
第一,现在委托前台的同事是可以代为签收的,即程序中的现有的 dom 节点是有事件的;
第二,新员工也是可以被前台代为签收的,即程序中新添加的 dom 节点也是有事件的。

2)为什么要使用事件委托

简单来说,就是为了减少不必要的 dom 操作,优化性能

考虑这样一个场景:我们有 100 个 li,每个 li 都有相同的点击事件,可能我们会用 for 循环的方法,来遍历所有的 li,一一给它们添加事件处理函数,那这么做会存在什么影响呢?

每个函数都是一个对象,是对象就会占用内存,对象越多,内存占用率就越大,自然性能就越差了。比如上面的 100 个 li,就要占用 100 个内存空间,如果是 1000 个,10000 个呢?但如果用事件委托,那么我们就可以只给所有 li 的某个公共父级绑定一个事件处理函数,虽然实际点击的是某个 li 子元素,但反正有冒泡机制,这个点击事件最终是会冒泡到达那个父元素的,在父元素的处理函数里,我们只要判断点击的是哪个子元素,并作相应的处理就可以了

3)事件委托的实际应用

需求一:不管点击哪个 li,都能打印 123

<ul id="ul">
    <li>111</li>
    <li>222</li>
    <li>333</li>
    <li>444</li>
</ul>

不使用事件委托,我们可能会这么实现:

window.onload = function(){
	var ul = document.getElementById("ul")
	var aLi = ul.getElementsByTagName('li')
	for(var i = 0;i < aLi.length;i ++){
		aLi[i].onclick = function(){
			console.log(123)
		}
	}
}

使用事件委托,则只需要绑定一次:

window.onload = function(){
	var ul = document.getElementById("ul")
	ul.onclick = function(){
		console.log(123)
	}
}

不管是哪个 li 被点击 —— 由于冒泡机制,事件最终都会冒泡到 ul 上,并触发 ul 上的点击事件,弹出 123。

需求二:只有点击的子元素是 li 的时候,才会打印 123

<ul id="ul">
    <li>111</li>
    <div>222</div>
</ul>

之前我们说过,在任意一个事件处理函数中,可以通过 event.target 拿到此次事件的目标节点,或者说事件源节点,而这个节点提供了很多信息给我们。

只有事件源节点是 li 的时候 —— 只有点击的是 li 的时候,才打印 123:

window.onload = function(){
  var ul = document.getElementById("ul");
  ul.onclick = function(event){
    var event = event || window.event
    var target = event.target || event.srcElement
    if(target.nodeName.toLowerCase() == 'li'){
         console.log(123)
    }
  }
}

需求三:点击的子元素不同,执行不同的操作

<div id="box">
    <input type="button" id="add" value="添加" />
    <input type="button" id="remove" value="删除" />
    <input type="button" id="move" value="移动" />
    <input type="button" id="select" value="选择" />
</div>

这里依然可以通过 event.target 帮助判断:

window.onload = function(){
    var oBox = document.getElementById("box");
    oBox.onclick = function (event) {
        var event = event || window.event
        var target = event.target || event.srcElement
        // 如果是 input 类型的子元素
        if(target.nodeName.toLocaleLowerCase() == 'input'){
            // 根据 input 的 id 不同,执行不同的操作
            switch(target.id){
                case 'add':
                    console.log('添加')
                    break
                case 'remove':
                    console.log('删除')
                    break
                case 'move':
                    console.log('移动')
                    break
                case 'select':
                    console.log('选择')
                    break
        	}
		}
	}
}

如果是新增的节点,在它身上触发的事件可以被父元素代理吗?

这就是事件委托另一个强大的地方了。如果说我们是采用手动遍历,为所有子元素添加事件处理函数的方式,那么一旦新增节点,我们还得重复这个过程;而使用事件委托则不用,因为不管新增了什么节点,它都会是父节点的一个子节点,它触发的事件都可以被父节点代理。

4)注意事项

那什么样的事件可以用事件委托,什么样的事件不可以用呢?

  • 诸如 click,mousedown,mouseup,keydown,keyup,keypress 等都是可以使用事件委托的。
  • 诸如 mousemove 就不适合使用,因为每次都要计算它的位置,非常不好把控,至于 focus,blur 之类的就更不用说了,本身就没有冒泡的特性,自然就不能用事件委托了。