DOM 的事件传送机制:捕获与冒泡

DOM 的事件传送机制:捕获与冒泡

2024 年 9 月 28 日

阅读 6 分钟 DOM 的事件传送机制:捕获与冒泡 前言

今天为大家带来的内容是 DOM 里面的事件传递机制,而与这些事件相关的代码,相信大家应该不陌生,就是 addEventListener, preventDefault 和 stopPropagation。

简单来说,就是事件在 DOM 里面传输的顺序,以及你可以对这些事件做什么。

为什么会有 “传输顺序” 这一词呢?假设你有一个 ul 元素,底下有很多 li,代表不同的 item。当你点击任何一个 li 的时候,其实你也点击了 ul,因为 ul 把所有的 li 都包含了。

假如我在两个元素上面都加了 eventListener,哪一个会先执行?这个时候,知道事件的执行順序就很重要。

另外,由于某些浏览器(IE)的机制比较不一样,因此那些东西我完全不会提到,有兴趣的可以研究文末附的参考资料。

简单范例

为了之后方便说明,我们先写一个非常简单的范例出来:

在这个范例里面,就是最外层一个 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 就好。

  • 1
  • 2
  • 3

而这样的另一个好处是当新增或者删除某一个 li 的时候,不用去处理那个元素相关的 listener,因为 listener 是在 ul 上代理。这样透过父节点来处理子节点的事件,就叫做事件代理。

除此之外,有一个有趣的应用,在知道原理后,我们可以这样使用 e.preventDefault():

window.addEventListener(

'click',

(e) => {

e.preventDefault()

e.stopPropagation()

},

true

)

只要这样一段代码,就可以把页面上的所有元素的点击事件停用,像 点击也不会跳转链接,

按了 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 的事件傳遞機制:捕獲與冒泡

标签: event javascript

相关推荐

盘点油烟机品牌排行前十名,油烟机十大品牌有哪些
365beat网页怎么打不开

盘点油烟机品牌排行前十名,油烟机十大品牌有哪些

📅 10-06 👁️ 1481
教做菜的app带视频教学的,教做菜的app带视频教学的有哪些
365beat网页怎么打不开

教做菜的app带视频教学的,教做菜的app带视频教学的有哪些

📅 10-01 👁️ 858
苹果手机换一个尾插一般价钱
365平台靠谱吗

苹果手机换一个尾插一般价钱

📅 10-07 👁️ 7732