DOM事件与事件委托

1,841 阅读10分钟

问题

<div class=爷爷>
  <div class=爸爸>
   <div class=儿子>
   文字
   </div>
  </div>
</div>
  • 结构为爷爷->爸爸->儿子
  • 分别给三个div添加事件监听为: fnGranderFather,fnFather,fnSon

问题1: 如果点击文字,请问点击了谁?

  • 点击文字,点击了儿子?
  • 点击文字,点击了爸爸?
  • 点击文字,点击了爷爷?

答案: 三个都被点击了

问题2:如果点击文字,请问事件的调用顺序为?

最先调用fnGranderFather,fnFather,fnSon其中的哪一个?

答案: IE5认为先调用fnSon, 网景Netscap认为先调用fnGranderFather

事件捕获和冒泡

  1. W3C2002年发布标准,文档名为DOM level2 Events Specification, 规定浏览器应该同时支持两种调用顺序。
  2. 首先,按爷爷->爸爸->儿子的顺序看有没有函数监听
  3. 然后,按儿子->爸爸->爷爷的顺序看没有函数监听
  4. 如果有函数就调用,并提供事件信息,没有就跳过

事件捕获:从外到内找监听函数,称为事件捕获。简单来说,就是子元素绑定的事件,会逐级冒泡到父元素,当它的一个或多个父元素上绑定有相同事件时,相应的事件处理函数会被触发。

事件冒泡:从内到外找监听函数,称为事件冒泡。事件捕获。就是说,在子元素事件已经发生,注册的事件处理函数被调用之前,父元素能够捕获到这个事件。

问题:那是不是fnGranderFather,fnFather,fnSon被总共调用了两次?

回答:不是。开发者可以自己决定把fnGranderFather放在捕获阶段还是冒泡阶段

事件绑定API

  • IE 5: div.attachEvent('onclick',fn)为事件冒泡
  • 网景: div.addEventListener('click', fn) 为事件捕获
  • W3C: div.addEventListener('click', fn, bool) 其中,如果bool为默认值,不传或者为falsy。则让fn走冒泡,意思是说当浏览器在冒泡阶段发现divfn监听函数,就会调用fn,并且提供事件信息
  • falsy值有: 0, "" " `` , null, undefined, NaN
  • 如果bool值为true,就让fn走捕获,意思是说,当浏览器在捕获阶段发现divfn监听函数,就会调用fn, 提供事件信息
  • 注意的是:捕获和冒泡都会被执行,区别在于fn在哪个过程会被执行
  • 注意的是:IE的知识不需要特意去学,因为已经过时了,等需要用到的时候,再去搜索学习

实例代码:

// HTML代码
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>test</title>
</head>
<body>
<div class="level1 x">
  <div class="level2 x">
    <div class="level3 x">
      <div class="level4 x">
        <div class="level5 x">
          <div class="level6 x">
            <div class="level7 x">
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>
</body>
</html>
***********************
// CSS代码
* {
  box-sizing: border-box;
}
div[class^=level] {
  border: 1px solid;
  border-radius: 50%;
  display: inline-flex;
}
.level1 {
  padding: 10px;
  background: purple;
}
.level2 {
  padding: 10px;
  background: blue;
}
.level3 {
  padding: 10px;
  background: cyan;
}
.level4 {
  padding: 10px;
  background: green;
}
.level5 {
  padding: 10px;
  background: yellow;
}
.level6 {
  padding: 10px;
  background: orange;
}
.level7 {
  width: 50px;
  height: 50px;
  border: 1px solid;
  background: red;
  border-radius: 50%;
}
.x{
  background: transparent;
}
******************************
// JS代码
const level1 = document.querySelector('.level1')
const level2 = document.querySelector('.level2')
const level3 = document.querySelector('.level3')
const level4 = document.querySelector('.level4')
const level5 = document.querySelector('.level5')
const level6 = document.querySelector('.level6')
const level7 = document.querySelector('.level7')

let n = 1

level1.addEventListener('click', (e)=>{
  const t = e.currentTarget
  setTimeout(()=>{  
    t.classList.remove('x')
  },n*1000)
  n+=1
})
level2.addEventListener('click', (e)=>{
  const t = e.currentTarget
  setTimeout(()=>{  
    t.classList.remove('x')
  },n*1000)
  n+=1
})
level3.addEventListener('click', (e)=>{
  const t = e.currentTarget
  setTimeout(()=>{  
    t.classList.remove('x')
  },n*1000)
  n+=1
})
level4.addEventListener('click', (e)=>{
  const t = e.currentTarget
  setTimeout(()=>{  
    t.classList.remove('x')
  },n*1000)
  n+=1
})
level5.addEventListener('click', (e)=>{
  const t = e.currentTarget
  setTimeout(()=>{  
    t.classList.remove('x')
  },n*1000)
  n+=1
})
level6.addEventListener('click', (e)=>{
  const t = e.currentTarget
  setTimeout(()=>{  
    t.classList.remove('x')
  },n*1000)
  n+=1
})
level7.addEventListener('click', (e)=>{
  const t = e.currentTarget
  setTimeout(()=>{  
    t.classList.remove('x')
  },n*1000)
  n+=1
})

