zl程序教程

您现在的位置是:首页 >  后端

当前栏目

细读 JS | 事件详解

JS事件 详解
2023-09-27 14:25:57 时间
前言

本文将会介绍事件、事件流、事件对象、事件处理程序、事件委托、以及兼容 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 事件流则不含捕获阶段。


举个例子

 div id div 

 Division

 p id p Paragraph /p 

 /div 


结合事件流 当我们点击 p 元素 产生一个点击事件。事件对象的捕获阶段的过程 如下

window - document - html - body - div - p

注 到达 p 之前是捕获阶段的所有过程 到达 p 处于目标阶段。


目标阶段过后 是冒泡阶段的过程 如下

p - div - body - html - document - window


因此 事件流过程可以简单绘成如下表格


2.webp.jpg


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 事件处理 不推荐使用


这是最早的事件处理方式 说实话在项目中没见过这种写法。简单了解下即可 不推荐使用。

 div 

 Division

 p onclick inner() Paragraph /p 

 p onclick inner(event) Paragraph /p 

 p onclick console.log( inner ) Paragraph /p 

 /div 

 script 

 function inner() {

 // 事件处理程序中 this 指向 window 对象

 console.log( inner )

 /script 

 ⚠️ 注意点 

 1. 内联式事件处理程序 this 执行 window 对象 

 2. 不要使用全局内置的方法 例如 onclick open() 它会触发 window.open() 而不是自定义的 open() 方法 

 3. 浏览器不会给你传入事件对象 只能手动传入 可以是 window.event 或 event 推荐前者 后者也可 因为处于 window 环境 

 4. HTML 事件处理程序会被 DOM0 事件处理程序覆盖。

 5. 以上种种原因 不推荐使用。事实上也没见过项目这么用了。

-- 


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 高级程序设计》)


举个例子

 !-- 通常将与渲染无关的信息放到 dataset 里面 即 data-* 的形式 -- 

 div id directory 

 Directory

 div class chapter Chapter1 /div 

 li data-page 10 section1 /li 

 li data-page 20 section2 /li 

 /ol 

 div class chapter Chapter2 /div 

 li data-page 30 section1 /li 

 li data-page 40 section2 /li 

 /ol 

 /div 


以上示例中 有多组 ol 、 li 的目录 想要点击 li 即 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. )

}


上述示例 只要点击 div id directory 及其后代元素都会命中事件处理程序 按需求是点击 li 才要执行业务逻辑。像点击 div class chapter 其实是没有实际意义的。而且明显上面的方法并不灵活。


我们再改一下

/**

 * 事件委托

 * 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...

})


思路就这样 其实很简单 麻烦在于兼容各浏览器而已。若不需要兼容 IE 浏览器 那简直不能太爽了 后面会给出一个简化版。


当然上面还不支持 IE8 浏览器。因为 e.target、el.addEventListener 还没兼容处理。请稍等 下一章节结合前面的内容再整合一下。


九、最终总结


这里会将本文所有的内容都封装在一起 包括 addEventListener()、attachEvent()、事件委托以及兼容问题等等。


1. 兼容所有浏览器的版本 包括 IE8


先给一个可兼容 IE8 的版本。可以轻松地按现代浏览器的方式去注册、移除事件监听器 以及方便处理冒泡、默认行为等。


但前提是 使用 Babel 转换一下以兼容 ES5。

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 ) // 有效


哎 丑陋的代码......由于 IE8 不支持类似 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

}


不需要兼容 IE8 的版本如下 将会使用 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()

 // 重新注册监听器

 this.addEventListener()

}


到这里 好像就完了 改了好几版 想吐血...


The end.



都2022年了你不会还没搞懂js中的事件吧 最近在面试时候,发现很多小伙伴对js事件还不太熟悉,比如绑定事件的方式、事件监听、事件委托、事件传播、事件捕获、事件冒泡等等。今天笔者总结下,希望可以帮助到你们。