DOM事件模型与事件委托

290 阅读6分钟

1. DOM事件模型

W3C规范中,DOM level 0 有两种方式绑定事件,

一是直接将事件作为属性写在HTML标签上,如下面所示

<button onclick = 'click'>click it</button>

function click(){
    console.log('hello')
}

二是,在JavaScript中获取元素后绑定

let button = document.querySelector('button')
button.onclick = f1
button.onclick = f2 // 会覆盖上一句

上述例子中,点击button后,只会触发f2方法。

button解除事件绑定,如下所示

button.onclick = null

DOM level 2,是通过addEventListener对元素绑定事件

let button = document.querySelector('button')
button.addEventListener('click',f1)
button.addEventListener('click',f2)

根据DOM 2标准,点击button会触发f1f2

因为addEventListener有事件队列,当事件触发后,会按照绑定的顺序,依次执行对应的方法

对元素解除绑定,使用removeEventListener

function f1(){
  console.log('11111');
}
function f2(){
  console.log('22222');
}
btn.addEventListener('click',f1);
btn.addEventListener('click',f2);
btn.removeEventListener('click',f1);

点击button后,只会触发f2

上述两种事件模型的区别是

difference.png

关于上图提到的事件传播,决定了元素以哪个顺序接收事件,具体指的是事件捕获和事件冒泡

2. 事件流

事件流:描述页面元素接受事件的顺序,分为事件捕获和事件冒泡,两种顺序

event.png

DOM事件流的三个阶段

  1. 事件捕获阶段,根据DOM树结构,事件对象通过目标的祖先从窗口传播到目标的父级,即从上往下找监听函数,称之为事件捕获。

  2. 处于目标阶段,事件对象到达目标元素。如果事件类型表明没有冒泡,则事件对象将在此阶段完成后停止传播。

  3. 事件冒泡阶段,事件对象以相反的顺序,从目标的父级开始,通过目标的祖先元素传播,以Window结束,即从下往上找监听函数,称之为事件冒泡。

早期IE浏览器使用

attachEvent('onclick',fn) //事件冒泡

但是网景公司采用

addEventListener('click',fn) //事件捕获

最后,W3C制定标准为

addEventListener('click',fn,boolean) // boolean为falsy或者不传,默认事件冒泡;为true,事件捕获

下面例子中捕获阶段的部分顺序是div-->p-->button,冒泡阶段的顺序为button-->p-->div,有一个p是属于捕获阶段,所以先打印,剩下的走冒泡阶段,所以顺序是button-->div

 <div id="parentp">
    <p id="child">
      <button id='btn'>click</button>
    </p>
  </div>
btn.addEventListener('click',()=>{console.log('btn clicked')});
child.addEventListener('click',()=>{console.log('child clicked')},true);
parentp.addEventListener('click',()=>{console.log('parent clicked')});
child clicked
btn clicked
parent clicked

无论是事件捕获,还是事件冒泡阶段,都有是事件相关信息e,当事件触发时,获取到的事件信息是准确可信的,

e.target && e.currentTarget

上述例子中,改写一下

btn.addEventListener('click',(e)=>{console.dir(e.target);console.dir(e.currentTarget)});
child.addEventListener('click',(e)=>{console.dir(e.target);console.dir(e.currentTarget)});
parentp.addEventListener('click',(e)=>{console.dir(e.target);console.dir(e.currentTarget)});

点击按钮,打印出如下

button#btn
button#btn
button#btn
p#child
button#btn
div#parentp

从上面可以看出

e.target 指的是当前被点击的元素

e.currentTarget 指的是被监听的元素,此时,使用的this也指的是currentTarget,不推荐使用this,以防误解

注意:

  • 若是对同一个元素同时监听捕获和冒泡阶段,执行顺序是否与方法写的位置有关呢?
btn.addEventListener('click',(e)=>{console.log('冒泡')});
btn.addEventListener('click',(e)=>{console.log('捕获')},true);

输出如下,可以得出,与位置无关,仍然是先捕获后冒泡

捕获
冒泡
  • 捕获阶段不可以取消,但冒泡可以 e.stopPropagation()
btn.addEventListener('click',(e)=>{e.stopPropagation();console.log('btn clicked')});
child.addEventListener('click',()=>{console.log('child clicked')},true);
parentp.addEventListener('click',()=>{console.log('parent clicked')});

输出为

