JavaScript Event order


常见的事件注册方法可以参看《HTML Forms and Form Elements》章节表单提交事件的例子。

事件流:当触发某个元素的事件时,事件会按照DOM树的结构进行传播,传播过程分为捕获阶段和冒泡阶段。

捕获阶段:从最外层的 document 节点开始,逐层向内/下传播,直到目标元素。

冒泡阶段:从目标元素开始,逐层向外/上传播,直到最外层的 document 节点。

本文从 这篇文章 里翻译主要内容。

问题描述

假设有两个元素:element 1 和 element 2,

元素 1 是元素 2 的父元素,两者都绑定了 onClick 事件处理函数,现在用户点击了元素 2,那么问题来了:

该行为在两个元素上都引起了点击事件,那么哪一个元素上的事件处理函数先执行?

在探讨上面的问题时,先来看一下模型。

Two models

在过去,关于事件执行顺序,Netscape 认为,元素 1 的 event handler 先执行,称为 event capturing 事件捕获。

Event capturing

Microsoft 认为元素 2 的 event handler 先执行,称为 event bubbling 事件冒泡。

Event bubbling

W3C 在这个纷争里选择了中立态度,在 W3C 事件模型里,任何事件首先被捕获,直到事件到达目标元素,接着再冒泡上来。

w3c event model

作为开发人员,可以决定是在捕获阶段,还是冒泡阶段通过 addEventListener() 方法来注册事件处理函数。

该函数最后一个参数如果为 true,表示为 capturing 捕获阶段注册事件 handler,若设为 false,则表示为 冒泡阶段注册事件 handler。

区别举例

下面通过举例说明,不同的事件注册函数设置,会有什么不同结果。

情况一:

element1.addEventListener('click',doSomething2,true)
element2.addEventListener('click',doSomething,false)
  1. click 事件首先开始于 capturing 阶段。事件检查元素 2是否有任何祖先元素,在捕获阶段注册了 event handler。
  2. 找到了!元素 1 的 doSomething2() 被执行。
  3. 事件一直向下传递直到到达目标本身,再没有找到任何 handler for 捕获阶段。接着,事件进入 bubbling 阶段,执行元素 2 为冒泡阶段注册的 doSomething()
  4. 事件再次向上传递,检查目标元素是否有任何祖先元素,在冒泡阶段注册了 event handler。没有!所以没有事都没发生。

情况二:

element1.addEventListener('click',doSomething2,false)
element2.addEventListener('click',doSomething,false)
  1. click 事件开始于 capturing 阶段。事件检查元素 2是否有任何祖先元素,在捕获阶段为 onclick 注册了 event handler。没有!
  2. 事件一直向下传递直到到达目标本身。接着,事件进入 bubbling 阶段,执行元素 2 为冒泡阶段注册的 doSomething()
  3. 事件再次向上传递,检查目标元素是否有任何祖先元素,在冒泡阶段注册了 event handler。
  4. 找到了!元素 1 的 doSomething2() 被执行。

Compatibility with traditional model

在支持 W3C DOM 的浏览器里,一个传统的事件注册如下:

element1.onclick = doSomething2;

被视为是在 bubbling 冒泡阶段的注册。

currentTarget

理解在 capturing 和 bubbling 阶段(if any),目标元素不变,这一点很重要!

在上面的例子里,目标 target 总是保留一个指向元素 2 的 reference.

但是假设:

element1.onclick = doSomething;
element2.onclick = doSomething;

用户点击元素 2,函数 doSomething() 执行了两次,我们怎么知道当前是哪一个 HTML 元素在处理事件呢?

target / srcElement 都不能给出答案,因为他们都指向元素 2(事件最初的来源)

为了解决这个问题,W3C 添加了 currentTarget 属性。它包含一个指向当前在处理事件的 HTML 元素的 reference.

也可以使用关键字 this,用法同 currentTarget 。

不幸的是, Microsoft 事件注册模型中,this 关键字不指向 HTML 元素,也不包含类似 currentTarget 的属性。

relatedTarget

