toFrankie / blog

种一棵树,最好的时间是十年前。其次,是现在。
21 stars 1 forks source link

细读 JS | 事件详解 #264

Open toFrankie opened 1 year ago

toFrankie commented 1 year ago

配图源自 Freepik

本文将会介绍事件、事件流、事件对象、事件处理程序、事件委托、以及兼容 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

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

1. 事件捕获、事件冒泡

上面提到的捕获阶段和冒泡阶段,所对应的就是事件捕获、事件冒泡。

事件冒泡和事件捕获,分别由微软和网景团队提出,这是几乎完全相反的两个概念,是为了解决页面中事件流而提出的。

想象一下:气泡从水中冒出水面的过程,它是从里(底)到外的。事件冒泡跟这个过程很相似,事件对象会从最内层的元素开始,一直向上传播,直到 window 对象。因此冒泡过程如下:

p -> div -> body -> html -> document -> window

注意,并非所有事件都支持冒泡行为,比如 onbluronfocus 等事件。

既然事件捕获与事件冒泡是相反的,捕获过程如下:

window -> document -> html -> body -> div -> p

总的来说,可以简单概括为:冒泡过程是由里到外,而捕获过程则是由外到里。两者刚好相反。

注意,现代浏览器都是从 window 对象开始捕获事件的,冒泡最后一站也是 window 对象。而 IE8 及以下浏览器,只会冒泡到 document 对象。

2. 示例

我们分别给 divp 元素添加了两个事件处理程序,如下:

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. 内置事件类型

在现代浏览器中,内置了很多事件类型:

以上这些都是基于 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 = {
  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 = {
  preventDefault()    // 取消元素默认行为
                      // 仅当 cancelable 为 true 时,调用 preventDefault() 才有效。
                      // IE8 及以下浏览器,请设置 returnValue = false 来取消元素默认行为

  stopPropagation()   // 停止事件对象的传播,后续事件流其他阶段将会被取消。
                      // IE8 及以下浏览器,请设置 cancelBubble = true,来阻止冒泡行为
                      // 注意 IE8 及以下浏览器“有且仅有”事件冒泡过程。

  stopImmediatePropagation() // 用来阻止同一时间的其他监听函数被调用。
                             // 当同一节点同一事件指定多个监听函数时,这些函数
                             // 会根据添加次序依次调用。但只要其中一个监听函数调用了
                             // stopImmediatePropagation() 方法,其他监听函数就不会执行了。
                             // DOM3 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 元素注册事件处理程序:

前两种方式不推荐,后两种就能覆盖 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(事件类型),比如 onclickondblclickonfocusonload 等。

举个例子:

// 注册事件处理程序
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)

举个例子:

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)

举个例子:

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. 小结

若有兴趣想了解 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
  <ol>
    <div class="chapter">Chapter1</div>
    <li data-page="10">section1</li>
    <li data-page="20">section2</li>
  </ol>
  <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.targetel.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()
  }
}

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

The end.