深入理解DOM事件机制

7,425 阅读14分钟

前言

本文主要介绍:

  1. DOM事件级别
  2. DOM事件流
  3. DOM事件模型
  4. 事件代理
  5. Event对象常见的方法和属性

一、DOM事件级别

针对不同级别的DOM,我们的DOM事件处理方式也是不一样的。

DOM级别一共可以分为4个级别:DOM0级「通常把DOM1规范形成之前的叫做DOM0级」,DOM1级,DOM2级和 DOM3级,而DOM事件分为3个级别:DOM0级事件处理,DOM2级事件处理和DOM3级事件处理。如下图所示:

DOM级别与DOM事件.jpg

1.DOM 0级事件

在了解DOM0级事件之前,我们有必要先了解下HTML事件处理程序,也是最早的这一种的事件处理方式,代码如下:

<button type="button" onclick="fn" id="btn">点我试试</button>

<script>
    function fn() {
        alert('Hello World');
    }
</script>

那有一个问题来了,那就是fn要不要加括号呢?

在html的onclick属性中,使用时要加括号,在js的onclick中,给点击事件赋值,不加括号。为什么呢?我们通过事实来说话:

// fn不加括号
<button type="button" onclick="fn" id="btn">点我试试</button>

<script>
    function fn() {
        alert('Hello World');
    }
    console.log(document.getElementById('btn').onclick);
    // 打印的结果如下:这个函数里面包括着fn,点击之后并没有弹出1
    /*
    ƒ onclick(event) {
    	fn
    }
    */
</script>

// fn 加括号,这里就不重复写上面代码,只需要修改一下上面即可
<button type="button" onclick="fn()"  id="btn">点我试试</button>
<script>
// 打印的结果如下:点击之后可以弹出1
/*
ƒ onclick(event) {
	fn()
}
*/
</script>

上面的代码我们通过直接在HTML代码当中定义了一个onclick的属性触发fn方法,这样的事件处理程序最大的缺点就是HTML与JS强耦合,当我们一旦需要修改函数名就得修改两个地方。当然其优点就是不需要操作DOM来完成事件的绑定

DOM0事件绑定,给元素的事件行为绑定方法,这些方法都是在当前元素事件行为的冒泡阶段(或者目标阶段)执行的

那我们如何实现HTML与JS低耦合?这样就有DOM0级处理事件的出现解决这个问题。DOM0级事件就是将一个函数赋值给一个事件处理属性,比如:

<button id="btn" type="button"></button>

<script>
    var btn = document.getElementById('btn');
    
    btn.onclick = function() {
        alert('Hello World');
    }
    
    // btn.onclick = null; 解绑事件 
</script>

上面的代码我们给button定义了一个id,然后通过JS获取到了这个id的按钮,并将一个函数赋值给了一个事件处理属性onclick,这样的方法便是DOM0级处理事件的体现。我们可以通过给事件处理属性赋值null来解绑事件。DOM 0级的事件处理的步骤:先找到DOM节点,然后把处理函数赋值给该节点对象的事件属性。

DOM0级事件处理程序的缺点在于一个处理程序「事件」无法同时绑定多个处理函数,比如我还想在按钮点击事件上加上另外一个函数。

var btn = document.getElementById('btn');
    
btn.onclick = function() {
    alert('Hello World');
}
btn.onclick = function() {
    alert('没想到吧,我执行了,哈哈哈');
}

2.DOM2级事件

DOM2级事件在DOM0级事件的基础上弥补了一个处理程序无法同时绑定多个处理函数的缺点,允许给一个处理程序添加多个处理函数。也就是说,使用DOM2事件可以随意添加多个处理函数,移除DOM2事件要用removeEventListener。代码如下:

<button type="button" id="btn">点我试试</button>

<script>
    var btn = document.getElementById('btn');

    function fn() {
        alert('Hello World');
    }
    btn.addEventListener('click', fn, false);
    // 解绑事件,代码如下
    // btn.removeEventListener('click', fn, false);  
