DOM 的事件传送机制:捕获与冒泡
前言
今天为大家带来的内容是 DOM 里面的事件传递机制,而与这些事件相关的代码,相信大家应该不陌生,就是 addEventListener
, preventDefault
和 stopPropagation
。
简单来说,就是事件在 DOM 里面传输的顺序,以及你可以对这些事件做什么。
为什么会有 “传输顺序” 这一词呢?假设你有一个 ul
元素,底下有很多 li
,代表不同的 item。当你点击任何一个 li
的时候,其实你也点击了 ul
,因为 ul
把所有的 li
都包含了。
假如我在两个元素上面都加了 eventListener
,哪一个会先执行?这个时候,知道事件的执行順序就很重要。
另外,由于某些浏览器(IE)的机制比较不一样,因此那些东西我完全不会提到,有兴趣的可以研究文末附的参考资料。
简单范例
为了之后方便说明,我们先写一个非常简单的范例出来:
<!doctype html>
<html lang="en">
<body>
<ul id="list">
<li id="list_item">
<a id="list_item_link" target="_blank" href="https://google.com">google.com</a>
</li>
</ul>
</body>
</html>
在这个范例里面,就是最外层一个 ul,再来 li,最后则是一个超链接。为了方便辨识,id 的取名也跟层级结构有关系。DOM 画成图大概长这样:
有了这个简单的 HTML 结构之后,就可以很清楚的说明 DOM 的事件传递机制了。
事件的三个 Phase
要帮一个 DOM 加上 click 事件,你会这样写:
const $list = document.getElementById('list')
$list.addEventListener('click', (e) => {
console.log('click!')
})
而这里的 e
里面就包含了许多这次事件的相关参数,其中有一个叫做 eventPhase
,是一个数字,表示这个事件在哪一个 Phase 触发。
const $list = document.getElementById('list')
$list.addEventListener('click', (e) => {
console.log(e.eventPhase)
})
eventPhase
的定义可以在 DOM specification 里面找到:
// PhaseType
const unsigned short CAPTURING_PHASE = 1;
const unsigned short AT_TARGET = 2;
const unsigned short BUBBLING_PHASE = 3;
这三个阶段,就是我们今天的重点。
DOM 的事件在传播时,会先从根节点开始往下传递到 target
,这边你如果加上事件的话,就会处于 CAPTURING_PHASE
,捕获阶段。
target 就是你所点击的那个目标,这时候在 target
身上所加的 eventListener
会是 AT_TARGET
这一个 Phase
最后,事件再往上从子节点一路逆向传回根节点,这时候就叫做 BUBBLING_PHASE
,也就是大家比较熟知的冒泡阶段。
这边用文字你可能会觉得云里雾里,直接引用一张 W3C event flow 的图,相信大家就清楚了。
你点击那个 td
的时候,这个点击事件会先从 window
开始往下传,一直传到 td
为止,到这边就叫做 CAPTURING_PHASE
,捕获阶段。接着事件传到 td
本身,这时候叫做 AT_TARGET
。最后事件会从 td
一路传回 window
,这时候叫做 BUBBLING_PHASE
,冒泡阶段。
所以,再看一些将事件机制的文章的时候,都会看到一个口诀:先捕获,再冒泡。就是这样来的。
可是,我要怎么决定我要在捕获阶段还是冒泡阶段去监听这个事件呢?
其实,一样是用大家所熟悉的 addEventListener
,只是这函数其实有第三个参数,true
代表把这个 listener 添加到捕获阶段,false
或是没有传参就代表把 listener 添加到冒泡阶段。
实际演练
大概知道事件的传递机制之后,我们拿上面写好的那个简单范例来示范一下,一样先附上事件传递的流程图(假设我们点击的对象是 #list_item_link
)。
接着,来试试看帮每个元素的每个阶段都添加事件,看一看结果跟我们想想的是否一样:
const get = id => document.getElementById(id)
const $list = get('list')
const $list_item = get('list_item')
const $list_item_link = get('list_item_link')
// list 的捕获
$list.addEventListener(
'click',
(e) => {
console.log('list capturing', e.eventPhase)
},
true
)
// list 的冒泡
$list.addEventListener(
'click',
(e) => {
console.log('list bubbling', e.eventPhase)
},
false
)
// list_item 的捕获
$list_item.addEventListener(
'click',
(e) => {
console.log('list_item capturing', e.eventPhase)
},
true
)
// list_item 的冒泡
$list_item.addEventListener(
'click',
(e) => {
console.log('list_item bubbling', e.eventPhase)
},
false
)
// list_item_link 的捕获
$list_item_link.addEventListener(
'click',
(e) => {
console.log('list_item_link capturing', e.eventPhase)
},
true
)
// list_item_link 的冒泡
$list_item_link.addEventListener(
'click',
(e) => {
console.log('list_item_link bubbling', e.eventPhase)
},
false
)
点一下超链接,console 输出以下结果:
'list capturing', 1
'list_item capturing', 1
'list_item_link capturing', 2
'list_item_link bubbling', 2
'list_item bubbling', 3
'list bubbling', 3
1 是 CAPTURING_PHASE
,2 是 AT_TARGET
,3 是 BUBBLING_PHASE
。
从这里就可以很明晰看出,时间的确是从最上层一直传递到 target
,而在这传递的过程里,我们用 addEventListener
的第三个参数把 listener 添加在 CAPTURING_PHASE
。
然后事件传递到我们点击的超链接(a#list_item_link
)本身,在这里无论你设置 addEventListener
的第三个参数是 true
还是 false
,这里的 e.eventPhase
都会变成 AT_TARGET
。
最后,在从 target
不断冒泡传回去,先传到上一层的 #list_item
,再传到上上层的 #list
。
先捕获,再冒泡的小陷阱
既然是先捕获,再冒泡,意思是无论那些 addEventListener
的顺序怎么变,输出的东西应该还是一样才对。我们把捕获跟冒泡的顺序对调,看一下输出的结果是否一样。
const get = id => document.getElementById(id)
const $list = get('list')
const $list_item = get('list_item')
const $list_item_link = get('list_item_link')
// list 的冒泡
$list.addEventListener(
'click',
(e) => {
console.log('list bubbling', e.eventPhase)
},
false
)
// list 的捕獲
$list.addEventListener(
'click',
(e) => {
console.log('list capturing', e.eventPhase)
},
true
)
// list_item 的冒泡
$list_item.addEventListener(
'click',
(e) => {
console.log('list_item bubbling', e.eventPhase)
},
false
)
// list_item 的捕獲
$list_item.addEventListener(
'click',
(e) => {
console.log('list_item capturing', e.eventPhase)
},
true
)
// list_item_link 的冒泡
$list_item_link.addEventListener(
'click',
(e) => {
console.log('list_item_link bubbling', e.eventPhase)
},
false
)
// list_item_link 的捕獲
$list_item_link.addEventListener(
'click',
(e) => {
console.log('list_item_link capturing', e.eventPhase)
},
true
)
同样点击超链接,输出结果是:
'list capturing', 1
'list_item capturing', 1
'list_item_link bubbling', 2
'list_item_link capturing', 2
'list_item bubbling', 3
'list bubbling', 3
可以发现一件神奇的事,那就是 list_item_link
居然是先执行了添加在冒泡阶段的 listener,才执行捕获阶段的 listener。
这是为什么呢?其实刚刚上面有提到,当事件传递到点击的真正对象,也就是 e.target
的时候,无论你是使用 addEventListener
的第三个参数是 true
还是 false
,这里的 e.eventPhase
都会变成 AT_TARGET
。
既然这里已经编成 AT_TARGET
,自然就没有什么捕获跟冒泡之分,所以执行顺序就会根据你 addEventListener
的顺序而定,先添加的先添加的先执行,后添加的后执行。
所以,这就是为什么我们上面把捕获跟冒泡的顺序换了以后,会先出现 list_item_link bubbling
的原因。
关于事件的传递顺序,只要记住两个原则就好:
- 先捕获,再冒泡
- 当事件传到
target
本身,沒有分捕获跟冒泡
取消事件传递
接着要讲的是,这一串事件链这么长,一定有方法可以中断,让事件的传递不再继续,而这个方法就是 e.stopPropagation
。
这个方法及在哪边,事件的传递就断在哪里,不会再继续往下传递。
例如说以上那个例子来讲,假如我加在 #list
的捕获阶段:
// list 的捕獲
$list.addEventListener(
'click',
(e) => {
console.log('list capturing', e.eventPhase)
e.stopPropagation()
},
true
)
这样,console
就只会输出:
'list capturing', 1
因为事件的传递被停止,所以剩下的 listener 都不会再收到任何的事件。
不过,这里依然有一个地方要特别注意。这里指的 “事件传递被停止” 的意思不是说不会再把事件传递给 “下一个节点”,但若是你在同一个节点上有不止一个 listener,还是会被执行到。
例如说:
// list 的捕獲
$list.addEventListener(
'click',
(e) => {
console.log('list capturing')
e.stopPropagation()
},
true
)
// list 的捕獲 2
$list.addEventListener(
'click',
(e) => {
console.log('list capturing2')
},
true
)
输出的结果是:
list capturing
list capturing2
尽管已经使用 e.stopPropagation
,但对于同一层级,剩下的 listener 还是会被执行到。
若不想同一级的其它 listener 被执行,可以改用 e.stopImmediatePropagation()
。比如:
// list 的捕獲
$list.addEventListener(
'click',
(e) => {
console.log('list capturing')
e.stopImmediatePropagation()
},
true
)
// list 的捕獲 2
$list.addEventListener(
'click',
(e) => {
console.log('list capturing2')
},
true
)
结果输出:
list capturing
取消默认行为
常常有人搞不清楚 e.stopPropagation
与 e.preventDefault
的区别,前者刚刚已经说明了,就是取消事件往下继续传递,而后者则是取消浏览器的默认行为。
最常见的做法是阻止超链接跳转:
// list_item_link 的冒泡
$list_item_link.addEventListener(
'click',
(e) => {
e.preventDefault()
},
false
)
这样,当点击超链接的时候,就不会执行原本的默认行为(新开分页或者跳转),而是不做任何行为,这就是 preventDefault
的作用。
所以说,preventDefault
与 JavaScript 的事件传递一点关系都没有,加上这一行后,事件还会继续往下传递。
需要注意的地方是 W3C 文件里面写道:
Once preventDefault has been called it will remain in effect throughout the remainder of the event’s propagation.
意思是说一旦执行了 preventDefault
,这之后传递下去的事件里面也会有效果。
// list 的捕獲
$list.addEventListener(
'click',
(e) => {
console.log('list capturing', e.eventPhase)
e.preventDefault()
},
true
)
我们在 #list
的捕获事件里面执行了 e.preventDefault()
,而根据文件上面所说的,这个效果会在之后的传递事件里面一直延续。因此,之后事件传递到 #list_item_link
的时候,会发现点超链接一样没反应。
实际应用
知道了事件的传递机制、取消传递事件和取消默认行为之后,在实际开发上有什么用处呢?
最常见的用法其实就是时间代理(Delegation),例如有一个 ul
,包裹着 1000 个 li
,如果帮每个 li
绑定 eventListener
,就新建了 1000 个 function。但是我们已经了解,任何点击 li
的事件都会传到 ul
上,于是可以在 ul
上绑定一个 listener 就好。
<!doctype html>
<html lang="en">
<body>
<ul id="list">
<li data-index="1">1</li>
<li data-index="2">2</li>
<li data-index="3">3</li>
</ul>
</body>
</html>
<script>
document.getElementById('list').addEventListener('click', (e) => {
console.log(e.target.getAttribute('data-index'))
})
</script>
而这样的另一个好处是当新增或者删除某一个 li
的时候,不用去处理那个元素相关的 listener,因为 listener 是在 ul
上代理。这样透过父节点来处理子节点的事件,就叫做事件代理。
除此之外,有一个有趣的应用,在知道原理后,我们可以这样使用 e.preventDefault()
:
window.addEventListener(
'click',
(e) => {
e.preventDefault()
e.stopPropagation()
},
true
)
只要这样一段代码,就可以把页面上的所有元素的点击事件停用,像 <a>
点击也不会跳转链接,<form>
按了 submit
没反应,因为阻止了事件冒泡,其它的 onClick
事件都不会执行。
或者,也可以这样用:
window.addEventListener(
'click',
(e) => {
console.log(e.target)
},
true
)
利用事件传递机制的特性,在 window 上面使用捕获,就能保证一定是第一个被执行的事件,就可以在这个 function 里面监听页面中每个元素的点击,可以传送到服务端做数据统计及分析。
总结
DOM 的事件传递机制算是 JavaScript 众多经典面试题里面相对简单很多的,只要掌握事件传递的原则跟顺序,其实就差不多。
而 e.preventDefault
与 e.stopPropagation
的区别在知道事件传递顺序之后也容易理解,前者只是取消默认行为,与事件传递没有关系,后者是让事件不再往下传递。
参考资料
- JavaScript 详说事件机制之冒泡、捕获、传播、委托
- Javascript 事件冒泡和捕获的一些探讨
- 浅谈 javascript 事件取消和阻止冒泡
- What Is Event Bubbling in JavaScript? Event Propagation Explained
- What is event bubbling and capturing?
- Event order
- Document Object Model Events
本文由 吳文俊 翻译,原文地址 OM 的事件傳遞機制:捕獲與冒泡