浅析DOM事件机制和事件委托

593 阅读6分钟

前言

如下代码

<div class="爷爷">
		<div class="爸爸">
			<div class="儿子">
				文字
			</div>
		</div>
	</div>

我们给三个div分别添加事件监听fnYeye/fnBaba/fnErzi

提问1:

  • 点击文字,算不算点击儿子?
  • 点击文字,算不算点击爸爸?
  • 点击文字,算不算点击爷爷? 答案:都算

提问2:

  • 点击文字,最先调用fnYeye还是fnErzi?
    答案:都行

因为在之前,IE5认为应该先调用fnErzi,而网景认为应该先调用fnYeye
直到2002年,W3C发布了标准:

  • 规定浏览器应该同时支持两种调用顺序
  • 首先爷爷->爸爸->儿子顺序看有没有函数监听
  • 然后儿子->爸爸->爷爷顺序看有没有函数监听
  • 有监听函数就调用,并提供事件信息,没有就跳过

我们把这两种不同顺序的监听形式添加一个术语:

  • 从外向内找监听函数,叫作事件捕获
  • 从内向外找监听函数,叫作事件冒泡

DOM事件机制

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

  1. 捕获阶段:事件从window对象自上而下向目标节点传播的阶段
  2. 目标阶段:真正的目标节点正在处理事件的阶段
  3. 冒泡阶段:事件从目标节点自下而上向window对象传播的阶段

如图,我们可以很直观的理解

image.png

addEventListener

事件绑定API

  • div.addEventListener('click,fn,bool')
    • 第一个参数是事件类型,第二个参数是一个函数,第三个参数是bool值
    • 浏览器自带100多种事件类型->事件参考_MDN,当然除此之外,用户也能自定义事件

如果bool不传或者为falsy

就让fn走冒泡,即当浏览器在冒泡阶段发现div有fn监听函数,会调用fn,并提供事件信息
举例:我们将圆环这样嵌套

<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>
level7填充红色,level1填充紫色

冒泡示例.gif
冒泡示例
当我们点击最中间的圆环时,它的颜色是自内向外填充的,点击任意圆环也都是自内向外

如果bool为true

就让fn走捕获,即当浏览器在捕获阶段发现div有监听函数,就调用fn,并提供事件信息
捕获示例.gif
捕获示例
当我们点击最中间的圆环时,它的颜色是自外向内填充的,点击任意圆环也都是自外向内

所以如图所示,通过bool,就能决定把fn放在哪一边

image.png

当然,也可以两边都执行:

捕获与冒泡示例1.gif
捕获与冒泡示例

代码图解:

image.png

注:e并非真的消亡,但这样理解就行,无需深究

target 与 currentTarget

区别

  • e.target 用户操作的元素
  • e.currentTarget 程序员监听的元素 举例
<div>
	<span>文字</span>
</div>

假设我们监听的是div,而用户实际点击了'文字',则

  • e.target 就是span
  • e.currentTarget 就是div

一个特例

按照上述来说,事件都是先捕获后监听的,
但如果只有一个div被监听(不考虑父子同时被监听),fn分别再捕获阶段和冒泡阶段对事件进行监听,
那么用户点击的元素就是开发者监听的元素

举例

div.addEventListener('click,f1')
div.addEventListener('click,f2,true')

这里是f1先执行还是f2先执行?
答案是f2先执行,因为在这个情况下,谁先监听谁先执行

取消冒泡

注:捕获不可取消,但冒泡可以

  • e.stopPropagation() 中断冒泡,浏览器不再往上走

阻止冒泡.gif
取消冒泡示例
如图,在第四个圆环处,取消冒泡后,颜色便不会再往外填充

level4.addEventListener('click', (e)=>{
  e.stopPropagation()
  removeX(e)
})

不可阻止默认动作

注意,有些事件是不能阻止默认动作的 我们搜索scroll_event_MDN,可以看到Bubble和cancelable

