DOM 事件体系相关的基本功,大家需要从以下几个方面来掌握
- DOM 事件流
- 事件对象
- 事件代理
- 自定义事件
DOM 事件流
前置知识
在理解事件流之前,大家首先要对以下三个名词有确切的认知
- 事件流:它描述的是事件在页面中传播的顺序。
- 事件:它描述的是发生在浏览器里的动作。 这个动作可以是用户触发的,也可以是浏览器触发的。像点击(click)、鼠标悬停(mouseover)、鼠标移走(mousemove)这些都是事件。
- 事件监听函数:事件发生后,浏览器如何响应——用来应答事件的函数,就是事件监听函数,也叫事件处理程序。
事件流的演进
当下广为大家所接受的 JS 事件流规范,也并非一蹴而就。早年, IE 和 NetScape 两家浏览器厂商在事件机制的设计上,争得你死我活,谁也不认可谁。IE 提出了冒泡流,而 NetScape 只认捕获流。两家各干各的,搞得前端程序员那段日子过得很难,每次做网页兼容性适配都是一把鼻涕一把泪。好在后来正义的 W3C 介入了,在 W3C 的统一组织下,JS 同时支持了冒泡流和捕获流,并以此为确切的事件流标准。这个标准也叫做 “DOM2事件流” 。不标准的我们不聊,下面我们所有的讨论,都围绕这个板上钉钉的“DOM2事件流”展开。
一个事件的旅行
W3C 标准约定了一个事件的传播过程要经过以下三个阶段
- 事件捕获阶段
- 目标阶段
- 事件冒泡阶段
理解这个过程最好的方式就是读图了,下图中的箭头就代表着时间的“穿梭”路径
这个过程很像是大家小时候玩蹦床:从高处下落,触达蹦床后再弹起、回到高处,整个过程呈一个对称的“V”字形
当事件被触发时,首先经历的是一个捕获过程:事件会从最外层的元素开始“穿梭”,逐层“穿梭”到最内层元素。这个穿梭过程会持续到事件抵达它目标的元素(也就是真正触发这个事件的元素)为止。此时事件流就切换到了“目标阶段”——事件被目标元素所接收。然后事件会被“回弹”,进入到冒泡阶段——它会沿着来时的路“逆流而上”,一层一层再走回去。
事件对象基础
当事件在层层的 DOM 元素中穿梭时,它可没闲着——所到之处,它都会触发当前元素上安装的事件处理函数。比如说你点击了上面图示中的 button 节点,但其实 div 节点上也安装了 click 这个事件的处理函数。那么当你点击 button 节点触发的这个 click 事件经过 div 节点时,div 节点上的处理函数照样会被触发。
当 DOM 接受了一个事件、对应的事件处理函数被触发时,就会产生一个事件对象 event 作为处理函数的入参。这个对象中囊括了与事件有关的信息,比如事件具体是由哪个元素所触发、事件的类型等等。
现在我们来写一个简单的 HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<div class="outer">
<button id="button">点击我</button>
</div>
</body>
</html>
咱们想看看这个 demo 中 button 的点击事件对象是啥样的,我们可以这么写对应的处理函数
function clickProcessor(event) {
console.log(event)
}
现在我们触发一个鼠标的点击动作时,对应的 event 对象就长这样
我们可以看到里面除了一事件普遍都会具备的基本的信息,还包括了一些某一类事件特有的补充信息(比如针对点击事件,这里记录了鼠标位置相关的信息)。这个事件对象很有意思,我们接下来着重对它展开剖析。
事件对象考点梳理
在事件对象中,有一些属性和方法,是我们特别常用的。这部分东西比较碎,但考察频率较高。面试官有时会单独问你,但更多的是倾向于在编码类题目中直接看你能不能用它们来写代码。我们把这部分考点总结如下
- currentTarget 它记录了事件当下正在被哪个元素接收,即”正在经过哪个元素“。这个元素是一直在改变的,因为事件的传播毕竟是个层层穿梭的过程。
如果事件处理程序绑定的元素,与具体的触发元素是一样的,那么函数中的 this、event.currentTarget、和 event.target 三个值是相同的。我们可以以此为依据,判断当前的元素是否就是目标元素。
- target 指触发事件的具体目标,也就是最具体的那个元素,是事件的真正来源。
就算事件处理程序没有绑定在目标元素上、而是绑定在了目标元素的父元素上,只要它是由内部的目标元素冒泡到父容器上触发的,那么我们仍然可以通过 target 来感知到目标元素才是事件真实的来源。
-
preventDefault 阻止默认行为 这个方法用于阻止特定事件的默认行为,如 a 标签的跳转等。
e.preventDefault();
-
stopPropagation 不再派发事件 这个方法用于终止事件在传播过程的捕获、目标处理或冒泡阶段进一步传播。调用该方法后,该节点上处理该事件的处理程序将被调用,事件不再被分派到其他节点。
e.stopPropagation();
有时我们不希望一个事件的触发带来“一石激起千层浪”的效果,希望把它的影响面控制在目标元素这个范围内。这种情况下,千万别忘了 stopPropagation。
- 事件对象,是可以手动创建的
事件对象不一定需要你通过触发某个具体的事件来让它“自然发生”,它也可以手动创建的:
我们可以借助 Event() 构造函数, 来创建一个新的事件对象 Event。
event = new Event(typeArg, eventInit);
事件对象的这个特性,是我们创建自定义事件的基础——可能一些同学对自定义事件还比较陌生,但它确实非常重要。在四五年前,自定义事件就已经是考察一个前端是否资深的重要标准。随着前端技术的蓬勃发展,对资深前端的要求不断提高,自定义事件的能力变成了基础层次的能力,但它的不可或缺性仍然不可改变。
自定义事件
大家到现在所了解到的事件,基本都离不开浏览器的行为。比如点击鼠标、按下键盘等等,这些都可以被浏览器感知到,进而帮助我们转换成一个“信号”触发对应处理函数。但是还有一些行为,是浏览器感知不到的。比如说大家看这样一段 html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
<style>
#divA,
#divB,
#divC {
width: 100px;
height:100px;
}
#divA {
background-color: #333;
}
#divB {
background-color: #dd5990;
}
#divC {
background-color: #ccc990;
}
</style>
</head>
<body>
<div id="divA">我是A</div>
<div id="divB">我是B</div>
<div id="divC">我是C</div>
</body>
</html>
如果我们仅仅想监听 divA 这个元素上的点击行为,我们可以用 addEventListener 来安装监听函数
var divA = document.getElementById('divA')
document.addEventListener('click',function(){
console.log('我是小A')
})
这是大家非常熟悉的操作。但是,如果我现在想实现这样一种效果
在点击A之后,B 和 C 都能感知到 A 被点击了,并且做出相应的行为——就像这个点击事件是点在 B 和 C 上一样。
是不是觉得有点意思了?我们知道,借助时间捕获和冒泡的特性,我们是可以实现父子元素之间的行为联动的。但是此处,A、B、C三者位于同一层级,他们怎么相互感知对方身上发生了什么事情呢?
这道题的解法其实有很多,尤其是在流行框架频出的今时今日,相信大家能联想到的办法就更多了。但是在早年,在工程师们的手段还没有这么多的时候,这道题最经典的解法就是咱们标题里写的——自定义事件。
“A被点击了”这件事情,可以作为一个事件来派发出去,由 B 和 C 来监听这个事件,并执行各自身上安装的对应的处理函数。在这个思路里,“A被点击了”这个动作挺特别,特别就特别在浏览器不认识它。因为浏览器不认识它,所以浏览器不肯”帮忙“,不会帮咱去感知和派发这个动作。不过没关系,感知和派发,咱都可以自己来实现
首先大家需要了解的是,自定义事件的创建。比如说咱们要创建一个本来不存在的"clickA"事件,来表示 A 被点击了,咱们可以这么写
var clickAEvent = new Event('clickA');
OK,现在事件有了,我们来完成事件的监听和派发
// 获取 divB 元素
var divB = document.getElementById('divB')
// divB 监听 clickA 事件
divB.addEventListener('clickA',function(e){
console.log('我是小B,我感觉到了小A')
console.log(e.target)
})
// 获取 divC 元素
var divC = document.getElementById('divC')
// divC 监听 clickA 事件
divC.addEventListener('clickA',function(e){
console.log('我是小C,我感觉到了小A')
console.log(e.target)
})
// A 元素的监听函数也得改造下
divA.addEventListener('click',function(){
console.log('我是小A')
// 注意这里 dispatch 这个动作,就是我们自己派发事件了
divB.dispatchEvent(clickAEvent)
divC.dispatchEvent(clickAEvent)
})
我们可以看到如下输出
说明我们的自定义事件生效了!
不过大家可能也注意到了,这里我们安装和派发事件必须得拿到 divC、divA 这样确切的元素才行。有的时候,为了进一步解耦,我们也会考虑把所有的监听函数都塞到 document 这个元素上去,由它来根据不同类型的自定义事件采取不同的动作。这种思路,就和事件代理非常相似了。
事件代理
前面咱们讲自定义事件,主要是怕大家掉坑里了。啥意思呢?我个人其实是很少考察候选人自定义事件的,在我的观念里,我默认你 js 基础只要够好,这题一定没问题(js 基础可以通过其它形式考察)。但是,对一些原生 js 情怀非常强烈的面试官,尤其是从前端史前时期一路写过来、做了 Team Leader 后代码越写越少不太能跟上潮流的面试官来说,自定义事件能说明很多问题(毕竟他早年也写了不少)。咱们防患于未然,不管他要不要考,只管先学过来。
自定义事件的考察频率,因面试官而异。而事件代理,则是妥妥的高频考点。你落到我手里,我得问你;你落到我同事手里,我同事也得问你。很多同学嫌太基础,觉得是问应届生的问题。这个想法对了一半——确实是基础,不过基础的考察可是不分人群的。所以说,事件代理可得打起精神来学了!(敲黑板)
事件代理,又叫事件委托。如果你之前没有了解过这玩意儿是啥,现在也不用急着去搜概念。这玩意儿不适合一上来就怼概念,我们直接来做题
真题:在如下的 HTML 里,我希望做到点击每一个 li 元素,都能输出它内在的文本内容。你会怎么做?
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<ul id="poem">
<li>鹅鹅鹅</li>
<li>曲项向天歌</li>
<li>白毛浮绿水</li>
<li>红掌拨清波</li>
<li>锄禾日当午</li>
<li>汗滴禾下土</li>
<li>谁知盘中餐</li>
<li>粒粒皆辛苦</li>
<li>背不动了</li>
<li>我背不动了</li>
</ul>
</body>
</html>
一个比较直观的思路是让每一个 li 元素都去监听一个点击动作
<script>
// 获取 li 列表
var liList = document.getElementsByTagName('li')
// 逐个安装监听函数
for (var i = 0; i < liList.length; i++) {
liList[i].addEventListener('click', function (e) {
console.log(e.target.innerHTML)
})
}
</script>
你要真这么干,这题一分都没有。谨记,一旦在 DOM 系列面试题中遇到符合下列三个特征的问题
- 要你安装监听某一个事件的监听函数(事件相同)
- 监听函数是被安装在一系列具有相同特征的元素上(元素特征相同,一般来说就是具备同样的父元素)
- 这一系列相同特征元素上的监听函数还干的都是一样的事儿(监听逻辑相同/雷同)
这时候你脑子里一定要冒出这四个大字——事件代理!
怎么搞?楼上我们给10个 li 安装了 10 次监听函数,累不累?开销大不大?仔细想想,10个监听函数干的还都是一样的事情,咱们能不能把这个逻辑收敛到一个元素上去?答案是能,为啥能?因为有事件冒泡啊!你想,点击任何一个 li,是不是这个点击事件都会被冒到它们共同的爹地—— ul 元素上去?那我能不能让 ul 来做这个事儿?答案是能,因为 ul 不仅能感知到这个冒上来的事件,它还可以通过 e.target 拿到实际触发事件的那个元素,做到无缝代理
var ul = document.getElementById('poem')
ul.addEventListener('click', function(e){
console.log(e.target.innerHTML)
})
大家谨记,e.target 就是指触发事件的具体目标,它记录着事件的源头。所以说,不管咱们的监听函数在哪一层执行,只要我拿到这个 e.target,就相当于拿到了真正触发事件的那个元素。拿到这个元素后,我们完全可以模拟出它的行为,实现无差别的监听效果。
像这样利用事件的冒泡特性,把多个子元素的同一类型的监听逻辑,合并到父元素上通过一个监听函数来管理的行为,就是事件代理。通过事件代理,我们可以减少内存开销、简化注册步骤,大大提高开发效率。