这回说的是事件模型,跟上篇博客说的事件循环关系不大。事件循环主要是同异步事件在内部环境的执行过程,而事件模型主要是涉及到事件的生成过程,在实践中的应用比较多,比如说常见的事件委托(也叫做事件代理)。
事件模型
事件模型指的是当我们触发一个事件时,会经历三个阶段,分别是捕获阶段,目标阶段,冒泡阶段。(IE8及之前版本 的事件模型则没有捕获阶段)
- 捕获阶段:事件对象从window经过目标节点的祖先节点到达目标节点,若经过的结点中绑定有同类型事件的监听函数,则会被触发执行。
- 目标阶段:事件对象到达目标节点,开始执行指定的事件监听函数。若该事件对象被标志为不能冒泡,则到此会停止执行。
- 冒泡阶段:事件对象从目标节点经过目标节点的祖先节点到达window,若经过的结点中绑定有同类型事件的监听函数,则会被触发执行。
在捕获阶段从 window 到目标节点经过的节点顺序为 window -> document -> html -> body -> 祖先节点 -> 父亲节点 -> 目标节点,冒泡阶段则反过来。
我们可以用代码验证一下:
// html 代码, css的样式代码就不贴了
<div id="parent>
<div id="children"></div>
</div>
// JS代码
window.addEventListener("click", () => {
console.log("window");
}, true)
document.addEventListener("click", () => {
console.log("document");
}, true)
document.body.addEventListener("click", () => {
console.log("body");
}, true)
document.documentElement.addEventListener("click", () => {
console.log("html");
}, true)
document.getElementById("parent").addEventListener("click", () => {
console.log("parent");
}, true)
document.getElementById("children").addEventListener("click", () => {
console.log("children");
}, true)
上面结果是通过点击 children 节点得到的。 需要注意的是,addEventListener 函数的第三个参数表示是否在捕获阶段触发监听函数,默认为 false 即在冒泡阶段才触发,为 true 时则在捕获阶段触发。我们把上面代码的第三个参数都修改成 false 看看冒泡阶段的执行顺序。
可以看到,冒泡阶段的执行顺序确实是和捕获阶段相反的。当然如果你给某一个节点的捕获阶段和冒泡阶段都添加相应的事件监听函数,则在一次点击事件中两者都会触发到。我们把上面捕获阶段和冒泡阶段的代码放到一起执行来验证看看。
这里还有一个注意点,如果目标节点同时定义了同类型事件捕获阶段和冒泡阶段的监听函数,则哪个事件代码写在前面就先执行哪个,而对于非目标节点则都是先执行捕获后执行冒泡。这里就不贴图了,看客们可以自行验证。
Event 对象
关于 Event 对象,这里只介绍四个和事件模型相关的 api,其余部分看客们可直接查看MDN。
event.stopPropagation()
如果要阻止事件继续向下捕获或向上冒泡的话,可以直接在相应的目标事件中使用event.stopPropagation()
(但依旧会执行当前节点的监听函数)。如果是非目标节点的话,在执行完当前节点的相应事件后就会停止执行了。如果是目标节点的话,即使在捕获阶段阻止了也还是会执行目标节点的冒泡事件再停止执行。
除了event.stopPropagation()
,event.cancelBubble = true
也可以达到同样的功能,不过event.cancelBubble = true
主要是用于做 IE 兼容,而且已经 Web 标准中删除了。我试了下,发现不管是event.stopPropagation()
,还是event.cancelBubble = true
在 Chrome 和 IE11 中都起作用了。event.cancelBubble = true
了解一下就好了,说不定以后哪天需要兼容到 IE 老古董的时候就可以派上用场。
event.composedPath()
event.composedPath()
会返回一个数组,成员是目标节点最内层的子节点和依次冒泡经过的所有上层节点。还是使用上面的代码,我们在 children 节点的点击监听函数中使用event.composedPath()
再点击验证看看。
event.stopImmediatePropagation()
如果有多个相同类型事件的监听函数绑定到同一个元素上的话,则当触发事件时,会把这些事件按代码编写顺序都执行下去。若在某一个事件上增加event.stopImmediatePropagation()
则可以阻止执行剩下的同类型监听函数。
event.preventDefault()
event.preventDefault()
顾名思义是用来阻止默认行为的,可以阻止浏览器给一些事件预先设置好的默认行为如 a 标签的页面跳转等。在 jQuery 中则可以直接使用return false
来阻止默认行为,简单粗暴,然而现在这年头估计很少用 jQuery 了吧。
之前我在做 2048 的时候发现一个问题,在移动端如果一个页面的宽高都只是移动设备视图的百分之百即不会出现水平垂直滚动,但若页面中给某一个元素添加了 touch 监听事件,则在 touchmove 的时候是会触发浏览器的默认行为造成页面滑动的。当然这时候可以选择使用event.preventDefault()
来阻止移动端页面的默认滑动行为,但这时候会报错Unable to preventDefault inside passive event listener due to target being treated as passive
。
之所以会这样和addEventListener
的第三个参数配置有关,一般情况下我们都是不设置第三个参数的,因为它默认下是 false 表示事件在冒泡阶段触发,这也是我们想要的效果。但实际上第三个参数是可以设置为一个对象的,包括了几个属性:
- capture: 表示监听函数是否在捕获阶段执行,默认为 false。
- once: 表示监听函数最多只会调用一次,设置为 true 后监听函数在第一次调用后会自动被移除。
- passive: 表示监听函数永远不会阻止默认行为,默认为 true,如果这时候监听函数仍使用
event.preventDefault()
就会报错。
没错,会报Unable to preventDefault inside passive event listener due to target being treated as passive
的错误就是和这个 passive 有关。这样的设置也是有原因的,简而言之就是,当我们触发页面的 touch 事件的时候,浏览器其实并不知道页面是否需要滚动,这得根据监听函数里是否有阻止默认行为。所以浏览器只能等 touch 监听事件执行完毕后才能开始选择滚动与否,这就造成了页面的滚动会有一定时间的延迟(我看到的说是 200ms)。而为了避免这无谓的浪费,就在addEventListener
的第三个参数中配置 passive 提前告诉浏览器是否需要滚动以提高页面响应速度。说到这,解决那报错的方法就很明显了,直接把 passive 设置为 false 就可以了。
事件委托
事件委托是利用事件冒泡来实现的,在实际的应用中十分常见。当我们想要给子元素比如 li 添加监听函数的时候,可以选择把监听函数委托给父元素如 ul 上,在子元素上触发该事件时通过冒泡到父元素上再触发。这样可以避免为每一个子元素单独编写监听函数(不但浪费内存而且代码还冗余),更重要的是可以给之后动态添加进来的子元素也绑定该监听事件!
// html 代码
<button id="btn">给ul添加一个li标签</button>
<ul id="ul">
<li>我是子元素</li>
<li>我是子元素</li>
<li>我是子元素</li>
</ul>
// JS 代码
let ul = document.getElementById("ul");
ul.addEventListener('click', (e) => {
e = e || window.event;
let ele = e.target || e.srcElement;
ele.style.color = 'red';
})
let btn = document.getElementById("btn");
btn.addEventListener('click', () => {
let tag = document.createElement('li');
let text = document.createTextNode("我是动态添加进来的子元素");
tag.appendChild(text);
ul.appendChild(tag);
})
上面的代码实现中涉及到一个问题,它对于每一个子元素的点击都会进行响应,但如果我们不想给其他子元素例如 p 和 span 等标签也添加事件监听那怎么办?其实这也可以办到,只要在监听函数里判断目标元素是不是 li 标签就可以了。
if(ele.tagName.toLowerCase() === 'li') {
ele.style.color = 'red';
};
这里再稍微提一下e.target
和e.currentTarget
,平时还是挺容易混淆两者的。e.target
获取的是真正触发事件的目标节点,而e.currentTarget
获取的是绑定监听事件的节点。以上面的代码为例,当我们点击每一个 li 时,e.target
获取到的就是点击到的那个 li,而e.currentTarget
获取到的就是 ul,因为监听事件其实是绑定到 ul 上的。