面试的时候经常会被问到,事件代理是利用了事件的什么机制?一般我们都会说,是利用了事件冒泡机制,但具体冒泡机制在事件代理的过程了起到了什么关键作用,我想大部分人也是不了解的。静下心来,仔细看了下有关事件和事件代理问题,自己终于找到了合适的答案。
在揭晓答案之前,还是来看一下事件的基本知识
事件操作在项目中使用频繁,jQuery封装了我们对DOM的事件操作,NodeJS中也有 events
这个事件操作模块,几乎各种语言都有事件模型,事件使用频繁,事件模型可以使我们从复杂的业务中抽象出独立的逻辑,跨越模块与模块之间传递数据,以达到类似解耦的概念。
不同的环境有不同的事件模型,在浏览器中,DOM事件模型比较特殊,重温一下DOM事件模型,再来自定义一个事件模型。
DOM事件发展
DOM事件:用户或浏览器执行的动作,如click
事件处理程序:是响应某个事件的函数,如 onclick()
也叫事件侦听器
DOM有4次版本更新,与DOM版本变更,产生了3种不同的DOM事件定义 DOM0级、DOM2级、DOM3级。由于DOM1级中没有事件的相关内容,所以没有DOM1级事件。
DOM0级事件
<p onclick="alert('click')">HTML事件处理程序</p> |
问题
- HTML代码域JavaScript代码紧密的耦合在一起,没有实现相互分离,在进行代码的更新与维护的时候就显得异常困难。
- 扩展事件处理程序的作用域链在不同浏览器当中会导致不同的结果。
- 如果不采用调用函数的方式,而是像例子中那样直接书写代码,那么代码的通用性很差,会使得整站的代码量很大,通用性差。如果提取出来,存放在函数当中,那么,会面临另一个问题——当函数还没有被定义,只是HTML、CSS代码加载完毕,用户进行点击,会完全没有反应。
DOM0中基本事件模型var btn = document.getElementById('btn');
btn.onclick = function(){
alert(this.innerHTML);
}
问题
当希望为同一个元素/标签绑定多个同类型事件的时候(如,为上面的这个p标签绑定3个点击事件),是不被允许的。
DOM2级事件
el.addEventListener(event-name, callback, useCapture)
event-name
: 事件名称,可以是标准的DOM事件callbakc
: 回调函数,当事件触发时,函数会被注入一个参数为当前的事件对象 event
useCapture
: 是否以捕获的方式触发,默认为true
var btn = document.getElementById('btn'); |
IE9以下的IE浏览器不支持 addEventListener()和removeEventListener()
,使用 attachEvent()与detachEvent()
代替,因为IE9以下是不支持事件捕获的,所以也没有第三个参数,第一个事件名称前要加on
。
DOM3级事件
在DOM2级事件的基础上添加了更多的事件类型
- UI事件,当用户与页面上的元素交互时触发,如:load、scroll
- 焦点事件,当元素获得或失去焦点时触发,如:blur、focus
- 鼠标事件,当用户通过鼠标在页面执行操作时触发如:dbclick、mouseup
- 滚轮事件,当使用鼠标滚轮或类似设备时触发,如:mousewheel
- 文本事件,当在文档中输入文本时触发,如:textInput
- 键盘事件,当用户通过键盘在页面上执行操作时触发,如:keydown、keypress
- 合成事件,当为IME(输入法编辑器)输入字符时触发,如:compositionstart
- 变动事件,当底层DOM结构发生变化时触发,如:DOMsubtreeModified
同时DOM3级事件也允许使用者自定义一些事件。
关于自定义事件,低版本的处理方式是不一样的,本篇主要介绍DOM事件流和自定义事件模型,这里就不展开讨论了,具体可参考漫谈js自定义事件、DOM/伪DOM自定义事件
DOM事件流
为什么是有事件流?
假如在一个button上注册了一个click事件,又在其它父元素div上注册了一个click事件,那么当我们点击button,是先触发父元素上的事件,还是button上的事件呢,这就需要一种约定去规范事件的执行顺序,就是事件执行的流程。
浏览器在发展的过程中出现实了两种不同的规范
- IE9以下的IE浏览器使用的是事件冒泡,先从具体的接收元素,然后逐步向上传播到不具体的元素。
- Netscapte采用的是事件捕获,先由不具体的元素接收事件,最具体的节点最后才接收到事件。
- 而W3C制定的Web标准中,是同时采用了两种方案,事件捕获和事件冒泡都可以。
事件阶段
既然有了事件捕获和事件冒泡,那就应该约定是事件的流向,是先捕获还是先冒泡,所以W3C标准中规定了事件流的三个阶段的顺序:
- 事件捕获阶段
- 处于目标阶段
- 事件冒泡阶段
有了捕获和冒泡这两种模式,我们就可以很好的控制父元素和子元素的事件执行顺序了
<div class="container" id="wrap"> |
var useCapture = true; //是否使用捕获模式 |
当我想事件的执行顺序是先父元素再子元素时,就使用事件捕获模式
点击box3时
如果是想先子元素上的事件先执行再执行父元素的上的事件时,使用冒泡模式
点击box3时
这里我们是同时让这三个元素上的事件都执行了,那么我们要想实现点击某个元素,只触发这个元素本身的事件,而不影响父元素或子元素呢?
所以这里需要一种方法,不让事件向下捕获或向上冒泡
所以有了 e.stopPropagation()
方法,用于阻止事件的继续传递。
执行这条语句,无论我们是使用捕获模式还是冒泡模式,事件都不会继续传递,只会响应我们点击的元素。
点击box3时
事件代理
那么利用事件冒泡或捕获的机制,我们可以对事件绑定做一些优化。
在JS中,如果我们注册的事件越来越多,页面的性能就越来越差,因为:
- 函数是对象,会占用内存,内存中的对象越多,浏览器性能越差
- 注册的事件一般都会指定DOM元素,事件越多,导致DOM元素访问次数越多,会延迟页面交互就绪时间。
- 删除子元素的时候不用考虑删除绑定事件
为了减少事件的注册,我们使用一个代理元素是代理它内部所有子元素事件
还是上面的例子,执行下面的代码document.getElementById('wrap').addEventListener('click', function(e){
if(e.target.classList.contains('box')){
logerBox.innerHTML += '<p>'+type+': '+e.target.id+'</p>';
}
}, useCapture)
答案揭晓
上面的例子中,无论useCapture
是true或是false,都能正确触发事件,那是不是说事件代理和事件冒泡没有关系呢?
SF上关于这个问题的争论 事件委托和冒泡机制有关系吗?
先来看两个问题
在同一个元素上同时绑定事件冒泡事件和事件捕获事件,谁先触发?
在同一个元素上同时绑定事件冒泡事件,第一个阻止事件传播,第二个会触发吗?
document.getElementById('box3').addEventListener('click', function(e){ |
其实要回答这两个问题,首先要弄清楚捕获和冒泡的适用性,捕获和冒泡发生在嵌套元素绑定了相同的事件类型,才需要捕获和冒泡的机制去控制,单个元素上绑定的事件,在触发的时候,其实就是事件三个阶段中的目标阶段,此时,这个元素本身的事件的触发顺序,只和绑定顺序有关,和是否捕获,是否冒泡,是否阻止了事件传播都没有关系。
再来看看 e.target
与 e.currentTarget
e.target
事件属性可返回事件的目标节点(触发该事件的节点),如生成事件的元素、文档或窗口。e.currentTarget
返回其监听器触发事件的节点,即当前处理该事件的元素、文档或窗口。在捕获和冒泡阶段,该属性是非常有用的,因为在这两个节点,它不同于 target 属性。
在事件代理中,e.target
是当前点击的目标元素,而 e.currentTarget
始终是事件的代理元素
回到上面的问题:无论useCapture
是 true
或是 false
,都能正确触发事件,那是不是说事件代理和事件冒泡没有关系呢?
嵌套关系 box1 > box2 > box3
- 代理事件使用事件冒泡,先给box2注册一个click事件,并阻止冒泡,点击box3和box2,这里就不能触发代理事件了,说明子元素是利用了事件冒泡向上查找到事件,并将本身的
e.target
传递给了事件函数,box2阻止了冒泡,所以在事件向上流动中断了,就无法触发事件代理函数了。 - 代理事件使用事件捕获,先给box2注册一个click事件,并阻止捕获,点击box3和box2,这里仍然可以正常触发代理事件,原因其实是父元素上的事件是捕获还是冒泡,其实子元素是不关心的,子元素事件流动一直都是冒泡行为。父元素的捕获或冒泡只会影响父元素的父级元素。
了解了DOM事件模型,我们应该看看事件模型的内部实现是什么样的。
自定义事件模型
事件模型的实现从设计模式的角度来看,是一种观察者模式或者也叫发布订阅模式,订阅者订阅一个消息,发布者发布这个消息,订阅者收到消息,这是一种数据流动的方式,使用这个模式的好处是,可以有多个订阅者,一个发布者,发布一条消息,可被多个订阅者收到。
最简短的发布订阅事件模型
var o = $({}); |
这里只是将jQuery的事件方法重命名了而已,要了解真正的事件模型内部对事件处理,还是需要我们从源码层面去探索了解的
下面我们用ES6的语法,实现一个简单版的事件模型
;(function(global){ |
这个事件模型很简单
on()
用于绑定事件,参数:事件名称,事件处理函数emit()
用于触发事件,参数:事件名称,传递给事件处理函数的参数off
用于解除绑定的指定事件, 参数:事件名称,要解绑的事件函数one
用于绑定一次性事件,只能触发一次,参数:事件名称,事件处理函数
使用Events.on('cus', (a, b)=>console.log(a+b))
Events.emit('cus', 1, 2);//3
Events.off('cus');
Events.emit('cus', 1, 2)
// 只触发一次
Events.one('once', a=>console.log(a))
Events.emit('once', 1);//1
Events.emit('once', 2);
当然 一个能用于生产环境的pubsub模块还需要考虑更多的情况
- 不同平台下的引用
- 命名空间和继承
- 回调的上下文控制
Github上有一个比较好的pubsub事件模块,有时间再来将这个模块做一个源码分析pubsub.js