事件机制总结

342 阅读15分钟

缘由

先前学习事件机制时不求甚解,草草过目后便跳过这部分的学习,后来发现一个有趣的事件机制演示网站,发觉先前了解的知识过于浅显,往往只知其一不知其二,需要重新回顾,于是写下本文作为记录总结,也希望能帮助新入门前端的同学少走弯路,一文梳理好相关知识点。

事件是什么

客户端 JavaScript 程序使用异步事件驱动的编程模型,在这种编程风格下,浏览器会在文档,浏览器或者某些元素或与之关联的对象发生某些值得关注的事情时生成事件

在《JavaScript 权威指南》中如上介绍事件,可能读起来会产生一些困惑,不急,举几个🌰感受一下:当浏览器加载完整个页面时,会触发 onload 事件,在用户点击元素时会触发 onclick 事件,当用户拉伸浏览器或敲击键盘时,同样会触发事件。换句话说,浏览器在等待用户与之交互,然后给出响应。我们关心这些用户的操作的发生,浏览器为这些值得关注事情都设置了一个“关卡”,让程序员能在事件触发时做“拦截”,获取一些必要事件细节来做对应的操作来响应。

相关术语简介

本段内容为各种术语和定义做简单的解释,为保证行文流畅和阅读体验,读者可过目一遍或略过不看,待阅读后文出现困惑时,再回头了解即可。

事件类型

事件类型是一个字符串,表示发生了什么事情。

比如常见的 clickmousemove 等,用于描述用户具体的操作,因为事件类型是字符串所以有时也称其为事件名称。

事件目标

事件目标是一个对象,而事件就发生在该对象上或者事件与该对象有关。

Window、Document 和 Element 元素应该是最常见的事件目标,比如 Window 上的 load<button></button>click 事件,但也有一些事件目标在此之外,比如 worker 对象是 message 的目标,在工作线程向主线程发消息时发生。

事件监听器

事件监听器是一个函数,负责处理或响应事件。

程序通过浏览器注册自己的事件处理程序,指定事件类型和事件目标。当事件目标上发生指定类型的事件时,浏览器则会应用自己的规则,调用事件处理程序,一般称浏览器“触发”、“派发”或“分派”了该事件。

事件对象

事件对象是与特定事件关联的对象,包含有关事件的细节,后文部分段落会简称其为‘’event“。

事件对象会作为事件监听器的参数传入,所有的事件对象都有 typetarget 属性,分别表示事件类型和事件目标。不同的事件会拥有其特有的一组属性,比如鼠标事件拥有鼠标指针的坐标,键盘事件包括按下的键和其按住不放的修饰键的信息。很多其他的事件类型只定义了几个标准属性(包括 typetarget ),对于这类事件,重要的是知道它发生了,而不是事件的细节。

事件传播

事件传播是一个过程,浏览器会决定在这个过程中哪些对象触发事件处理程序。

对于 Window 对象上的 load 或 Worker 对象上的 message 等特定于一个对象的事件,不需要传播。

但对于 HTML 文档中的某些事件,会分为 3 个阶段处理:

  1. 首先会先从 Window 对象开始逐级触发,直至事件目标,此过程称为“捕获阶段”。
  2. 然后派发此事件目标上的事件,此处称为“目标阶段”
  3. 此后再从目标元素触发至 Window 对象,此过程称为“冒泡阶段”,事件监听器的默认在“冒泡”过程中触发。

若觉得文字干涉难懂,看下方配图,或点击此链接查看其过程。

image-20210525160549785.png

有些事件有与之关联的默认动作(default action)。比如点击超链接,会让浏览器跟随链接,加载一个新页面,相关的事件监听器可以调用事件对象中的一个方法 event.preventDefault() 来取消该默认动作。

注册事件方法

DOM 事件分为 3 个级别:DOM0、DOM2 和 DOM3

DOM0 级事件

DOM0 级事件即直接在事件目标上注册事件,是早期浏览器注册事件的方法,现在仍能使用但应该被淘汰,本文对其做介绍,但极不推荐使用该写法。

示例代码1
<!-- 为 btn 注册点击事件 -->
<button onclick="alert(123)"></button>

<!-- 获取 event 对象,注意此处只能用 event 作为事件对象变量 -->
<button id="btn" onclick="console.log(event)"></button>

<!-- 为 btn 注册多个事件,用 ; 分开 -->
<button onclick="alert(123); handleClick()"></button>

<!-- 取消 a 标签默认行为 -->
<a href="https://domevents.dev/" onclick="return false">https://domevents.dev/</a>

上述代码容易污染 HTML 文档,耦合度高,对你的网站绝对是一个噩梦,不推荐该写法!将代码逻辑与内容分离也会让你的站点对搜索引擎更加友好。