child clicked
btn clicked
  • 阻止默认行为,部分事件不可以阻止默认行为(可以通过搜索scroll event MDNCancelableYes表示可以阻止,No为不可以阻止)

    e.preventDefault() 阻止事件默认行为

    click 事件可以阻止默认行为

    <a id="test" href="https://www.google.com/">跳转</a>
    
    test.addEventListener('click',(e)=>{e.preventDefault();console.log('跳转了')})
    //点击后,会打印出‘跳转了’,但是不会跳转
    

    scroll 事件不可以阻止默认行为,需要先滚动才有滚动事件,如果无法滚动,产生不了事件对象,无法阻止

    想要阻止滚动,PC端可以使用阻止wheel事件默认行为,移动端可以使用阻止touchstart事件的默认行为

3. 自定义事件

3.1 事件的创建与触发

最简单的创建事件的方法是,使用Event构造函数

let myEvent = new Event('event_name')

如果要传递数据,需要使用CustomEvent构造函数

let myEvent = new CustomEvent('event_name', {
    detail:{
        // 此处填充待传递的数据
    }
})

DOM中的事件,都是有相应操作后触发的,而自定义事件,需要自己手动(dispatchEvent)触发

let myEvent = new CustomEvent('testE', {
    detail: {
        name: 'zhangSan',
        age: '18'
    }
});
let btn = document.querySelector('button');
btn.onclick = () => {
    btn.dispatchEvent(myEvent) //触发自定义事件
}
btn.addEventListener('testE', (e) => {
    console.log(e.detail.name) //点击button,后打印zhangSan
})

3.2 自定义事件的使用场景

自定义事件相当于是 观察者模式,可以把复杂逻辑解耦,代码可以写的很清晰,而且很容易复用

比如,点击按钮后,触发A动作,A完成后,触发B,B完成后,触发C。可以将A、B、C分为三个不同的事件,可以分开实现,便于维护

(待完善...)

4 事件委托

4.1 事件委托介绍

事件委托:利用事件冒泡,只指定一个事件处理程序,就可以管理某一类型的所有事件。即委托祖先元素代为执行事件

常见的使用场景

  • 监听子元素的事件。

    比如ul下有多个lili被点击后,背景颜色发生变化,我们可以监听ul,然后在其click事件中通过e.target匹配到对应的li元素,让其背景颜色变化

  • 监听新增元素的事件

    比如,上述例子中动态新增了一个li,新增的也要满足上述的要求

借鉴大牛们解释事件委托的例子,

有三个同事预计会在周一收到快递。为签收快递,有两种办法:一是三个人在公司门口等快递;二是委托给前台MM代为签收。现实当中,我们大都采用委托的方案(公司也不会容忍那么多员工站在门口就为了等快递)。前台MM收到快递后,她会判断收件人是谁,然后按照收件人的要求签收,甚至代为付款。这种方案还有一个优势,那就是即使公司里来了新员工(不管多少),前台MM也会在收到寄给新员工的快递后核实并代为签收。

这里其实还有2层意思的:

第一,现在委托前台的同事是可以代为签收的,即程序中的现有的DOM节点是有事件的;

第二,新员工也是可以被前台MM代为签收的,即程序中新添加的DOM节点也是有事件的。

let ul = document.querySelector('ul')
ul.addEventListener('click', (e) => {
    let target = e.target
    if (target.tagName.toLowerCase() === 'li') {
        target.style.backgroundColor = 'red'
    }
})
// 新增元素也同样响应此事件
let btn = document.querySelector('button')
btn.onclick = () => {
    let li6 = document.createElement('li')
    li6.style.className = 'li6'
    li6.innerHTML = '第6个li'
    ul.appendChild(li6);
}

如果对不同的li操作不一样,也可以对不同的li进行标记(比如添加不同的类,或者不同的自定义属性),然后一一匹配,实现对应的操作,减少获取DOM节点的操作

事件委托 参考此处

4.2 封装事件委托

function on(element, eventType, selector, fn) {
    element.addEventListener(eventType, (e) => {
        let el = e.target
        while(!(el.matches(selector))){
            if(element === el){
                el = null
                break
            }
            el = el.parentNode
        }
        el && el.fn.call(el,e,el)
    })
}

matches 如果元素被指定的选择器字符串选择,Element.matches() 方法返回true; 否则返回false

如果被点击元素,不是被操作的元素,例如,ul>li>span,原本需要操作的元素是li,但是e.targetspan,所以需要循环获取父元素,直到e.target能够和selector匹配;但如果查找到被委托元素仍未匹配成功,则不再查找(正常情况下应该不会出现这种场景)