理解 DOM 事件流
DOM2 Events 规范规定事件流分为3个阶段:事件捕获、到达目标、事件冒泡。虽然 DOM2 Events 规范规定的事件流是从
document
开始,但实际上,所有浏览器都是从window
对象就开始了。
下面的代码均在一个空白的宽高100%的页面中演示,点击的元素均为body
EventTarget
在理解事件流之前,需要先理解 EventTarget
。为什么要先理解EventTarget
?因为注册事件监听器(处理程序),大多通过调用addEventListener()
方法或设置onevent
属性这两种方式。而addEventListener()
其实就是EventTarget.prototype
上的方法。主要包括addEventListener()
、removeEventListener()
、dispatchEvent()
。
为什么DOM
元素、window
对象等都可以使用addEventListener()
注册事件监听器,是因为他们都继承了EventTarget
,包括XMLHttpRequest
、AudioNode
和 AudioContext
等等。
const { addEventListener } = EventTarget.prototype
window.addEventListener === addEventListener
document.body.addEventListener === addEventListener
XMLHttpRequest.prototype.addEventListener === addEventListener
AudioNode.prototype.addEventListener === addEventListener
AudioContext.prototype.addEventListener === addEventListener
以上结果均为 true
addEventListener()
方法用于注册事件监听器,removeEventListener()
方法移除事件监听器,dispatchEvent()
方法触发事件监听。
function fn() {
console.log('事件监听')
}
document.body.addEventListener("click", fn)
const event = new Event('click')
setTimeout(function () {
// 模拟点击事件,或 document.body.click()
document.body.dispatchEvent(event)
}, 1000)
setTimeout(function () {
document.body.removeEventListener('click', fn)
}, 2000)
setTimeout(function () {
document.body.dispatchEvent(event)
}, 3000)
1秒后打印事件监听
,2秒后移除fn
对应的事件监听器,因此3秒后不会再次打印。
其实如果从设计模式的角度理解,这就是一个简单的发布订阅模式,就像Vue
中的$on()
、$off()
、$emit()
。addEventListener()
对应$on()
,也可以通过参数配置实现$once()
的功能,removeEventListener()
对应$off()
,dispatchEvent()
对应$emit()
。
事件流
addEventListener()
文章开头提到,事件流分为3个阶段:事件捕获、到达目标、事件冒泡。在使用addEventListener()
注册事件监听器的时候,可以通过参数配置,添加到捕获或冒泡阶段,默认注册到冒泡阶段。
// 点击 document.body,document.body 为目标元素
window.addEventListener("click", function () {
console.log('window clicked 事件冒泡阶段')
})
document.addEventListener("click", function () {
console.log('document clicked 事件冒泡阶段')
})
document.body.addEventListener("click", function () {
console.log('document.body clicked 事件冒泡阶段')
})
window.addEventListener("click", function () {
console.log('window clicked 事件捕获阶段')
}, true)
document.addEventListener("click", function () {
console.log('document clicked 事件捕获阶段')
}, true)
document.body.addEventListener("click", function () {
console.log('document.body clicked 事件捕获阶段')
}, true)
当body
触发点击事件时,打印结果如下:
window clicked 事件捕获阶段
document clicked 事件捕获阶段
document.body clicked 事件捕获阶段
document.body clicked 事件冒泡阶段
document clicked 事件冒泡阶段
window clicked 事件冒泡阶段
在调用addEventListener()
方法,第三个参数传入了true
(默认为false)时,事件监听器被注册了冒泡阶段,按照事件流执行的顺序,先是事件捕获
阶段,到达document.body
目标元素后,再向上冒泡,进入到事件冒泡
阶段,因此打印结果如上所示。对于现在的浏览器来说,事件流是一个完整的流程,不会说只触发事件捕获,或者只触发事件冒泡,而是到达哪个阶段就执行那个阶段注册的事件监听器。因此想在哪个阶段触发事件,就将事件监听注册到哪个阶段。
但对于到达目标
阶段有点特殊。虽然 DOM2 Events
规范明确捕获阶段不命中事件目标,但现在的浏览器都会在捕获阶段在事件目标上触发事件,因此事件目标在事件捕获
、事件冒泡
阶段都会处理事件。就像document.body clicked 事件捕获阶段
所在的事件可以理解为在事件捕获
阶段处理,而document.body clicked 事件冒泡阶段
所在的事件在事件冒泡
阶段处理。
在使用addEventListener()
注册事件监听时,需要注意两点:
- 在同一阶段,传入同一个函数多次注册事件监听时,只会注册一次。
function handleClick(){
console.log('事件监听')
}
document.body.addEventListener("click", handleClick)
// 再次添加
document.body.addEventListener("click", handleClick)
点击一次,handleClick
这个方法只会执行一次。
document.body.addEventListener("click", function handleClick(){
console.log('事件监听')
})
// 再次添加
document.body.addEventListener("click", function handleClick(){
console.log('事件监听')
})
在body
元素上,传入匿名函数注册了两个事件监听,虽然匿名函数的名称都是handleClick
,看起来也没有任何区别,但却是两个不同的Function
实例,因此点击body
会打印两次事件监听
。
function handleClick(){
console.log('事件监听')
}
document.body.addEventListener("click", handleClick)
// 再次添加
document.body.addEventListener("click", handleClick)
// 再次添加到捕获阶段
document.body.addEventListener("click", handleClick, true)
点击一次,handleClick
方法触发两次,捕获阶段一次,冒泡阶段一次。
addEventListener()
方法传入一个匿名函数时,事件监听无法被移除。
document.body.addEventListener("click", function handleClick(){
console.log('事件监听')
})
// error
document.body.removeEventListener('click', handleClick)
上面的代码会报错,removeEventListener()
方法第二个参数需要接收一个函数引用,而handleClick
这个函数名,只在handleClick
这个函数内部可用。
onevent
除了通过调用addEventListener()
注册事件监听以外,还可以通过元素的onevent
属性添加,on
加上事件类型
,例如onclick
、onload
。
document.body.addEventListener("click", function () {
console.log('document.body clicked 事件捕获阶段')
}, true)
document.body.addEventListener("click", function () {
console.log('document.body clicked 事件冒泡阶段')
})
document.body.onclick = function(){
console.log('document.body clicked 事件冒泡阶段-onclick')
}
当body
触发点击事件时,打印结果如下:
document.body clicked 事件捕获阶段
document.body clicked 事件冒泡阶段
document.body clicked 事件冒泡阶段-onclick
上面的代码中为document.body.onclick
属性赋值了一个匿名函数,可以看到这个事件在事件冒泡
阶段被处理。以onevent
这种方式添加的事件处理程序,都是会注册到事件流的冒泡阶段。既然onevent
是以属性的方式添加,那么最多也就只能添加一个,再次添加会被覆盖,可以通过设置属性为null
的方式移除监听,例如document.body.onclick = null
document.body.addEventListener("click", function () {
console.log('document.body clicked 事件捕获阶段')
}, true)
document.body.addEventListener("click", function () {
console.log('document.body clicked 事件冒泡阶段')
})
document.body.onclick = function(){
console.log('document.body clicked 事件冒泡阶段-onclick')
}
// 事件冒泡阶段-1
document.body.addEventListener("click", function () {
console.log('document.body clicked 事件冒泡阶段-1')
})
在上面的代码的基础上,又添加了一个事件监听事件冒泡阶段-1
。
打印结果如下:
document.body clicked 事件捕获阶段
document.body clicked 事件冒泡阶段
document.body clicked 事件冒泡阶段-onclick
document.body clicked 事件冒泡阶段-1
在同一阶段同一事件类型添加多个事件监听,在触发点击事件时,多个事件监听器执行的顺序就是添加的顺序。
Event
在 DOM 中发生事件时,所有相关信息都会被收集并存储在一个名为 event
的对象中,event
也是传给事件监听器的唯一参数。在通过dispatchEvent
方法触发事件监听时,就用到Event
类生成一个event
对象。不同的事件生成的事件对象也许会包含不同的属性和方法,但他们无疑都继承了Event
。
document.body.onfocus = (event) => {
console.log('focus', getAllConstructor(event))
}
document.body.onclick = (event) => {
console.log('click', getAllConstructor(event))
}
function getAllConstructor(obj) {
const arr = []
let prototype
while (prototype = Object.getPrototypeOf(obj)) {
arr.push(prototype.constructor.name)
obj = prototype
}
return arr
}
点击body
获取焦点后,打印结果如下:
focus ['Event', 'Object']
click ['PointerEvent', 'MouseEvent', 'UIEvent', 'Event', 'Object']
可以看到,focus
对应的事件对象就是Event
类的实例,而click
对应的事件对象是PointerEvent
类的实例,但PointerEvent
也是继承了Event
。
所有的事件对象都包含一些公共属性和方法,例如:
- type: 事件类型
- currentTarget: 注册该事件监听器的元素
- target: 事件目标
- preventDefault(): 阻止默认行为
- stopPropagation(): 阻止事件流传播,取消后续的事件捕获或冒泡
- stopImmediatePropagation(): 阻止事件流传播,并阻止调用任何后续的事件监听
(DOM3 Events 中新增)
- eventPhase: 调用当前事件监听所处的阶段:
1
代表捕获阶段,2
代表到达目标,3
代表冒泡阶段。
target、currentTarget、eventPhase
window.addEventListener("click", function ({currentTarget, target, eventPhase}) {
console.log(eventPhase) // 1
console.log(this === currentTarget) // true
console.log(window === currentTarget) // true
console.log(window === target) // false
}, true)
document.addEventListener("click", function ({currentTarget, target, eventPhase}) {
console.log(eventPhase) // 3
console.log(this === currentTarget) // true
console.log(document === currentTarget) // true
console.log(document === target) // false
})
document.body.addEventListener("click", function ({currentTarget, target, eventPhase}) {
console.log(eventPhase) // 2
console.log(this === currentTarget) // true
console.log(document.body === currentTarget) // true
console.log(document.body === target) // true
})
点击body
时,使用普通函数注册的事件监听,在执行期间this
等于currentTarget
,指向注册当前事件监听的元素。而target
永远指向事件目标document.body
。只有当到达目标
阶段,也就是eventPhase === 2
时,this
、currentTarget
、target
这三者才是完全相等的,都指向注册当前事件监听器的元素。Vue
事件修饰符中的self
对应的就是这种情况。
stopPropagation、stopImmediatePropagation
event.stopPropagation()
方法用于阻止事件流传播,事件流不会传播到下一个元素,更不会传播到下一个阶段。
// 捕获阶段
document.addEventListener("click", function (event) {
event.stopPropagation()
console.log('document clicked 捕获阶段')
}, true)
document.addEventListener("click", function (event) {
console.log('document clicked 捕获阶段-1')
}, true)
// 捕获阶段
document.body.addEventListener("click", function (event) {
console.log('document.body clicked 捕获阶段')
}, true)
点击body
时,打印document clicked 捕获阶段
、document clicked 捕获阶段-1
,因为在document
捕获阶段就已经阻止了事件流传播,不会传播到下一个元素,因此不会触发document.body
的事件监听。
// 捕获阶段
document.body.addEventListener("click", function (event) {
event.stopPropagation()
console.log('document.body clicked 捕获阶段')
}, true)
// 冒泡阶段
document.body.addEventListener("click", function (event) {
console.log('document.body clicked 冒泡阶段')
})
点击body
时,只会打印document.body clicked 捕获阶段
,因为在document.body
捕获阶段就已经阻止了事件流传播,不会传播到冒泡阶段
,因此不会触发document.body
冒泡阶段的事件监听。
event.stopImmediatePropagation()
方法同样可以阻止事件流传播,但与event.stopImmediatePropagation()
不同的是,event.stopImmediatePropagation()
还会阻止调用任何后续的事件监听。什么意思呢?还使用event.stopPropagation()
的第一个例子,将其中的event.stopPropagation()
替换成event.stopImmediatePropagation()
。
// 捕获阶段
document.addEventListener("click", function (event) {
// event.stopPropagation()
event.stopImmediatePropagation()
console.log('document clicked 捕获阶段')
}, true)
document.addEventListener("click", function (event) {
console.log('document clicked 捕获阶段-1')
}, true)
// 捕获阶段
document.body.addEventListener("click", function (event) {
console.log('document.body clicked 捕获阶段')
}, true)
此时点击body
,只会打印document clicked 捕获阶段
,而在event.stopPropagation()
的例子中会多打印一个document clicked 捕获阶段-1
。就是因为event.stopImmediatePropagation()
会阻止调用任何后续的事件监听,因此document clicked 捕获阶段-1
对应的事件监听并不会执行,而event.stopPropagation()
则会将当前元素
上的当前事件流阶段
的所有事件监听全部执行。
需要注意的是:event
对象只在事件监听器执行期间存在,一旦执行完毕,就会被销毁;调用stopPropagation()
、stopImmediatePropagation()
等方法时,必须通事件对象event
,或者将内部的this
绑定为event
。
参考文献
《JavaScript 高级程序设计(第4版)》
转载自:https://juejin.cn/post/7321410951324614693