Aitter's Blog

DOM事件机制解惑

面试的时候经常会被问到,事件代理是利用了事件的什么机制?一般我们都会说,是利用了事件冒泡机制,但具体冒泡机制在事件代理的过程了起到了什么关键作用,我想大部分人也是不了解的。静下心来,仔细看了下有关事件和事件代理问题,自己终于找到了合适的答案。

在揭晓答案之前,还是来看一下事件的基本知识

事件操作在项目中使用频繁,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>

问题

  1. HTML代码域JavaScript代码紧密的耦合在一起,没有实现相互分离,在进行代码的更新与维护的时候就显得异常困难。
  2. 扩展事件处理程序的作用域链在不同浏览器当中会导致不同的结果。
  3. 如果不采用调用函数的方式,而是像例子中那样直接书写代码,那么代码的通用性很差,会使得整站的代码量很大,通用性差。如果提取出来,存放在函数当中,那么,会面临另一个问题——当函数还没有被定义,只是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');
btn.addEventListener("click", test, false);
function test(e){
e = e || window.event;
alert((e.target || e.srcElement).innerHTML);
btn.removeEventListener("click", test)
}
//IE9-:attachEvent()与detachEvent()。
//IE9+/chrom/FF:addEventListener()和removeEventListener()

IE9以下的IE浏览器不支持 addEventListener()和removeEventListener(),使用 attachEvent()与detachEvent() 代替,因为IE9以下是不支持事件捕获的,所以也没有第三个参数,第一个事件名称前要加on

DOM3级事件

在DOM2级事件的基础上添加了更多的事件类型

  1. UI事件,当用户与页面上的元素交互时触发,如:load、scroll
  2. 焦点事件,当元素获得或失去焦点时触发,如:blur、focus
  3. 鼠标事件,当用户通过鼠标在页面执行操作时触发如:dbclick、mouseup
  4. 滚轮事件,当使用鼠标滚轮或类似设备时触发,如:mousewheel
  5. 文本事件,当在文档中输入文本时触发,如:textInput
  6. 键盘事件,当用户通过键盘在页面上执行操作时触发,如:keydown、keypress
  7. 合成事件,当为IME(输入法编辑器)输入字符时触发,如:compositionstart
  8. 变动事件,当底层DOM结构发生变化时触发,如:DOMsubtreeModified
    同时DOM3级事件也允许使用者自定义一些事件。
    关于自定义事件,低版本的处理方式是不一样的,本篇主要介绍DOM事件流和自定义事件模型,这里就不展开讨论了,具体可参考漫谈js自定义事件、DOM/伪DOM自定义事件

DOM事件流

为什么是有事件流?

假如在一个button上注册了一个click事件,又在其它父元素div上注册了一个click事件,那么当我们点击button,是先触发父元素上的事件,还是button上的事件呢,这就需要一种约定去规范事件的执行顺序,就是事件执行的流程。

浏览器在发展的过程中出现实了两种不同的规范

  • IE9以下的IE浏览器使用的是事件冒泡,先从具体的接收元素,然后逐步向上传播到不具体的元素。
  • Netscapte采用的是事件捕获,先由不具体的元素接收事件,最具体的节点最后才接收到事件。
  • 而W3C制定的Web标准中,是同时采用了两种方案,事件捕获和事件冒泡都可以。

事件阶段

既然有了事件捕获和事件冒泡,那就应该约定是事件的流向,是先捕获还是先冒泡,所以W3C标准中规定了事件流的三个阶段的顺序:

  1. 事件捕获阶段
  2. 处于目标阶段
  3. 事件冒泡阶段


有了捕获和冒泡这两种模式,我们就可以很好的控制父元素和子元素的事件执行顺序了

<div class="container" id="wrap">
<div class="box box1" id="box1">box1
<div class="box box2" id="box2">box2
<div class="box box3" id="box3">box3</div>
</div>
</div>
<div class="loger" id="loger"></div>
</div>
var useCapture = true; //是否使用捕获模式
var type = useCapture ? '捕获':'冒泡';
var logerBox = document.getElementById('loger');
// 捕获 与 冒泡
Array.from(document.querySelectorAll('.box')).forEach(v => {
var id = v.id;
v.addEventListener('click',function(e){
logerBox.innerHTML += '<p>'+type+': '+id+'</p>';
//e.stopPropagation()
}, useCapture);
})