</script>

DOM2级事件定义了addEventListener和removeEventListener两个方法,分别用来绑定和解绑事件

target.addEventListener(type, listener[, useCapture]);
target.removeEventListener(type, listener[, useCapture]);
/*
	方法中包含3个参数,分别是绑定的事件处理属性名称(不包含on)、事件处理函数、是否在捕获时执行事件处理函数(关于事件冒泡和事件捕获下面会介绍)
*/

注:

IE8级以下版本不支持addEventListener和removeEventListener,需要用attachEvent和detachEvent来实现:

// IE8级以下版本只支持冒泡型事件,不支持事件捕获所以没有第三个参数
// 方法中包含2个参数,分别是绑定的事件处理属性名称(不包含on)、事件处理函数
btn.attachEvent('onclick', fn); // 绑定事件 
btn.detachEvent('onclick', fn); // 解绑事件 

3.DOM3级事件

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事件级别的发展使得事件处理更加完整丰富,而下一个问题就是之前提到的DOM事件模型。「事件冒泡和事件捕获」

二、DOM事件流

为什么是有事件流?

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

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

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

三、DOM事件模型

DOM事件模型分为捕获和冒泡。一个事件发生后,会在子元素和父元素之间传播(propagation)。这种传播分成三个阶段。

(1)捕获阶段:事件从window对象自上而下向目标节点传播的阶段;

(2)目标阶段:真正的目标节点正在处理事件的阶段;

(3)冒泡阶段:事件从目标节点自下而上向window对象传播的阶段。

上文中讲到了addEventListener的第三个参数为指定事件是否在捕获或冒泡阶段执行,设置为true表示事件在捕获阶段执行,而设置为false表示事件在冒泡阶段执行。那么什么是事件冒泡和事件捕获呢?可以用下图来解释:

1.事件捕获

捕获是从上到下,事件先从window对象,然后再到document(对象),然后是html标签(通过document.documentElement获取html标签),然后是body标签(通过document.body获取body标签),然后按照普通的html结构一层一层往下传,最后到达目标元素。我们只需要将addEventListener的第三个参数改为true就可以实现事件捕获。代码如下:

<!-- CSS 代码 -->
<style>
    body{margin: 0;}
    div{border: 1px solid #000;}
    #grandfather1{width: 200px;height: 200px;}
    #parent1{width: 100px;height: 100px;margin: 0 auto;}
    #child1{width: 50px;height: 50px;margin: 0 auto;}
</style>

<!-- HTML 代码 -->
<div id="grandfather1">
    爷爷
    <div id="parent1">
        父亲
        <div id="child1">儿子</div>
    </div>
</div>

<!-- JS 代码 -->
<script>
    var grandfather1 = document.getElementById('grandfather1'),
        parent1 = document.getElementById('parent1'),
        child1 = document.getElementById('child1');
    
    grandfather1.addEventListener('click',function fn1(){
        console.log('爷爷');
    },true)
    parent1.addEventListener('click',function fn1(){
        console.log('爸爸');
    },true)
    child1.addEventListener('click',function fn1(){
        console.log('儿子');
    },true)

    /*
        当我点击儿子的时候,我是否点击了父亲和爷爷
        当我点击儿子的时候,三个函数是否调用
    */
    // 请问fn1 fn2 fn3 的执行顺序?
    // fn1 fn2 fn3 or fn3 fn2 fn1  
</script>

先来看结果吧:

当我们点击id为child1的div标签时,打印的结果是爷爷 => 爸爸 => 儿子,结果正好与事件冒泡相反。

2.事件冒泡

所谓事件冒泡就是事件像泡泡一样从最开始生成的地方一层一层往上冒。我们只需要将addEventListener的第三个参数改为false就可以实现事件冒泡。代码如下:

//html、css代码同上,js代码只是修改一下而已
var grandfather1 = document.getElementById('grandfather1'),
    parent1 = document.getElementById('parent1'),
    child1 = document.getElementById('child1');

grandfather1.addEventListener('click',function fn1(){
    console.log('爷爷');
},false)
parent1.addEventListener('click',function fn1(){
    console.log('爸爸');
},false)
child1.addEventListener('click',function fn1(){
    console.log('儿子');
},false)

/*
   当我点击儿子的时候,我是否点击了父亲和爷爷
   当我点击儿子的时候,三个函数是否调用
*/
// 请问fn1 fn2 fn3 的执行顺序?
// fn1 fn2 fn3 or fn3 fn2 fn1  

先来看结果吧:

比如上图中id为child1的div标签为事件目标,点击之后后同时也会触发父级上的点击事件,一层一层向上直至最外层的html或document。

注:当第三个参数为false或者为空的时候,代表在冒泡阶段绑定。

四、事件代理(事件委托)

1.事件代理含义和为什么要优化?

由于事件会在冒泡阶段向上传播到父节点,因此可以把子节点的监听函数定义在父节点上,由父节点的监听函数统一处理多个子元素的事件。这种方法叫做事件的代理(delegation)。

举个例子,比如一个宿舍的同学同时快递到了,一种方法就是他们都傻傻地一个个去领取,还有一种方法就是把这件事情委托给宿舍长,让一个人出去拿好所有快递,然后再根据收件人一一分发给每个宿舍同学;

在这里,取快递就是一个事件,每个同学指的是需要响应事件的 DOM 元素,而出去统一领取快递的宿舍长就是代理的元素,所以真正绑定事件的是这个元素,按照收件人分发快递的过程就是在事件执行中,需要判断当前响应的事件应该匹配到被代理元素中的哪一个或者哪几个。

那么利用事件冒泡或捕获的机制,我们可以对事件绑定做一些优化。 在JS中,如果我们注册的事件越来越多,页面的性能就越来越差,因为:

  • 函数是对象,会占用内存,内存中的对象越多,浏览器性能越差
  • 注册的事件一般都会指定DOM元素,事件越多,导致DOM元素访问次数越多,会延迟页面交互就绪时间。
  • 删除子元素的时候不用考虑删除绑定事件

2.优点

  • 减少内存消耗,提高性能

假设有一个列表,列表之中有大量的列表项,我们需要在点击每个列表项的时候响应一个事件

// 例4
<ul id="list">
  <li>item 1</li>
  <li>item 2</li>
  <li>item 3</li>
  ......
  <li>item n</li>
</ul>

如果给每个列表项一一都绑定一个函数,那对于内存消耗是非常大的,效率上需要消耗很多性能。借助事件代理,我们只需要给父容器ul绑定方法即可,这样不管点击的是哪一个后代元素,都会根据冒泡传播的传递机制,把容器的click行为触发,然后把对应的方法执行,根据事件源,我们可以知道点击的是谁,从而完成不同的事。

  • 动态绑定事件

在很多时候,我们需要通过用户操作动态的增删列表项元素,如果一开始给每个子元素绑定事件,那么在列表发生变化时,就需要重新给新增的元素绑定事件,给即将删去的元素解绑事件,如果用事件代理就会省去很多这样麻烦。

2.如何实现

接下来我们来实现上例中父层元素 #list 下的 li 元素的事件委托到它的父层元素上:


<ul id="list">
    <li>1</li>
    <li>2</li>
    <li>3</li>
    <li>4</li>
</ul>

<script>
// 给父层元素绑定事件
document.getElementById('list').addEventListener('click', function (e) {
    // 兼容性处理
    var event = e || window.event;
    var target = event.target || event.srcElement;
    // 判断是否匹配目标元素
    if (target.nodeName.toLocaleLowerCase() === 'li') {
        console.log('the content is: ', target.innerHTML);
    }
});
</script>

这是常规的实现事件委托的方法,但是这种方法有BUG,当监听的元素里存在子元素时,那么我们点击这个子元素事件会失效,所以我们可以联系文章上一小节说到的冒泡事件传播机制来解决这个bug。改进的事件委托代码:

<ul id="list">
    <li>1 <span>aaaaa</span></li>
    <li>2 <span>aaaaa</span></li>
    <li>3 <span>aaaaa</span></li>
    <li>4</li>
</ul>

<script>


// 给父层元素绑定事件
document.getElementById('list').addEventListener('click', function (e) {
    // 兼容性处理
    var event = e || window.event;
    var target = event.target || event.srcElement;
    // 判断是否匹配目标元素
    /* 从target(点击)元素向上找currentTarget(监听)元素,
    找到了想委托的元素就触发事件,没找到就返回null */
    while(target.tagName !== 'LI'){
    
        if(target.tagName === 'UL'){
            target = null
            break;
        }
        target = target.parentNode
    }
    if (target) {
    console.log('你点击了ul里的li')
    }
});

五、Event对象常见的方法和属性

1.event. preventDefault()

如果调用这个方法,默认事件行为将不再触发。什么是默认事件呢?例如表单一点击提交按钮(submit)刷新页面、a标签默认页面跳转或是锚点定位等。

使用场景1:使用a标签仅仅是想当做一个普通的按钮,点击实现一个功能,不想页面跳转,也不想锚点定位。

方法一

<a href="javascript:;">链接</a>

方法二

使用JS方法来阻止,给其click事件绑定方法,当我们点击A标签的时候,先触发click事件,其次才会执行自己的默认行为

<a id="test" href="http://www.google.com">链接</a>
<script>
    test.onclick = function(e){
        e = e || window.event;
        return false;
    }
</script>

方法三

<a id="test" href="http://www.google.com">链接</a>
<script>
    test.onclick = function(e){
        e = e || window.event;
        e.preventDefault();
    }
</script>

使用场景2:输入框最多只能输入六个字符,如何实现?

实现代码如下:

<input type="text" id='tempInp'>
<script>
    tempInp.onkeydown = function(ev) {
        ev = ev || window.event;
        let val = this.value.trim() //trim去除字符串首位空格(不兼容)
        // this.value=this.value.replace(/^ +| +$/g,'') 兼容写法
        let len = val.length
        if (len >= 6) {
            this.value = val.substr(0, 6);
            //阻止默认行为去除特殊按键(DELETE\BACK-SPACE\方向键...)
            let code = ev.which || ev.keyCode;
            if (!/^(46|8|37|38|39|40)$/.test(code)) {
                ev.preventDefault()
            }
        }
    }
</script>

2.event.stopPropagation() & event.stopImmediatePropagation()

event.stopPropagation() 方法阻止事件冒泡到父元素,阻止任何父事件处理程序被执行。demo代码如下:

// 在事件冒泡demo代码的基础上修改一下
child1.addEventListener('click',function fn1(e){
    console.log('儿子');
    e.stopPropagation()
},false)

stopImmediatePropagation 既能阻止事件向父元素冒泡,也能阻止元素同事件类型的其它监听器被触发。而 stopPropagation 只能实现前者的效果。我们来看个例子:

<button id="btn">点我试试</button>
<script>
const btn = document.querySelector('#btn');
btn.addEventListener('click', event => {
  console.log('btn click 1');
  event.stopImmediatePropagation();
});
btn.addEventListener('click', event => {
  console.log('btn click 2');
});
document.body.addEventListener('click', () => {
  console.log('body click');
});
</script>

根据打印出来的结果,我们发现使用 stopImmediatePropagation后,点击按钮时,不仅body绑定事件不会触发,与此同时按钮的另一个点击事件也不触发。

3.event.target & event.currentTarget

从上面这张图片中我们可以看到,event.target指向引起触发事件的元素,而event.currentTarget则是事件绑定的元素

总结

因此不必记什么时候e.currentTargete.target相等,什么时候不等,理解两者的究竟指向的是谁即可。

  • e.target 指向触发事件监听的对象「事件的真正发出者」。
  • e.currentTarget 指向添加监听事件的对象「监听事件者」。

六、参考文章