示例代码2
const btn = document.getElementById('btn')
btn.onclick = function(event){
  // 输出 event 对象
  console.log(event)
  
  // 阻止捕获和冒泡阶段中当前事件的进一步传播。
  // 但是,它不能防止任何默认行为的发生; 例如,对链接的点击仍会被处理
  event.stopPropagation()
  
  // 阻止监听同一事件的其他事件监听器被调用。
  // 如果多个事件监听器被附加到相同元素的相同事件类型上,当此事件触发时,它们会按其被添加的顺序被调用。
  // 如果在其中一个事件监听器中执行 stopImmediatePropagation() ,那么剩下的事件监听器都不会被调用。
  event.stopImmediatePropagation()
  
  // 默认的动作也不应该照常执行
	event.preventDefault()
  
	// 取消默认行为第二种方式,不推荐使用
  // return false
}

注册事件逻辑,仅需为其对应的属性方法赋值即可,相对于前面的代码,降低了耦合度,但也出现了一个问题,若是要为同一目标元素绑定同类事件时,后者则会覆盖前者。当然,我们也可以自行封装一个函数按顺序包含所有需要覆盖的事件,但终究还是较为麻烦。仅在某些情况下推荐使用,比如需要更好的跨浏览器兼容时。不过现在 IE 浏览器已不再被支持,我们应该面向未来,使用更好的 DOM2 级事件!

在最后一行的 return false 中可以看出,示例代码2 和示例代码1 实际上是一致的,示例代码1 是示例代码2 的简化写法。可以将前者理解为以下形式.

functino(event) {
  with(document) {
    with(this.form || {}) { // 如果有<form>
      with(this) {
        // your code
      }
    }
  }
}
DOM2 级事件

一言以蔽之,就是新增了几个 API,其一是:EventTarget.addEventListener(eventName, callback, options),任何可以作为事件目标的对象,都定义了该方法,可以使用它来注册目标为调用对象的事件处理程序。