当我想事件的执行顺序是先父元素再子元素时,就使用事件捕获模式
点击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){
console.log('box3-stop:',e.target.id)
e.stopPropagation();
})
document.getElementById('box3').addEventListener('click', function(e){
console.log('box3-冒泡:', e.target.id)
})
document.getElementById('box3').addEventListener('click', function(e){
console.log('box3-捕获:', e.target.id)
},true)
document.getElementById('box3').addEventListener('click', function(e){
console.log('box3-冒泡:', e.target.id)
})
document.getElementById('box3').addEventListener('click', function(e){
console.log('box3-stop:',e.target.id)
e.stopPropagation();
})
/*点击输出:
box3-stop: box3
box3-冒泡: box3
box3-捕获: box3
box3-冒泡: box3
box3-stop: box3*/

其实要回答这两个问题,首先要弄清楚捕获和冒泡的适用性,捕获和冒泡发生在嵌套元素绑定了相同的事件类型,才需要捕获和冒泡的机制去控制,单个元素上绑定的事件,在触发的时候,其实就是事件三个阶段中的目标阶段,此时,这个元素本身的事件的触发顺序,只和绑定顺序有关,和是否捕获,是否冒泡,是否阻止了事件传播都没有关系。

再来看看 e.targete.currentTarget
e.target 事件属性可返回事件的目标节点(触发该事件的节点),如生成事件的元素、文档或窗口。
e.currentTarget 返回其监听器触发事件的节点,即当前处理该事件的元素、文档或窗口。在捕获和冒泡阶段,该属性是非常有用的,因为在这两个节点,它不同于 target 属性。

在事件代理中,e.target 是当前点击的目标元素,而 e.currentTarget 始终是事件的代理元素

回到上面的问题:无论useCapturetrue 或是 false ,都能正确触发事件,那是不是说事件代理和事件冒泡没有关系呢?

嵌套关系 box1 > box2 > box3

  1. 代理事件使用事件冒泡,先给box2注册一个click事件,并阻止冒泡,点击box3和box2,这里就不能触发代理事件了,说明子元素是利用了事件冒泡向上查找到事件,并将本身的 e.target 传递给了事件函数,box2阻止了冒泡,所以在事件向上流动中断了,就无法触发事件代理函数了。
  2. 代理事件使用事件捕获,先给box2注册一个click事件,并阻止捕获,点击box3和box2,这里仍然可以正常触发代理事件,原因其实是父元素上的事件是捕获还是冒泡,其实子元素是不关心的,子元素事件流动一直都是冒泡行为。父元素的捕获或冒泡只会影响父元素的父级元素。

了解了DOM事件模型,我们应该看看事件模型的内部实现是什么样的。

自定义事件模型

事件模型的实现从设计模式的角度来看,是一种观察者模式或者也叫发布订阅模式,订阅者订阅一个消息,发布者发布这个消息,订阅者收到消息,这是一种数据流动的方式,使用这个模式的好处是,可以有多个订阅者,一个发布者,发布一条消息,可被多个订阅者收到。

最简短的发布订阅事件模型

var o = $({});
$.subscribe = (...args) => o.on.apply( o, args)
$.publish = (...args) => o.trigger.apply( o, args )
$.unsubscribe = (...args) => o.off.apply( o, args )
$.subscribe('cus', function(e, a, b){ console.log(a+b+1) })
$.subscribe('cus', function(e, a, b){ console.log(a+b+2) })
$.subscribe('cus', function(e, a, b){ console.log(a+b+3) })
$.publish('cus', [1, 2])
// 4, 5, 6
$.unsubscribe('cus')
$.publish('cus', [1, 2])

这里只是将jQuery的事件方法重命名了而已,要了解真正的事件模型内部对事件处理,还是需要我们从源码层面去探索了解的

下面我们用ES6的语法,实现一个简单版的事件模型

;(function(global){
class Events {
constructor(){
this.cache = {};
this.onceKeys = [];
}
on(key, fn){
if(!this.cache[key]) this.cache[key] = [];
this.cache[key].push(fn);
}
one(key, fn){
this.cache[key]=[];
this.on(key, fn);
this.onceKeys.push(key);
}
off(key, fn){
if(this.cache[key]) this.cache[key] = fn ? this.cache[key].filter(v=>v !== fn) : [];
}
emit(key, ...args){
if(this.cache[key]){
this.cache[key].forEach(v=>v.apply(null, args))
if(this.onceKeys.includes(key)){
this.cache[key] = [];
this.onceKeys = this.onceKeys.filter(v=>v!==key);
}
}
}
}
global.Events = new Events();
})(this)

这个事件模型很简单

  • 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模块还需要考虑更多的情况

  1. 不同平台下的引用
  2. 命名空间和继承
  3. 回调的上下文控制

Github上有一个比较好的pubsub事件模块,有时间再来将这个模块做一个源码分析pubsub.js