总结:

  1. 问题, 儿子被点击了,算不算点击了爸爸?先调用爸爸的函数还是先调用儿子的函数?答案1:算。答案2:在w3c的标准中先调用爸爸的函数,网景和IE浏览器分情况
  2. 捕获规定先调用爸爸的监听函数, 冒泡规定先调用儿子的监听函数
  3. W3C事件模型中,先捕获再冒泡,即先爸爸->儿子,再儿子-> 爸爸
  4. 注意的是e事件对象被传递给所有的监听函数,事件结束后,e事件对象会被浏览器进行修改,currentTargetnull, 没有访问价值

target和currentTarget

基础:

                       // 当用户点击文字的时候
<div>                  // e.currentTarget就是div
  <span>文字</span>    // e.target就是span
</div>
  • e.target: 用户操作的元素
  • e.currentTarget: 程序员监听的元素
  • thise.currentTarget, 是不推荐的用法,因为this指向不确定

特例: 在同级别div

div.addEventListener('click', f1)  // f1先被执行
div.addEventListener('click', f2, true) // 然后被f2执行

请问, f1还是f2先执行?如果把代码调换顺序,哪个先被执行?

答案:谁先事件监听,谁就先执行。但是这是一个特例。

特例存在的情况:

  • 在不考虑父子同时被监听的情况下,只有一个div被监听
  • fn会分别在捕获阶段和冒泡阶段监听click事件
  • 开发者监听的元素就是用户点击的元素

事件绑定

  1. 直接获取元素绑定

优点是:简单和稳定,可以确保它在你使用的不同浏览器中运作一致。

缺点:只会在事件冒泡中运行;一个元素一次只能绑定一个事件处理函数,新绑定的事件处理函数会覆盖旧的事件处理函数;事件对象参数(e)只有在非IE浏览器才可用

element.onclick = function(e){
        // ...
    };
  1. 直接在元素里面使用事件属性
<button οnclick="f1"></button>

3、添加事件监听

w3c方法

优点:该方法同时支持事件处理的捕获和冒泡阶段;事件阶段取决于addEventListener最后的参数设置:false (冒泡) 或 true (捕获);在事件处理函数内部。事件对象总是可以通过处理函数的第一个参数(e)捕获;可以为同一个元素绑定你所希望的多个事件,同时并不会覆盖先前绑定的事件

缺点:IE不支持,你必须使用IEattachEvent函数替代

element.addEventListener('click', function(e){
        // ...
    }, false);

IE方法

优点:可以为同一个元素绑定你所希望的多个事件,同时并不会覆盖先前绑定的事件。

缺点:IE仅支持事件捕获的冒泡阶段;如果使用了this,事件监听函数内的this关键字指向了window对象,而不是当前元素, 事件对象仅存在与window.event参数中;事件必须以ontype的形式命名,比如,onclick而非click;仅IE可用,你必须在非IE浏览器中使用W3CaddEventListener

注意:不是意味着版本的IE没有事件捕获,它也是先发生事件捕获,再发生事件冒泡,只不过这个过程无法通过程序控制。

element.attachEvent('onclick', function(){
        // ...
});

取消事件绑定

  • 使用removeEventListener
  • 使用detachEvent
// W3C
element.removeEventListener('click', function(e){
        // ...
    }, false);

// IE
element.detachEvent('onclick', function(){
        // ...
});

取消冒泡

  • 捕获不可以被取消,冒泡可以被取消

  • 在支持addEventListener()的浏览器中,可以调用事件对象的stopPropagation()方法以阻止事件的继续传播。如果在同一对象上定义了其他处理程序,剩下的处理程序将依旧被调用,但调用stopPropagation()之后任何其他对象上的事件处理程序将不会被调用。即可以阻止事件在冒泡阶段的传播

  • IE 9之前的IE不支持stopPropagation()方法,而是设置事件对象cancelBubble属性为true来实现阻止事件进一步传播

  • 有些事件不可以取消冒泡,例如MDN搜索scroll event, 看到BubblesCanceleble, 在Canceleble可以找到不可以取消冒泡的事件

// w3c
element.addEventListener("click", function(e){
    // 在捕获阶段阻止事件的传播
    e.stopPropagation();
}, true);

阻止滚动

  • scroll事件为不可取消的冒泡事件,阻止scroll默认动作没有用,因为先有滚动才有滚动事件。要阻止滚动,需要阻止wheeltouchstart的默认动作
  • CSS上使用overflow:hidden可以直接取消滚动条,然而JS上依然可以修改scrollTop