只读属性。比如光标失焦事件,此时 relatedTarget 指的是接收到 focus(光标聚焦的)EventTarget。对于无法聚焦的元素,该属性返回 null.

Turning it off

更多的情况下,你是希望关掉所有的 capturing 和 bubbling,为保持函数之间不要互现干扰。

此外,如果你的文档结构非常复杂,bubbling 花费时间去查找每一个祖先元素。

下面说一下停止冒泡阶段的传递的方法。

IE 8 及以前版本的浏览器:

window.event.cancelBubble = true

在 W3C 模型里:

event.stopPropagation()

跨浏览器:

function doSomething(e) {
  if (!e) var e = window.event;
  e.cancelBubble = true;  // 在不支持该属性的浏览器里这样做,也没事
  if (e.stopPropagation) e.stopPropagation();
}

Event Loop

Event Loop 是一个异步处理结构,用于查询、等待和发送消息和事件。

时间循环的工作方式:负责执行队列中的回调,并将其压入函数调用栈。其中的调用栈(Call Stack ,LIFO 先进后出)会记录所有的函数调用信息。当调用某个函数时,会将其参数与局部变量等压入栈中;在执行完毕后,会弹出栈首的元素。而堆(Heap)则存放了大量的非结构化数据,譬如程序分配的变量与对象。队列(Callback Queue,FIFO 先入先出)则包含了一系列待处理的信息与相关联的回调函数。

每个 JavaScript 运行时都必须包含一个任务队列。

譬如按钮点击或者 HTTP 请求响应都会作为消息存放在任务队列中;需要注意的是,仅当这些事件的回调函数存在时才会被放入任务队列,否则会被直接忽略。

异步任务分类

  • MacroTask(宏任务,一般是用户发起的任务)包含了 setTimeout, setInterval, setImmediate, requestAnimationFrame, I/O, UI rendering 等
  • MicroTask(微任务,一般是操作系统引擎发起的任务)包含了 process.nextTick, Promises, Object.observe, MutationObserver 等

执行优先级

同步任务-》微任务-》宏任务

举例:

console.log(1); // 同步任务

setTimeout(() => {
  console.log(2); // 宏任务
}, 0);

// Promise.then 是异步执行的,而创建 Promise 实例(executor)是同步执行的。
new Promise((resolve, reject) => {
  console.log(3); // 同步任务
  resolve();
  console.log(4); // 同步任务
}).then(() => {
  console.log(5); // 微任务
});
console.log(6); // 同步任务

// 输出:1 3 4 6 5 2

Promise

方式 说明 优点 缺点
同步执行 所有任务组成一个序列,前一个任务结束,才会执行下一个任务 任务不会遗漏 执行总时间长,前序任务会阻塞后续任务
异步执行 所有任务并发执行,谁先执行完毕,谁先响应 执行速度快,程序执行总时间短 早期JS为在异步执行环境下,实现任务按序执行必须层层嵌套回调函数,开发工作量大、逻辑复杂、容易出错

Promise 构造器指定某个任务完成后,根据这个任务执行的不同结果,去执行不同的回调函数(即下一个任务)。

共有三种状态:

  • pending:初始状态,任务还未完成时触发
  • fulfilled:任务完成后触发
  • rejected:任务失败后出发

用例:

function Hello() {
  return new Promise((resolve, reject) => {
    console.log('in Hello function');
    setTimeout(resolve, 1000);
    // setTimeout(reject, 1000); // 失败时调用 reject
  })
}
Hello().then(() =>{
  console.log('from resolve')
}).catch(() => {
  console.log('from reject')
})

事件委托

当为大量HTML元素注册相同的事件,并且事件的处理函数完全相同,可以使用事件委托来优化性能。

怎么做?在这些HTML元素共同的父级元素注册事件。

<ul id="list">
  <li>Item 1</li>
  <li>Item 2</li>
  <li>Item 3</li>
</ul>

<script>
  var list = document.getElementById('list');
  list.addEventListener('click', function(event) {
    var target = event.target;
    if (target.nodeName.toLowerCase() === 'li') {
      console.log(target.textContent);
    }
  });
  // 点击任何一个 li 元素都会触发该事件处理函数
</script>