其接受 3 个参数:

  1. 事件名称:要注意的是,其不包含前缀 “on”
  2. 回调函数:见名知意,不做介绍
  3. 配置项:可选参数,一般用于控制触发时机,后面会做介绍
    示例代码
    const btn = document.querySelector('#btn')
    btn.addEventListener('click', (event) => {
      console.log('capture');
    }, true)
    
    btn.addEventListener('click', (event) => {
      // 冒泡阶段
      console.log('bubble1')
      // this 指向 btn
      console.log(this);
      
      // 获取 event 对象
      console.log(event);
    }, false) 
    // 第 3 个参数为 boolean 时控制事件监听器的触发时机,false 代表在冒泡阶段触发,反之则是捕获阶段
    // 默认值为 false
    
    btn.addEventListener('click', (event) => {
      // 冒泡阶段
      console.log('bubble2')
    }, false) 
    
    
    // 当 btn 触发 click 时结果如下
    
    // capture
    // bubble1
    // bubble2
    // thisObj
    // eventObj
    

    值得注意的是,addEventListener 并不会相互覆盖,而会根据代码书写的顺序,从上至下执行。对于目标元素,则会先执行捕获阶段,再执行冒泡阶段。可能一些人会对目标阶段的代码执行顺序有疑惑🤔,这里有一篇文章应该能解决你心中所想,点这

    书写代码时,尽量保证先写捕获阶段的代码,再书写冒泡事件代码,即可保证代码时从上至下至下,就无需考虑太多问题。

    addEventListener 的第三个参数也可以传入一个对象,该对象长的像这样:

    {
      capture: false,
      once: false,
      passive: false
    }
    

    其中 capture 就是用来决定事件监听器的执行时机的,不再赘述。

    once 很好理解,表示该事件监听器最多调用一次,其会在被调用后移除。

    passive 当其设置为 true 时,表示其永远不会调用 preventDefault() ,但并不能保证程序员不会在代码里加上这个函数,当 passivetrue 而仍然调用了 preventDefault() 时,会在控制台抛出警告。

    可能现在你不理解为什么要表明其永远不会调用 preventDefault() ,看到后文你会知道答案。

    如果移除事件监听器,对于 DOM0 级事件而言,只需要删除响应代码或将对应书写方法置为 undefined 即可,对于 DOM2 级事件,使用 EventTarget.removeEventListener(eventName, callback[, options]) 即可。

    很明显,仅需提供相同的 eventNamecallback 就可以达到移除事件监听器的目的,但如果使用第三个可选参数呢?下面举个例子🌰

    EventTarget.addEventListener("mousedown", handleMouseDown, true);
    
    // 现在思考下面两个 removeEventListener(),哪一个会成功移除呢?
    EventTarget.removeEventListener("mousedown", handleMouseDown, false);    
    EventTarget.removeEventListener("mousedown", handleMouseDown, true);     
    

    答案是第二个,当使用 removeEventListener 时,其检查的是 capture 标志,该标志必须与先前 addEventListener 匹配。

    现在再看下面这个例子:

    EventTarget.addEventListener("mousedown", handleMouseDown, { passive: true });
    
    EventTarget.removeEventListener("mousedown", handleMouseDown, { passive: true });     // 成功
    EventTarget.removeEventListener("mousedown", handleMouseDown, { capture: false });    // 成功
    EventTarget.removeEventListener("mousedown", handleMouseDown, { capture: true });     // 失败
    EventTarget.removeEventListener("mousedown", handleMouseDown, { passive: false });    // 成功
    EventTarget.removeEventListener("mousedown", handleMouseDown, false);                 // 成功
    EventTarget.removeEventListener("mousedown", handleMouseDown, true);                  // 失败
    

    只有当 capturetrue 时移除失败,其余情况都是成功的,所以只有 captrue 会影响 removeEventListener

    所以如果同一个监听事件分别为“捕获”与“冒泡”注册过一次,我们需要为它们分别移除,两者之间不会相互干扰。

    tips

    一个 EventTarget 上的 EventListener 被移除之后,如果此事件正在执行,会立即停止。

    还剩下最后一个 API,那就是 dispatchEvent ,这个 API 用的比较少,其接受一个参数,为event 对象,作用是向一个指定的目标元素派发一个事件,并以合适的顺序同步调用目标元素相关的事件处理函数。注意这个同步调用,其与原生事件不同,原生事件是由 DOM 派发的,并通过 event-loop 异步调用处理程序。在调用 dispatchEvent 后,所有监听该事件的事件处理程序将在代码继续执行并返回。

    示例代码
    const btn = document.querySelector('#btn')
    btn.addEventListener('click', (event) => {
      console.log('bubble')
    }) 
    // 看起来没有任何问题
    btn.dispatchEvent('click')
    

    但实际运行起来却发生了错误:

    image-20210525202909407.png

    原因是参数类型不为 Event,看来得多下些功夫了,把示例代码改一改。

    const btn = document.querySelector('#btn')
    btn.addEventListener('click', (event) => {
      console.log('event', event);
    })
    // 这里介绍一下 new Event(type, options)
    // 其接受两个参数:
    //  - type<string> 事件名称
    //  - options<object> 可选的配置项对象
    //    - bubbles<boolean> 表示该事件是否冒泡
    //    - cancelable<boolean> 表示该事件能否被取消
    //    - composed<boolean> 表示事件是否会在影子 DOM 根节点之外触发监听器
    // 看下面的例子你就应该明白如何使用了
    const clickEvent = new Event('click', { 
      bubbles: true 
    });
    setTimeout(() => {
      btn.dispatchEvent(clickEvent)
    }, 1000)
    

    一切正常,没有报错,但是通过 dispatchEvent 输出的 event 对象相对于少了很多属性。看到最后一行的__proto__,大概明白了什么。

    image-20210525205217203.png

    原来是事件对象的原型不一致,MouseEvent 相对于 Event 加入了更多的属性,如果想要 dispatchEvent 也能获取到 MouseEvent ,则必须再修改一次原先的示例代码,没错,就是把 new Event() 改为 new MouseEvent(),接下来会发现参数一样多了,但是仍然有一些异样,很多参数都变成了 0 或者 false

    image-20210527172538714.png

    不过也能很快反应过来,既然是代码触发而非用户点击的,又怎么会有点击时才有的信息呢?这些信息都变成了缺省值。那如果我们想要设定对应的参数来模拟真实事件呢?比如说模拟点击一个 div 的上下左右四个角等等,这听起来有点“无理取闹”,不过确实可以满足这种“无理”的需求。欢迎 CustomEvent 出场!

    CustomEvent 与先前各类 Event 的使用大同小异,区别主要在于其第二个参数:

    {
    	detail<any>, // 重点在这里
    	bubbles<boolean>,
    	cancelable<boolean>
    }
    

    关于 bubbles && cancelable 我们已经很熟悉,这个 detail 什么呢?来份示例代码展示一下:

    const btn = document.querySelector('#btn')
    btn.addEventListener('click', (event) => {
      console.log('event', event);
    })
    
    const clickEvent = new CustomEvent('click', {
      detail: {
      	pageX: '15px'
      },
    	someOfOthers: {
      	sth: 1
      }
    });
    btn.dispatchEvent(clickEvent)
    

    image-20210528103448070.png

    发现多了一个 detail 属性,含有我们加入的信息。这样就解决了自定义各种细致的事件的问题。

    DOM3 级事件

    DOM3 在 DOM2 的基础上添加了更多的事件类型,同时也允许开发人员自定义一些事件。这里简单介绍一下。

    • UI事件,当用户与页面上的元素交互时触发,如:load、scroll

    • 焦点事件,当元素获得或失去焦点时触发,如:blur、focus

    • 鼠标事件,当用户通过鼠标在页面执行操作时触发如:dblclick、mouseup

    • 滚轮事件,当使用鼠标滚轮或类似设备时触发,如:mousewheel

    • 文本事件,当在文档中输入文本时触发,如:textInput

    • 键盘事件,当用户通过键盘在页面上执行操作时触发,如:keydown、keypress

    • 合成事件,当为IME(输入法编辑器)输入字符时触发,如:compositionstart

    • 变动事件,当底层DOM结构发生变化时触发,如:DOMsubtreeModified

    业务场景

    说了这么多,还是要落实到业务中去,看一下业务场景。

    事件委托

    我们为单个目标元素绑定事件时,直接 addEventListener 一把梭就完事了,那为 100 个,1000 个目标元素呢?会不会产生什么问题?当然,我们可以加个 for 循环轻松搞定😉,但对于浏览器可就不轻松了。我们知道对象会占用内存,每个函数都是一个对象,假如要为 1000 个目标元素绑定事件,内存消耗大大提高。

    我们可以利用事件的冒泡原理,为外层的元素绑定对应的事件类型,则内层元素的响应的事件触发会冒泡至外层,现在唯一的麻烦在于判断究竟是哪一个子元素被触发事件,幸好 Event 对象给我们了一个 target 属性,让我们能够快速定位目标元素!下面情况示例代码:

     <ul>
        <li id="li1">1</li>
        <li id="li2">2</li>
        <li id="li3">3</li>
        <li id="li4">4</li>
      </ul>
    
    const ul = document.querySelector('ul')
    ul.addEventListener('click', event => {
      console.log('target', event.target);
    })
    

    image-20210528115622265.png

    以上是点击 ul 各个部位的反馈效果,在这里,能够很方便的通过 target 来控制想要的结果。

    可别急着使用,事件委托也有它天然的缺陷,其基于冒泡原理实现,也必然受限于冒泡,对于一些不冒泡的事件,如 focus && blur 等,就不可以使用。并且建议就近委托,否则可能被某层阻止掉。

    PASSIVE

    不知道各位读者还记得先前介绍 addEventListener 时,有过这么一个 passive 的配置项,当其被设置为 true 时,表示该事件监听器永远不会调用 preventDefault() ,当时卖了个关子,现在给大家解惑。

    根据规范,passive 的默认值始终为 false,但是,这引入了处理某些触摸事件(以及其他)的事件监听器在尝试处理滚动时阻止浏览器的主线程的可能性,从而导致滚动处理期间性能可能大大降低

    看文字可能不好理解,这里有个视频,相信看完之后了然于胸,点这(需要科学上网)。

    当我们在滑动页面的时候,浏览器并不知道我们是否调用了 preventDefault ,所以会执行事件监听器并检测,这个过程通常需要一定耗时,浏览器会等待事件监听器的完成再进行滑动,这就造成了用户体验的不丝滑。对于这些可能会影响用户体验的事件监听器,需要将 passive 显示设置为 true,通知浏览器跳过检查,放行主线程。

    你不需要当心 scroll 事件的 passive 值。由于无法取消,因此事件监听器无法阻止页面呈现。

    现在,哪怕你在事件监听器里写上了死循环,浏览器也能够正常处理页面的滑动!

    现在的浏览器基本对 passive 兼容,当然也可以使用下列代码来检测浏览器是否能够正确识别 passive

    // Test via a getter in the options object to see if the passive property is accessed
    var supportsPassive = false;
    try {
      var opts = Object.defineProperty({}, 'passive', {
        get: function () {
          supportsPassive = true;
        },
      });
      window.addEventListener('testPassive', null, opts);
      window.removeEventListener('testPassive', null, opts);
    } catch (e) {}
    

    完结撒花

    对浏览器事件的介绍就到此结束了,希望本文能让读者对事件能够有更多的了解。当然,本文也肯定有纰漏之处,欢迎各位在评论区留言指正!

    参考文献

    [0]《JavaScript 权威指南》

    [1] Chrome 89 更新事件触发顺序

    [2] passive

    [3] 移动端Web界面滚动性能优化: Passive event listeners

    [4] JavaScript事件机制