element.addEventListener('wheel',(e)=>{
    e.preventDefault() // 取消滚轮的默认动作
})

element.addEventListener('touchstart',(e)=>{
    e.preventDefault() // 取消手机上的触屏默认动作
})

取消默认行为

  • e.preventDefault()可以阻止事件的默认行为发生,默认行为是指:点击a标签就转跳到其他页面、拖拽一个图片到浏览器会自动打开、点击表单的提交按钮会提交表单等等,因为有的时候我们并不希望发生这些事情,所以需要阻止默认行为

  • IE 9之前的IE中,可以通过设置事件对象的returnValue属性为false达到同样的效果

function cancelHandler(event){
    var event=event||window.event;//兼容IE
    
    //取消事件相关的默认行为
    if(event.preventDefault)    //标准技术
        event.preventDefault();
    if(event.returnValue)    //兼容IE9之前的IE
        event.returnValue=false;
    return false;    //用于处理使用对象属性注册的处理程序
}

自定义事件

浏览器自带事件,一共有100多种事件,可以在MDN上查询。同时,开发者也可以在自带事件之外,自定义一个事件

// html代码
<body>
  <div id=div1>
    <button id=button1>点击触发事件     
    </button>
  </div>
**************
// js代码

// 先自定义事件,然后点击时触发自定义事件
button1.addEventListener('click', ()=>{
  const event = new CustomEvent("tab", {"detail":{name:'tab', age: 18}},
  bubbles: true) // 允许冒泡
  button1.dispatchEvent(event)
})

// 监听自定义事件
button1.addEventListener('tab', (e)=>{
  console.log('tab')
  console.log(e)
})

事件委托

定义

  • JavaScript中,添加到页面上的事件处理程序数量将直接关系到页面的整体运行性能。导致这一问题的原因是多方面的。首先,每个函数都是对象,都会占用内存;内存中的对象越多,性能就越差。其次,必须事先指定所有事件处理程序而导致的DOM访问次数,会延迟整个页面的交互就绪时间。

  • 对事件处理程序过多问题的解决方案就是事件委托。事件委托利用了事件冒泡,只指定一个事件处理程序,就可以管理某一类型的所有事件。例如,click事件会一直冒泡到document层次。也就是说,我们可以为整个页面指定一个onclick事件处理程序,而不必给每个可单击的元素分别添加事件处理程序

  • 优点:提高页面性能,可以监听动态元素

事件委托使用场景

  1. 实例一:假设给100个按钮添加点击事件
// html代码
<div id="div1">
 <button data-id="1">1</button>
 <button data-id="2">2</button>
 <button data-id="3">3</button>
 <button data-id="4">4</button>
 ****************
</div>

// js代码
div1.addEventListener('click', (e)=>{
    const t = e.target
    if(t.tagName.toLowerCase() === 'button'){
        console.log('button'被点击了)
        console.log('button'内容是 + t.textContext) // 获取被点击元素的文本内容
        console.log('button 的data-id是:'+ t.dataset.id) // 获取被点击元素的dataset.id
    }
})
  1. 实例二:监听目前不存在的元素的点击事件,例如下面的例子中1秒钟之后button才出现
// html代码
<div id="div1">

</div>

// js代码
setTimeout(()=>{
    const button = document.createElement('button')
    button.textContent= 'click 1'
    div1.appendChild(button)
},1000)

div1.addEventListener('click', (e)=>{
    const t = e.target
    if(t.tagName.toLowerCase() === 'button'){
        console.log('button'被点击了)
    }
})

封装事件委托

写出一个函数,例如on('click', '#div1','li', fn),当用户点击div1中的li时,调用fn函数

// 答案一
setTimeout(()=>{
    const button = document.createElement('button')
    button.textContent= 'click 1'
    div1.appendChild(button)
},1000)

functin on(eventType, element, selector, fn){
   if(!(element instanceOf Element)){
       element = document.querySelector(element)
   }
    element.addEventListener(eventType, (e)=>{
        const t = e.target
        if(t.matches(selector)){
            fn(e)
        }
    })
}

on('click', '#div1', 'button',()=>{
    console.log('button 被点击了')
})
// 答案二: 使用递归进行判断
function on(eventType, element, selector, fn) {
   if(!(element instanceOf Element)){
      element = document.querySelector(element)
   }
    element.addEventListener(eventType, e => {
      let el = e.target
      while (!el.matches(selector)) {
        if (element === el) {
          el = null
          break
        }
        el = el.parentNode
      }
      el && fn.call(el, e, el)
    })
    return element

注意的是:本章节讲的是DOM的事件,JS只是调用了DOM提供的addEventListener方法,其实JS不支持事件,除非开发者手写一个事件系统

更多信息

事件捕获和事件冒泡的区别

事件冒泡、事件捕获和事件委托

MDN 事件参考