image.png

  • Bubbles就是该事件是否冒泡,所有冒泡事件都可以取消
  • Cancelable的意思是开发者是否可以阻止默认事件
  • Cancelable与冒泡无关

一个可能会用到的小技巧

如何阻止滚动?
我们知道,scroll事件不可阻止默认动作,但我就是叛逆,就是不想让它滚动,应该怎么做?

x.addEventListener('wheel', (e)=>{
  e.preventDefault()
})//阻止滑轮滑动滚动条

x.addEventListener('touchstart', (e)=>{
  e.preventDefault()
})//阻止手机端滑动页面

并在CSS中将滚动条隐藏

::-webkit-scrollbar {
  width: 0 !important}
}

这样就能阻止页面滚动

事件委托

什么是事件委托?

事件代理又叫事件委托,JavaScript高级程序设计上讲:事件委托就是利用事件冒泡,只指定一个事件处理程序,就可以管理某一类型的所有事件。
通常,事件委托即是把原本需要绑定在子元素的响应事件委托给祖先元素,让祖先元素担当事件监听的职务;这样只需监听一个祖先元素,就能同时处理多个子元素的事件。

使用场景举例

场景一:如果要给100个按钮添加点击事件,应该怎么做?
当然,可以给100个按钮都绑定事件,但这样太过于耗费内存,且不够简洁,
所以,利用事件委托,我们可以监听这100个按钮的祖先,等冒泡的时候判断target是不是这100个按钮中的一个

场景二:你要监听目前不存在的元素的点击事件(比如利用setTimeout,在数秒之后再添加按钮),应该怎么做?
答案依然是利用事件委托,监听祖先,等点击的时候看看是不是你想要监听的元素即可

从上述场景可以看出事件委托的优点:

  • 省监听数(内存)
  • 可以监听动态元素

封装事件委托

一个简单的需要:用户点击了li标签里的数字,就打印出"li被点击了"

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

利用事件委托:

testDiv.addEventListener('click', (e)=> {
  const t = e.target //把t记为被用户操作的元素
  if(t.matches('li')) {//判断t是不是li
    console.log('li被点击了')
  }
})

思路:

  1. 监听父元素
  2. 设置一个t,把t记为被用户操作的元素
  3. 判断被用户操作的t是不是li元素,如果是,打印出值

根据这样的思路,我们封装一个事件委托,
只需写出on('click','#testDiv','li',fn)
当用户点击#testDiv里的li中的数字,就调用fn

function on(eventType, element, selector, fn) {
    if (!(element instanceof Element)) {
        //判断传进来的element是不是Element(元素),如果不是,如#testDiv,就把它变成一个元素
        element = document.querySelector(element)
    }
    element.addEventListener(eventType, (e)=>{
        const t = e.target
        if (t.matches(selector)) {
            fn(e)
        }
    })
}

const f1 =  ()=>{
     console.log('li被点击了')
}

on('click','#testDiv','li',f1)

但这样的操作,有一个问题:

<ul id="testDiv">
    <li><span><p>1<p><span><li>
    <li><span>2<span><li>
    <li><span>3<span><li>
    <li><span>4<span><li>
</ul>

如果被点击的元素被多个祖先元素包住,这个方法就不管用了

更改思路:

  1. 递归地往外找祖先元素,直到找到li元素为止
  2. 当然不能无止境的往上找,如果到了监听的元素(上面为ul)还没找到li,就要停止查找
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){
          //在往上找的过程中,如果当前元素已经到testDiv了,默认找不到了,就把el为空,停止查找
            el = null
            break
          }
          //判断被操作的元素是否符合li,不符合就将它等于它的爸爸
          el=el.parentNode
        }
      el&&fn.call(el,e,el)//找到参数匹配选择器,就执行函数
    })
}
const f1 =  ()=>{
     console.log('li被点击了')
}

on('click', '#testDiv', 'li',f1)