本文将会介绍事件、事件流、事件对象、事件处理程序、事件委托、以及兼容 IE 浏览器等内容。
一、概念
就本文一些“术语”作简单介绍,后续章节再详述。
1. 事件
事件,可以理解为用户与浏览器(网页)交互产生的一个动作。比如点击(click)、双击(dblclick)、聚焦(focus)、鼠标悬停(mouseover)、松开键盘(keyup)等动作。浏览器内部包含了非常复杂的事件系统,事件种类非常多,JavaScript 与 HTML 的交互就是通过事件实现的。前面列举这些仅仅是冰山一角。
2. 事件对象
在产生事件之后,浏览器会生成一个对象并存储起来,它包含了当前事件的所有信息,比如发生事件的类型、导致事件的元素以及其他与事件相关的数据。例如鼠标操作,该对象还会记录事件产生时鼠标的位置等信息。待事件完成使命(即事件完结)之后,它将会被销毁。这个对象,被称为“事件对象”。
3. 事件流
事件对象在 DOM 中的传播过程,被称为“事件流”。
经常能看到类似 IE 事件流、标准事件流(DOM 事件流)的说法,主要原因是早期浏览器厂商各干各的,没有一个“中间人”去进行统一。随着 Web 的飞速发展,相关标准就由特定机构制定,浏览器厂商负责按照标准去实现。例如 W3C、WHATWG、ECMAScript 等。但由于历史遗留原因,不得不写出一大堆的兼容方法以适配所有的浏览器。
说到事件流,就不得不提“事件冒泡”和“事件捕获”了。它们分别由微软、网景团队提出,是两种几乎完全相反的事件流方案。前者被所有浏览器支持,后者则是被所有的现代浏览器所支持(包括 IE9 及以上)。
由于事件捕获不被旧版本浏览器(IE8 及以下)支持,因此实际中通常在冒泡阶段触发事件处理程序。
综上,我们可以简单地,将 IE8 及以下浏览器的事件流处理方案称为“IE 事件流”,其余的称为标准事件流(或 DOM 事件流)。
4. 事件处理程序
为响应事件而调用的函数被称为“事件处理程序”(也可称为事件处理函数、事件监听器、监听器)。通常我们会在发生事件之前,需提前为某个 DOM 元素编写事件处理监听器并进行绑定。待用户与浏览器产生交互后,事件对象会通过事件流机制在 DOM 中进行传播,一旦命中目标,事件监听器就被调用,并接收事件对象作为其唯一的参数。(注意,IE8 需要通过 window.event
全局对象来获取事件对象)
5. 事件委托
事件委托,它是利用事件流机制来提高页面性能的一种解决方案,也被称为事件代理。
6. 其他
文中还会提到事件目标一词,是指触发事件的 DOM 元素,但不一定是事件处理程序所在的 DOM 元素。当生成事件对象之后,事件目标(在 JavaScript 就是一个对象)将会被存储在事件对象的 target
属性(只读,IE8 则是 srcElement
属性)。
还有,本文将大量使用到“现代浏览器”、“主流浏览器”、“标准浏览器”、“IE 浏览器”等词语。若无特殊说明,前三个指的是包括 IE9 ~ IE11 及其他常见的浏览器。而“IE 浏览器”通常指 IE8 及更低版本的浏览器。
二、事件流
前面提到,事件流就是事件对象在 DOM 中的传播过程。标准事件流过程,如下:
捕获阶段 -> 目标阶段 -> 冒泡阶段
Captruing Phase -> Target Phase -> Bubbling Phase
其中 IE8 及以下浏览器,不支持事件捕获。即 IE8 事件流则不含捕获阶段。
举个例子:
Division
Paragraph
结合事件流,当我们点击 元素,产生一个点击事件。事件对象的捕获阶段的过程,如下:
window -> document -> html -> body -> div -> p
注:到达 p 之前是捕获阶段的所有过程,到达 p 处于目标阶段。
目标阶段过后,是冒泡阶段的过程,如下:
p -> div -> body -> html -> document -> window
因此,事件流过程可以简单绘成如下表格:
1. 事件捕获、事件冒泡
上面提到的捕获阶段和冒泡阶段,所对应的就是事件捕获、事件冒泡。
事件冒泡和事件捕获,分别由微软和网景团队提出,这是几乎完全相反的两个概念,是为了解决页面中事件流而提出的。
- 事件冒泡(Event Bubbling)
想象一下:气泡从水中冒出水面的过程,它是从里(底)到外的。事件冒泡跟这个过程很相似,事件对象会从最内层的元素开始,一直向上传播,直到 window
对象。因此冒泡过程如下:
p -> div -> body -> html -> document -> window
注意,并非所有事件都支持冒泡行为,比如 onblur
、onfocus
等事件。
- 事件捕获(Event Capture)
既然事件捕获与事件冒泡是相反的,捕获过程如下:
window -> document -> html -> body -> div -> p
总的来说,可以简单概括为:冒泡过程是由里到外,而捕获过程则是由外到里。两者刚好相反。
注意,现代浏览器都是从
window
对象开始捕获事件的,冒泡最后一站也是window
对象。而 IE8 及以下浏览器,只会冒泡到document
对象。
2. 示例
我们分别给 div
和 p
元素添加了两个事件处理程序,如下:
const elem1 = document.getElementById('div')
const elem2 = document.getElementById('p')
// 捕获阶段触发事件处理程序
elem1.addEventListener('click', () => console.log('div capturing'), true)
elem2.addEventListener('click', () => console.log('p capturing'), true)
// 冒泡阶段触发事件处理程序
elem1.addEventListener('click', () => console.log('div bubbling'), false)
elem2.addEventListener('click', () => console.log('p bubbling'), false)
// 注意,本示例在现代浏览器中可正常执行。
1. 点击 div 元素区域(不包含 p 元素区域),先后打印:
div capturing
div bubbling
2. 点击 p 元素区域,先后打印:
div capturing
p capturing
p bubbling
div bubbling
3. 注意点
通过 DOM2 Event 提供的 addEventListener()
方法,可以在捕获阶段触发事件监听器。在事件监听器中,除了可以写业务逻辑外,还经常做阻止事件冒泡、取消元素默认行为等处理。
如果不支持某个阶段,或事件对象已停止传播,则将跳过该阶段。例如,将 addEventListener()
方法的第三个参数 useCapture
设为 true
,则将跳过冒泡阶段(但注意它是会到达目标阶段的)。如果事件监听器中调用了 stopPropagation()
,则将跳过后续的所有阶段。
还有,我们给某个 DOM 元素注册一个点击事件监听器,假设其后代元素未阻止冒泡行为,只要点击该元素本身或其后代任意子元素,最后都会触发该事件监听器。因此,我们可以得出一个结论:事件监听函数的作用范围,包含元素本身所占空间及其后代元素所占空间。不论后代元素是否溢出当前元素范围(长宽),或者是否脱离文档流(指绝对布局等)。
四、事件对象
前面提到,当用户与浏览器发生交互会产生一个事件,接着会创建生成一个事件对象。
1. 获取事件对象
在标准浏览器中,无论是以哪种方式(DOM0 或 DOM2)注册事件处理程序,事件对象都是传给事件处理程序的唯一参数。但是在 IE8 及以下浏览器下事件对象只能通过 window.event
获取。
target.onclick = function (e) {
// 以下方式可兼容所有浏览器
var ev = e || window.event
}
// ⚠️ 以下这块内容,纯属满足个人强迫症,建议跳过!!!
//
// 关于 e 和 window.event 的异同(亲测):
//
// 1. 在标准浏览器, 可能是为了兼容处理,同样支持 window.event 对象,实际中我们从事件监听函数参数 e 取值即可。
// 事件发生过程中 e === window.event。当事件结束后,window.event 的值变为 undefined。
// 这点 IE11 与标准浏览器表现是一致的。
//
// 2. 在 IE9、IE10 中,e 可能是 MouseEvent 等实例对象,
// 而 window.event 是 MSEventObj 实例对象,
// 因此,e !== window.event,但问题不大。
//
// 3. 在 IE8 及以下,DOM0 事件处理程序中将不会接收到事件对象,即 e 为 undefined。
// 此时要获取事件对象,只能通过全局 window.event 获取。
// 而且 window.event 是 Object 的实例,IE8 下并没有 MSEventObj 对象,与 IE9 ~ 10 有细微差别。
// 同样地,window.event 只在事件发生过程有效,其他时候取到的值为 null。
// 还有偶然发现,在 IE8 下竟然 Object.prototype.toString.call(null) === '[object Object]',无语!
//
// 4. 综上,想要在事件监听函数中取到事件对象,只需通过:
// var ev = e || window.event
// 以上这条语句,可以兼容所有浏览器,并正确获取到事件对象。
// 至于 IE9 ~ 10 中 e 与 window.event 的细微差异,实际中无需关心,没有影响。
//
// 5. That's all.
需要注意的是,事件对象仅在事件发生过程有效,一旦事件完毕,就会被销毁。这一点所有浏览器表现一致。
在标准浏览器里,可以通过 window.event
或者事件对象的 eventPhase
属性来验证,如下:
target.onclick = function (e) {
console.log(e === window.event) // true
setTimeout(() => {
console.log(e.eventPhase) // 0
console.log(window.event) // undefined
})
}
// ⚠️ 解释:
// 1. 在标准浏览器,事件发生过程中监听器参数 e 与全局对象 window.event 是一致的
// 2. 事件对象 eventPhase 属性为 0 表示目前没有事件正在执行。
2. 内置事件类型
在现代浏览器中,内置了很多事件类型:
- 用户界面事件(UIEvent)
- 鼠标事件(MouseEvent)
- 还有很多内置事件类型,请看...
以上这些都是基于 Event 接口的派生类,而事件对象一般是派生类的实例化对象。比如:
target.onclick = function (e) {
var ev = e || window.event
console.log(ev instanceof Event) // true
console.log(ev instanceof MouseEvent) // true
}
// ⚠️
// 注意 IE8 不含 MouseEvent 等派生类,只有 Event 基类,
// 因此 IE8 中第 4 行会抛出错误。
再啰嗦一句,
window.event
最初是由 IE 引入的全局属性,且只有事件发生过程有效。现代浏览器为了兼容,也实现了这个全局属性。
3. Event 对象
前面提到,所有事件对象都源自 Event 基类,意味着 Event 基类(对象)本身包含适用于所有事件类型实例的属性和方法。主要是为了兼容性。
- 标准浏览器中 Event 对象常用属性:
Event = {
bubbles // (只读,布尔值)表示当前事件是否会冒泡。
eventPhase // (只读,数值范围 0 ~ 3)表示事件流正被处理到了哪个阶段。
// 0 表示当前没有事件正在被处理,
// 1 ~ 3 分别表示事件对象处于捕获、目标、冒泡阶段。
cancelable // (只读,布尔值)表示事件是否可以取消元素的默认行为。
// 若为 true 可以使用 preventDefault() 来取消元素的默认行为。
// 若为 false 调用 preventDefault() 没有任何效果。
cancelBubble // (布尔值)如果设为 true,相当于执行 stopPropagation(),事件对象将会停止传播。
// 标准浏览器中,请使用 stopPropagation();
// IE8 浏览器中,只能使用 cancelBubble = true 来阻止传播;
currentTarget // (只读)返回当前事件处理程序所绑定的节点(不一定是事件触发节点)。
// 标准浏览器,this === currentTarget 总为 true。
// IE8 及以下浏览器不支持该属性。
target // (只读)返回事件触发节点(即事件目标)。
// 当事件触发节点与事件处理程序所绑定节点相同时,target === currentTarget 结果为 true。
// IE8 及以下浏览器中不支持该属性,请使用 srcElement 属性(相当于 target 属性)。
type // (只读,字符串)表示事件类型
isTrusted // (只读,布尔值)true 表示事件是由浏览器生成的。
// false 表示由 JavaScript 创建,一般指自定义事件。
detail // 数值。该属性只有浏览器的 UI 事件才具有,表示事件的某种信息。
// 例如,单击为 1,双击为 2,三击为 3。
composed // (只读,布尔值)表示事件是否可以穿过 Shadow DOM 和常规 DOM 之间的隔阂进行冒泡。
// 替代已废弃的是 scoped 属性。
}
- 标准浏览器中 Event 对象常用方法:
Event = {
preventDefault() // 取消元素默认行为
// 仅当 cancelable 为 true 时,调用 preventDefault() 才有效。
// IE8 及以下浏览器,请设置 returnValue = false 来取消元素默认行为
stopPropagation() // 停止事件对象的传播,后续事件流其他阶段将会被取消。
// IE8 及以下浏览器,请设置 cancelBubble = true,来阻止冒泡行为
// 注意 IE8 及以下浏览器“有且仅有”事件冒泡过程。
stopImmediatePropagation() // 用来阻止同一时间的其他监听函数被调用。
// 当同一节点同一事件指定多个监听函数时,这些函数
// 会根据添加次序依次调用。但只要其中一个监听函数调用了
// stopImmediatePropagation() 方法,其他监听函数就不会执行了。
// DOM3 Event 新增方法
}
- IE8 及以下浏览器中 Event 对象常用属性:
Event = {
cancelBubble // (可读写,布尔值)设置为 true 可以阻止冒泡行为。
// 作用同标准浏览器中的 stopPropagation() 方法。
returnValue // (可读写,布尔值)默认为 true。
// 设置为 false 可以取消元素的默认行为
// 作用同标准浏览器中的 preventDefault() 方法。
srcElement // (只读)返回事件触发节点(即事件目标)。
// 作用同标准浏览器中的 target 属性。
type // (只读,字符串)表示事件类型(同标准浏览器的 type 属性)。
}
以上这些方法(包括标准浏览器、IE 浏览器)列举出来是为了方便下文封装方法时,注意兼容处理。
五、事件冒泡与默认行为
前面提到,并非所有事件都支持冒泡行为。因此,若给不支持冒泡行为的事件去 stopPropagation()
是多次一举,且没有意义。取消元素默认行为同理。
阻止事件冒泡和元素默认行为经常放在一起讨论。上一章节已经清楚介绍了标准浏览器与 IE 浏览器的兼容性。如下:
// 阻止冒泡
ev.stopPropagation() // 标准浏览器
ev.cancelBubble = true // IE8 及以下浏览器
// 取消默认行为
ev.preventDefault() // 标准浏览器
ev.returnValue = false // IE8 及以下浏览器
// ⚠️ ev 表示事件发生过程中的事件对象
还有,在事件处理程序中慎用 return false
语句,不同环境下会发生非预期行为。例如:
// 原生 JS,只会取消元素默认行为,不会阻止事件冒泡行为
target.onclick = function (e) {
return false
}
// JQuery,既会取消元素默认行为,也会阻止事件冒泡行为
$(target).on('click', function (e) {
return false
})
因此,无论使用 JQuery 库或其他库,还是原生 JS 去编写事件处理程序,都尽量避免使用
return
语句。其实 JQuery 已经给我们封装了
stopPropagation()
和preventDefault()
方法,它是兼容所有浏览器的,因此按照标准浏览器的方式来去阻止冒泡或取消默认行为即可。可参考文章。
六、事件处理程序
事件处理程序,也常称为事件监听器或监听器。可通过以下几种方式给 DOM 元素注册事件处理程序:
- HTML 事件处理程序(不推荐)
- DOM0 事件处理程序(也不太推荐)
- DOM2 事件处理程序
- IE 事件处理程序
前两种方式不推荐,后两种就能覆盖 99.9% 的浏览器了。如果不用兼容 IE,那么 DOM2 就更香了,至少可以减少 70% 的代码量...
1. HTML 事件处理(不推荐使用)
这是最早的事件处理方式,说实话在项目中没见过这种写法。简单了解下即可,不推荐使用。
Division
Paragraph
Paragraph
Paragraph
2. DOM0 事件处理程序
使用方法简单,也很常见。就是将一个函数赋值给一个 DOM 元素的事件属性。其中元素的事件属性通常是 on + type
(事件类型),比如 onclick
、ondblclick
、onfocus
、onload
等。
举个例子:
// 注册事件处理程序
target.onclick = function (e) {
// this 将会指向事件处理程序所绑定的元素
// do something...
}
// 移除事件处理程序
target.onclick = null
这种方式只能够注册一个事件处理程序。若多次绑定,后者会覆盖前者。
3. DOM2 事件处理程序
在 DOM2 Event 标准中,新增了 addEventListener()
和 removeEventListener()
方法来注册或移除事件处理程序。它的优势有两点:
- 可以为同一元素同一事件注册多个事件处理程序。
- 事件处理程序可以在“捕获阶段”被触发。这也是目前唯一可以在捕获阶段命中事件处理程序的方法。
该特性仅在现代浏览器中被支持,IE8 及以下不支持。
语法:
target.addEventListener(type, listener, useCapture)
target.removeEventListener(type, listener, useCapture)
type
:表示事件类型(字符串)。比如click
。
listener
:通常是一个函数,即事件处理程序。被触发时将会接收到一个事件对象作为参数。
useCapture
:布尔值,默认为false
。该参数决定了listener
是否在“捕获阶段”被触发。
举个例子:
function handler(e) {
// this 将会指向事件处理程序所绑定的元素
// do something...
}
// 注册事件处理程序
target.addEventListener('click', handler, false)
// 移除事件处理程序
target.removeEventListener('click', handler, false)
// ⚠️ 注意点:
// 1. 保留事件处理程序的引用,是移除监听事件的唯一方法。
// 2. 换句话说,若使用匿名函数作为事件处理程序,将无法移除监听事件。
// 3. 若多次注册事件处理程序,对应地需要多次移除事件。
// 4. addEventListener() 是目前唯一一个可在“捕获阶段”触发事件监听的方法。
// 5. 在注册事件处理程序时,即使 listener 引用相同,若 useCapture 参数不同,
// 也会被注册多个。
// 6. 多次重复(指三个参数都完全相等时)注册事件处理程序,仅第一次有效。
// 这点与 DOM0 事件处理程序的方式是不同的。
// 7. 除了 DOM 元素,其他对象也有这个接口,比如 window、XMLHttpRequest 等。
// 8. IE8 及以下浏览器不支持,对应的解决方法是 attachEvent() 和 detachEvent() 方法,
// 这是 IE 浏览器特有的。
4. IE 事件处理程序
尽管 IE8 及以下浏览器不支持 addEventListener()
和 removeEventListener()
方法,但它有两个类似的方法来注册或移除事件处理程序,那就是 attachEvent()
和 detachEvent()
。区别是不支持在事件捕获阶段触发,因为 IE8 事件流只含事件冒泡。
语法如下:
// 只接受两个参数
target.attachEvent(ontype, listener)
target.detachEvent(ontype, listener)
ontype
:表示事件属性(字符串),即on + type
,比如onclick
。这点与addEventListener()
中的type
参数是不同的。
listener
:同样接受一个函数作为事件处理程序。请注意listener
函数内部this
将指向window
对象。
举个例子:
function handler(e) {
// this 将会指向 window 对象
// 要处理 this 指向问题很简单,例如 Function.prototype.bind() 等
// do something...
}
// 注册事件处理程序
target.attachEvent('onclick', handler)
// 移除事件处理程序
target.detachEvent('onclick', handler)
// ⚠️ 注意点:
// 1. 注意第一个参数是 on + type 的形式,这点与 DOM2 是不同的。
// 2. 若无特殊处理,事件处理程序内 this 指向 window 对象,这点与 DOM0、DOM2 是不同的。
// 3. 若 Function.prototype.bind() 去处理 this 问题,注意保持事件处理程序引用问题。
// 4. attachEvent() 不支持在捕获阶段触发事件处理程序。
5. 跨浏览器事件处理程序
跨浏览器,就是说要同时兼容 IE 浏览器和现代浏览器。其实上面已经将所有方式都介绍了一遍,写起来就很简单了。
// 注册
function addHandler(el, type, fn) {
if (el.addEventListener) {
el.addEventListener(type, fn, false)
} else if (el.attachEvent) {
el.attachEvent('on' + type, fn)
} else {
el['on' + type] = listener
}
}
// 移除
function removeHandler(el, type, fn) {
if (el.removeEventListener) {
el.removeEventListener(type, fn, false)
} else if (el.detachEvent) {
el.detachEvent('on' + type, fn)
} else {
el['on' + type] = null
}
}
6. DOM3 自定义事件(扩展内容)
前面介绍的,全都是浏览器内置事件,当用户与浏览器发生交互时,事件(对象)就诞生了,它接着会在 DOM 中进行传播,命中目标后会触发相应的时间处理函数。
在 DOM3 Event 标准上,除了在 DOM2 Event 的基础上,新增了很多事件类型,而且它还允许自定义事件。但是自定义事件,需要“手动”触发,即主动调用 dispatchEvent()
方法才可以。
至于用处嘛,假设动态加载脚本,需在加载完成后才能做一些事情,例如配置什么的。那么我们需要监听脚本什么时候加载完,这时候自定义事件就能发挥其作用了。举个例子:
// 创建自定义事件
const customEvent = new CustomEvent('ready')
// 创建元素
const el = document.createElement('script')
el.src = 'https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js'
el.onload = () => el.dispatchEvent(customEvent) // 派发事件
el.onerror = err => { /* 脚本加载失败... */ }
// 注册事件处理程序
el.addEventListener('ready', e => {
// 脚本加载完成后,可以做些配置什么的...
})
// 插入 document
const s = document.getElementsByTagName('script')[0]
if (s && s.parentNode) s.parentNode.insertBefore(el, s)
关于 CustomEvent
的一些语法注意点:
// 1. 为 watch 事件添加监听事件函数
target.addEventListener('watch', function () { /* ... */ }, false)
// 2. 创建 watch 事件,若无需传入指定数据,可直接使用 new Event('watch')
var watchEvent = new CustomEvent('watch',
detail: { /* ... */ }, // 可以是任意值,通常是与本事件相关的一些信息
bubbles: false, // 是否冒泡
cancelable: false // 是否取消默认行为
)
// 3. 手动触发事件
target.dispatchEvent(watchEvent)
// ⚠️ 注意点:
// 1. 前面 1 和 2 的顺序可以调换过来,没关系的。
// 2. 上述我们给事件目标 target(DOM 元素)注册了 watch 事件监听函数,
// 是无法通过鼠标点击或触控等形式去触发事件监听函数的,
// 需要主动触发,即调用 dispatchEvent() 方法。
// 3. 还有 CustomEvent 是有兼容性的,IE 是不支持的,
// 4. 至于兼容性如何处理,暂时不展开讲述。后面有空再另起一文吧。
7. 小结
可能有人会好奇为什么不介绍 DOM1,原因是 DOM1 没有关于事件的新增或改动,因而没提及。
若同时存在 HTML 事件处理程序和 DOM0 处理函数,后者会覆盖掉前者。
DOM2 不会覆盖 HTML 事件处理程序或 DOM0 事件处理程序。为同一事件目标注册多个事件处理程序时,执行次序为:HTML 事件处理程序或 DOM0 > DOM2 或 IE 事件处理程序。跟事件注册先后顺序无关。
若有兴趣想了解 DOM0 ~ DOM3 新增了哪些内容,可看文章。另外附上 WHATWG 的 DOM 标准:DOM Living Standard。
七、总结(上半部分)
就前面关于事件处理程序所有兼容性问题,我们来进一步封装下。
由于要兼容 IE8 的原因,这里并没有使用
class
类的写法。因为经过 Babel 处理,类似Object.assgin()
、Object.defindProperty()
等 ES5 方法在 IE8 及更低版本浏览器压根不支持。
先写个构造函数吧:
function CreateMyEvent({ type, el, fn }) {
this.el = el
this.type = type
this.fn = fn
this.listener = function (e) {
// IE8 DOM0 需要从 window.event 获取事件对象
const ev = e || window.event
// 为 IE8 的事件对象添加以下属性和方法:
// target、currentTarget、stopPropagation()、preventDefault()
if (!ev.stopPropagation) {
ev.target = ev.srcElement
ev.currentTarget = el
ev.stopPropagation = () => {
ev.cancelBubble = true
}
ev.preventDefault = () => {
ev.returnValue = false
}
}
// 统一 this 指向
fn.call(el, ev)
}
}
CreateMyEvent.prototype.addEventListener = function () {
const { type, el, listener } = this
if (el.addEventListener) {
// 为了兼容所有浏览器,这里将 useCapture 设为 false
el.addEventListener(type, listener, false)
} else if (el.attachEvent) {
el.attachEvent('on' + type, listener)
} else {
el['on' + type] = listener
}
}
CreateMyEvent.prototype.removeEventListener = function () {
const { type, el, listener } = this
if (el.removeEventListener) {
el.removeEventListener(type, listener, false)
} else if (el.detachEvent) {
el.detachEvent('on' + type, listener)
} else {
el['on' + type] = null
}
}
一些注意点在注释有标注体现,然后进行实例化并调用,如下:
// 创建实例对象
const myEvent = new CreateMyEvent({
el: document.getElementById('directory'),
type: 'click',
fn: e => {
// 解决以下痛点:
// 1. this 总指向监听事件函数所绑定节点
// 2. e 总会得到事件对象
// 3. IE8 也可以轻松使用 target、currentTarget、stopPropagation()、preventDefault()
// do something...
}
})
// 注册/移除监听器
myEvent.addEventListener()
setTimeout(() => {
myEvent.removeEventListener()
}, 5000)
以上示例部分使用了 ES6 的语法,都 2021 年了,如需兼容 ES5 请放心交给 Babel 吧。(亲测 IE8 下可正常运行)
八、事件委托
除了面试中常被问及,实际应用场景里也是优化性能的一种手段。
前面提到,事件流的传播路径:捕获阶段 -> 目标阶段 -> 冒泡阶段。我们都知道,所有浏览器都支持事件冒泡,但事件捕获并不是都支持的,例如 IE8。
需要注意的是,多数情况下利用事件冒泡行为实现“事件委托”(或称为事件代理)。但其实,捕获阶段也是可以实现事件委托的。可能为了兼容,选择前者居多。
为什么要事件委托?
想象一个生活场景:一本书的目录包含大章节、小章节,每个小章节都会对应一个页码,然后根据页码就可以翻到对应的内容。
如果用程序实现的话,“最笨”的做法是:给每个小章节注册一个点击监听器并实现跳转。似乎也没太大问题,是吗?如果这本书有 1000 个小章节,意味着要注册 1000 个事件监听函数。先不说性能问题,写代码的是不是得疯掉。假设还没疯,哪天产品经理又新增 500 章节,是不是又得改,总有一天会逼疯你的!
如果用“事件委托”怎么做呢?我把监听器注册到目录上。当点击某章节时,利用点击事件的冒泡行为,事件会被传递到目录并命中来触发监听器。而且,这是一劳永逸的事情,无论产品经理如何增删章节,都无需再改动了。这不就有时间摸鱼了对吧。
在 JavaScript 中,页面中事件处理程序的数量与页面整体性能直接相关。原因有很多。首先每个函数都是对象,都占用内存空间,对象越多,性能越差。其次,为指定事件处理程序所需访问 DOM 的次数会先期造成整个页面交互的延迟。只要在事件处理程序时多注意一些方法,就可以改善页面性能。(这段话摘自《JavaScript 高级程序设计》)
举个例子:
Directory
Chapter1
- section1
- section2
Chapter2
- section1
- section2
以上示例中,有多组
、 的目录,想要点击
(即
section
部分)的时候,打开书本对应页面。
根据上述给出的 HTML 示例,事件委托最粗糙、最不灵活、最简单的实现如下:
function delegate() {
const el = document.getElementById('directory')
el.addEventListener('click', e => {
const { page } = e.target.dataset
if (page === undefined) return
// do something...
// 处理业务逻辑,比如:
console.log(`Please turn to page ${page} of the book.`)
})
}
上述示例,只要点击 我们再改一下: 按上述方式封装的好处是方便灵活,一些实现思路或注意事项,在相应位置的注释已标注。 思路就这样,其实很简单,麻烦在于兼容各浏览器而已。若不需要兼容 IE 浏览器,那简直不能太爽了,后面会给出一个简化版。 当然上面还不支持 IE8 浏览器。因为 这里会将本文所有的内容都封装在一起,包括 先给一个可兼容 IE8 的版本。可以轻松地按现代浏览器的方式去注册、移除事件监听器,以及方便处理冒泡、默认行为等。 但前提是,使用 Babel 转换一下以兼容 ES5。 使用方式如下: 哎,丑陋的代码......由于 IE8 不支持类似 ⚠️ 暂不支持添加多个事件监听器,实际中我暂时想不到需要添加多个事件监听器的场景。实现倒是不难,处理起来也很简单,但我感觉没必要。 另外,前面示例是将页面数据定义在 不需要兼容 IE8 的版本如下,将会使用 到这里,好像就完了,改了好几版,想吐血... The end. 才要执行业务逻辑。像点击
/**
* 事件委托
*
* @param {Element} el 事件委托的目标元素
* @param {string} type 事件类型
* @param {Function} fn 监听器
* @param {string} selectors CSS 选择器
*/
function delegate({ el, type, fn, selectors }) {
el.addEventListener(type, e => {
if (!selectors) {
fn.call(el, e)
return // 请注意不要 return false 避免取消默认行为
}
// myClosest() 作用:向上查找最近的 selectors 元素。
// 1. 内置 closest() 方法兼容性较差,不支持 IE 浏览器;
// 2. 内置 closest() 方法内部从 Document 开始检索的,若结合事件委托场景,
// 如果从事件绑定元素(含)开始检索,效果更优。
// 3. 因而,基于 Element.closest() 稍作修改,并添加到 Element 原型上。
if (!Element.prototype.myClosest) {
;(() => {
Element.prototype.myClosest = function (s, root) {
let el = this
let i
// 其实改成 root.querySelectorAll(s) 也行,
// 目前这种写法是为了让 root === el 也正常匹配到,
// 但是回头想一下,这种情况还有必要事件委托吗,对吧!自个看着办吧,问题也不大
const matches = root.parentElement.querySelectorAll(s)
do {
i = matches.length
// eslint-disable-next-line no-empty
while (--i >= 0 && matches.item(i) !== el) {}
} while (i < 0 && (el = el.parentElement))
return el
}
})()
}
// 若匹配不到 selectors 则返回 null
const matchEl = e.target.myClosest(selectors, el)
matchEl && fn.call(matchEl, e)
// 如果像这样绑定 this,请注意:
// 1. fn 的 this 指向 selectors 对应的元素
// 2. e.target 仍指向事件目标
// 3. e.currentTarget 仍指向监听器绑定元素。
// 4. 由于事件对象的 target、currentTarget 属性只读,唯有改变 this 来指向引用 selectors 元素
})
}
delegate({
el: document.getElementById('directory'),
type: 'click',
selectors: 'li',
fn: e => {
const { page } = e.target.dataset
console.log(`Please turn to page ${page} of the book.`)
// other statements...
}
})
e.target
、el.addEventListener
还没兼容处理。请稍等,下一章节结合前面的内容再整合一下。九、最终总结
addEventListener()
、attachEvent()
、事件委托以及兼容问题等等。1. 兼容所有浏览器的版本(包括 IE8)
function CreateMyEvent({ el, type, fn }) {
this.el = el
this.type = type
this.fn = fn
this.listener = function (e) {
// 使得 IE8 中,正常用上 target、stopPropagation() 等属性或方法
const ev = CreateMyEvent.eventPolyfill(e, el)
// 统一 this 指向
fn.call(el, ev)
}
this.added = false // 是否已添加监听器
}
CreateMyEvent.eventPolyfill = function (e, currentTarget) {
// 处理 IE8 兼容性问题
const ev = e || window.event
if (!ev.stopPropagation) {
ev.target = ev.srcElement
ev.currentTarget = currentTarget
ev.stopPropagation = () => {
ev.cancelBubble = true
}
ev.preventDefault = () => {
ev.returnValue = false
}
}
return ev
}
CreateMyEvent.closestPolyfill = function () {
Element.prototype.myClosest = function (s, root) {
let el = this
let i
const matches = root.parentElement.querySelectorAll(s)
do {
i = matches.length
// eslint-disable-next-line no-empty
while (--i >= 0 && matches.item(i) !== el) {}
} while (i < 0 && (el = el.parentElement))
return el
}
}
CreateMyEvent.prototype.addEventListener = function () {
if (this.added) {
console.warn('Please note that you have added event handler before and will not be added again.')
return
}
const { type, el, listener } = this
if (el.addEventListener) {
// 为了兼容所有浏览器,这里将 useCapture 设为 false
el.addEventListener(type, listener, false)
} else if (el.attachEvent) {
el.attachEvent('on' + type, listener)
} else {
el['on' + type] = listener
}
this.added = true
}
CreateMyEvent.prototype.removeEventListener = function () {
const { type, el, listener } = this
if (el.removeEventListener) {
el.removeEventListener(type, listener, false)
} else if (el.detachEvent) {
el.detachEvent('on' + type, listener)
} else {
el['on' + type] = null
}
this.added = false
}
CreateMyEvent.prototype.delegate = function (selectors) {
const { el, fn } = this
if (!selectors) {
this.addEventListener()
return
}
// 在重写 listener 监听器之前,确保移除此前的监听器
if (this.added) console.warn('Please note that the previously registered event handler will be deleted and a new event handler will be added.')
this.removeEventListener()
// 重写监听器
this.listener = function (e) {
const ev = CreateMyEvent.eventPolyfill(e)
// 在 Element 原型上添加 myClosest 方法
if (!Element.prototype.myClosest) CreateMyEvent.closestPolyfill()
const matchEl = ev.target.myClosest(selectors, el)
matchEl && fn.call(matchEl, ev)
}
// 重新注册监听器
this.addEventListener()
}
// 创建实例对象
const myEvent = new CreateMyEvent({
type: 'click',
el: document.getElementById('directory'),
fn: e => {
// 1. e 总是能获取到事件对象。
// 2. 阻止冒泡:e.stopPropagation()
// 3. 取消默认行为:e.preventDefault()
// 4. 唯一要注意的是:
// 当使用事件委托时,this 指向 selectors 对应元素;
// 其他的均指向事件监听器所绑定的元素,即 e.currentTarget。
}
})
// 注册事件监听器
myEvent.addEventListener() // 有效
// 重复注册事件监听器,会被阻止(实际上注册同一个也是无效的)。
myEvent.addEventListener() // 无效
// 移除事件监听器
myEvent.removeEventListener() // 有效
// 移除后,重新注册事件处理程序
myEvent.addEventListener() // 有效
// 事件委托,可传入 selectors 作为参数(实质上也就是注册事件处理程序)
// 不传入 selectors 参数时,相当于 myEvent.addEventListener() 所以无效
myEvent.delegate() // 无效
// 事件委托:内部会先移除上一个事件监听器,在重新注册
myEvent.delegate('li') // 有效
// 事件委托,这将会注册全新的一个事件监听器(同样的上一个会被移除)
myEvent.delegate('li') // 有效
Object.assgin()
、Object.defindProperty()
等 ES5 方法,上面只能使用最原始的构造函数去写了。
data-*
上的,但由于 IE10 及更低版本浏览器并不支持 Element.prototype.dataset
属性,因此也要处理一下。例如:// 也要用 Babel 转换一下
function getDataset(el) {
if (el.dataset) return el.dataset
const attrs = el.attributes
const dataset = {}
for (let i = 0, re1 = /^data-(.+)/, re2 = /-([a-z\d])/gi, len = attrs.length; i < len; i++) {
// data-camel-case to camel-case
const matchName = re1.exec(attrs[i].name)
if (!matchName) continue
// camel-case to camelCase
const name = matchName[1].replace(re2, (...args) => {
return args[1].toUpperCase()
})
// add to dataset
dataset[name] = attrs[i].value
}
return dataset
}
class
写法,会简洁很多。辣鸡 IE...2. 兼容现代浏览器版本(包括 IE9 ~ IE11)
class CreateMyEvent {
constructor({ el, type, fn }) {
this.el = el
this.type = type
this.fn = fn
this.listener = fn.bind(el)
this.added = false // 是否已添加监听器
}
static closestPolyfill() {
Element.prototype.myClosest = function (s, root) {
let el = this
let i
const matches = root.parentElement.querySelectorAll(s)
do {
i = matches.length
// eslint-disable-next-line no-empty
while (--i >= 0 && matches.item(i) !== el) {}
} while (i < 0 && (el = el.parentElement))
return el
}
}
addEventListener() {
if (this.added) {
console.warn('Please note that you have added event handler before and will not be added again.')
return
}
const { type, el, listener } = this
el.addEventListener(type, listener, false)
this.added = true
}
removeEventListener() {
const { type, el, listener } = this
el.removeEventListener(type, listener, false)
this.added = false
}
delegate(selectors) {
const { el, fn } = this
if (!selectors) {
this.addEventListener()
return
}
// 在重写 listener 监听器之前,确保移除此前的监听器
if (this.added) console.warn('Please note that the previously registered event handler will be deleted and a new event handler will be added.')
this.removeEventListener()
// 重写监听器
this.listener = function (e) {
// 在 Element 原型上添加 myClosest 方法
if (!Element.prototype.myClosest) CreateMyEvent.closestPolyfill()
const matchEl = e.target.myClosest(selectors, el)
matchEl && fn.call(matchEl, e)
}
// 重新注册监听器
this.addEventListener()
}
}