Skip to content

Latest commit

 

History

History
293 lines (230 loc) · 16.5 KB

13.历久弥新的事件.md

File metadata and controls

293 lines (230 loc) · 16.5 KB

13.历久弥新的事件

  • 了解事件循环
  • 使用计时器处理复杂任务
  • 使用计时器管理动画
  • 使用事件冒泡和委派
  • 使用自定义事件

深入事件循环

事件循 环不仅仅包含事件队列,而是具有至少两个队列,除了事件,还要保持浏览器执行的其 他操作。这些操作被称为任务,并且分为两类:宏任务(或通常称为任务)和微任务

  • 宏任务
    • 创建主文档对象
    • 解析 HTML
    • 执行主线(或全局)JavaScript 代码
    • 更改当前 URL 以及各种事件,如页面加载、输入、网络事件和定时器事件。
    • 从浏览器的角度来看,宏任务代表一个个离散的、独立工作单元。运行完任务后,浏览器可以继续其他调度,如重新渲染页面的 UI 或执行垃圾回收。
  • 微任务
    • 更小的任务。微任务更新应用程序的状态,但必须在浏览器任务继续执行其他任务之前执行,浏览器任务包括重新渲染页面的 UI。
    • 微任务的案例包括 promise 回调函数、DOM 发生变化等。
    • 微任务需要尽可能快地、通过异步方式执行,同时不能产生全新的微任务。微任务使得我们能够在重新渲染 UI 之前执行指定的行为,避免不必要的 UI 重绘,UI 重绘会使应用程序的状态不连续。

ECMAScript 规范没有提到事件循环。不过,事件循环在 HTML 规范中有详细说明,里面也讨论了宏任务和微任务的概念。ECMAScript 规范提到了处理 promise 回调的功能(类似于微任务)。虽然只有 HTML 规范中定义了事件循环,但其他环境(如 Node.js)也都在使用它。

事件循环基于两个基本原则:

  • 一次处理一个任务。
  • 一个任务开始后直到运行完成,不会被其他任务中断。

在一次迭代中,事件循环将首先检查宏任务队列,如果宏 任务等待,则立即开始执行宏任务。直到该任务运行完成(或者队列为空),事件循环将移动去处理微任务队列。如果有任务在该队列中等待,则事件循环将依次开始执行,完成一个后执行余下的微任务,直到队列中所有微任务执行完毕。注意处理宏任务和微任务队列之间的区别:单次循环迭代中,最多处理一个宏任务(其余的在队列中等待),而队列中的所有微任务都会被处理。

当微任务队列处理完成并清空时,事件循环会检查是否需要更新 UI 渲染,如果是,则会重新渲染 UI 视图。至此,当前事件循环结束,之后将回到最初第一个环节,再次检查宏任务队列,并开启新一轮的事件循环。

  • 两类任务队列都是独立于事件循环的,这意味着任务队列的添加行为也发生在事件循环之外。如果不这样设计,则会导致在执行 JavaScript 代码时,发生的任何事件都将被忽略。正因为我们不希望看到这种情况,因此检测和添加任务的行为,是独立于事件循环完成的。
  • 因为 JavaScript 基于单线程执行模型,所以这两类任务都是逐个执行的。当一个任务开始执行后,在完成前,中间不会被任何其他任务中断。除非浏览器决定中止执行该任务,例如,某个任务执行时间过长或内存占用过大。所有微任务会在下一次渲染之前执行完成,因为它们的目标是在渲染前更新应用程序状态。
  • 浏览器通常会尝试每秒渲染 60 次页面,以达到每秒 60 帧(60 fps)的速度。60fps 通常是检验体验是否平滑流畅的标准,比方在动画里——这意味着浏览器会尝试在 16ms 内渲染一帧。需要注意图 13.1 所示的“更新渲染”是如何发生为何事件循环内的,因为在页面渲染时,任何任务都无法再进行修改。这些设计和原则都意味着,如果想要实现平滑流畅的应用,我们是没有太多时间浪费在处理单个事件循环任务的。理想情况下,单个任务和该任务附属的所有微任务,都应在 16ms 内完成。

在浏览器完成页面渲染,进入下一轮事件循环迭代后,可能发生的 3 种情况。

  • 在另一个 16ms 结束前,事件循环执行到“是否需要进行渲染”的决策环节。因为更新 UI 是一个复杂的操作,所以如果没有显式地指定需要页面渲染,浏览器可能不会选择在当前的循环中执行 UI 渲染操作。
  • 在最后一次渲染完成后大约 16ms,时间循环执行到“是否需要进行渲染”的决策环节。在这种情况下,浏览器会进行 UI 更新,以便用户能够感受到顺畅的应用体验。
  • 执行下一个任务(和相关的所有微任务)耗时超过 16ms。在这种情况下,浏览器将无法以目标帧率重新渲染页面,且 UI 无法被更新。如果任务代码的执行不耗费过多的时间(不超过几百毫秒),这时的延迟甚至可能察觉不到,尤其当页面中没有太多的操作时。反之,如果耗时过多,或者页面上运行有动画时,用户可能会察觉到网页卡顿而不响应。在极端的情况下,如果任务的执行超过几秒,用户的浏览器将会提示“无响应脚本”的恼人信息。(不必担心,在本章的后面我们会介绍如何将复杂的任务分解为不阻塞事件循环的小任务)

请注意事件处理函数的发生频率以及执行耗时。例如,处理鼠标移动(mouse-move)事 件时应当特别小心。因为移动鼠标将导致大量的事件进入队列,因此在鼠标移动的处理 函数中执行任何复杂操作都可能导致 Web 应用的糟糕体验。

仅含宏任务的示例

让我们看看一个简单的 Web 页面,包括如下内容。

  • 全局 JavaScript 代码。
  • 两个按钮以及对应的两个单击处理器(一个按钮一个处理器)。

单一任务队列示例的伪代码

<button id="firstButton"></button>
<button id="secondButton"></button>
<script>
  const firstButton = document.getElementById("firstButton");
  const secondButton = document.getElementById("secondButton");
  firstButton.addEventListener("click", function firstHandler() {
    /*Some click handle code that runs for 8 ms*/
  });
  secondButton.addEventListener("click", function secondHandler() {
      /*Click handle code that runs for 5ms*/
  });
  /*Code that runs for 15ms*/
</script>

上述代码需要发挥一些想象空间,避免添加不必要的聚合代码,我们要求读 者想象以下内容。

  • 主线程 JavaScript 代码执行时间需要 15ms。
  • 第一个单击事件处理器需要运行 8ms。
  • 第二个单击事件处理器需要运行 5ms。

假设有一个手快的用户在代码执行后 5ms 时单击第一个按钮,随后在 12ms 时单击第二个按钮。

同时含有宏任务和微任务的示例

<button id="firstButton"></button>
<button id="secondButton"></button>
<script>
  const firstButton = document.getElementById("firstButton");
  const secondButton = document.getElementById("secondButton");
  firstButton.addEventListener("click", function firstHandler(){
    Promise.resolve().then(() => {
      /*Some promise handling code that runs for 4 ms*/
    });
  /*Some click handle code that runs for 8 ms*/
  });
  secondButton.addEventListener("click", function secondHandler(){
  /*Click handle code that runs for 5ms*/
  });
  /*Code that runs for 15ms*/
</script>
  • 第 5ms 单击 firstButton。
  • 第 12ms 单击 secondButton。
  • firstButton 的单击事件处理函数 firstHandler 需要执行 8ms。
  • secondButton 的单击事件处理函数 secondHandler 需要执行 5ms。

与之前的示例唯一的区别是,在 firstHandler 代码中我们创建立即兑现的 promise, 并需要运行 4ms 的传入回调函数。因为 promise 表示当前未知的一个未来值,因此 promise 处理函数总是异步执行。

firstHandler 函数通过调用 Promise.resolve() 创建一个已兑现的 promise,传入 Promise.resolve()中的回调函数一定会执行。此时创建了一个调用回调函数的微任务。将该微任务置入微任务队列,第一个按钮的单击事件处理器继续执行 8ms。在第 23ms 时重新查看程序执行的任务队列,此时 firstButton 单击处理器执行完成,并移出队列。

只有当微任务队列为空时,事件循环才会开始重新渲染页面。

玩转计时器:延迟执行和间隔执行

在事件循环中执行计时器

<button id="myButton"></button>
<script>
  setTimeout(function timeoutHandler(){
    /*Some timeout handle code that runs for 6ms*/
  }, 10);
  setInterval(function intervalHandler(){
    /*Some interval handle code that runs for 8ms*/
  }, 10);

  const myButton = document.getElementById("myButton");
  myButton.addEventListener("click", function clickHandler(){
    /*Some click handle code that runs for 10ms*/
  });
  /*Code that runs for 18ms*/
</script>

假设某毫无耐心的用户在程序执行 6ms 时快速单击按钮。

setTimeout 函数只到期一次,setInterval 函数则不同,setInterval 会持续执行直到被清除。因此,在第 20ms 时,setInterval 又一次触发。但是,此时间隔计时器的实例已经在队列中等待执行,该触发被中止。浏览器不会同时创建两个相同的间隔计时器。

延迟执行与间隔执行的区别

setTimeout(function repeatMe() {
  /* Some long block of code... */
  setTimeout(repeatMe, 10);
}, 10);
setInterval(() => {
  /* Some long block of code... */
}, 10);

很明显,setTimeout 内的代码在前一个回调函数执行完成之后,至少延迟 10ms 执行(取决于事件队列的状态,等待时间只会大于 10ms);而 setInterval 会尝试每 10ms 执行回调函数,不关心前一个回调函数是否执行。

我们知道当超过时间结束时,无法保证超时回调精准执行。不是像间隔函数那样每 10ms 触发一次,它是重新安排每 10ms 后执行。

处理计算复杂度高的任务

一个长时间运行的任务

<table><tbody></tbody></table>
<script>
  const tbody = document.querySelector("tbody");
  for (let i = 0; i < 20000; i++) { // 创建20000 行
    const tr = document.createElement("tr");
    for (let t = 0; t < 6; t++) {
      const td = document.createElement("td");
      td.appendChild(document.createTextNode(i + "," + t));
      tr.appendChild(td);
    }
    tbody.appendChild(tr);
  }
</script>

我们创建了 240000 个 DOM 节点,创建一个 20000 行、每行 6 列的表格,表格中的每个单元格都包含一个文本节点。这个操作的消耗是惊人的,会导致浏览器挂起一段时间,这段时间内用户无法正常操作

使用一个计时器来中断一个长时间运行的任务

const rowCount = 20000;
const divideInto = 4;
const chunkSize = rowCount / divideInto;
let iteration = 0;
const table = document.getElementsByTagName('tbody')[0];
setTimeout(function generateRows() {
  const base = chunkSize * iteration; // 计算上一次离开的时间
  for (let i = 0; i < chunkSize; i++) {
    const tr = document.createElement('tr');
    for (let t = 0; t < 6; t++) {
      const td = document.createElement('td');
      td.appendChild(
        document.createTextNode(i + base + ',' + t + ',' + iteration)
      );
      tr.appendChild(td);
    }
    table.appendChild(tr);
  }
  iteration++;
  if (iteration < divideInto) setTimeout(generateRows, 0); // 将超时延迟设置为0来表示下一次迭代应该“尽快”执行,但仍然必须在UI更新之后执行
}, 0);

在这个修改的例子中,我们将冗长的操作分解成 4 个小操作,每个操作分别创建 DOM 节点。这些较小的操作不太可能打断浏览器的运行流,注意,假设需要将操作分解成 10 段操作,我们通过设置,通过变量即可控制需要分解操作的数目(rowCountdivideIntochunkSize)。

令人印象深刻的是,可以使用异步的方法让小段的代码适应变化。还有一些工作需要处理,如跟踪当前程序的执行,确保每个代码片段正确执行以及安排每个执行的部分。但除此之外,代码的核心类似于我们之前介绍的内容。

在本例中,我们使用0作为超时时间。如果关注事件循环是如何工作的,就会知道这并不意味着将在 0ms 时执行回调。使用 0,意味着通知浏览器尽快执行回调,但与其他微任务不同,在回调之前可以执行页面渲染。允许浏览器更新 UI,使得 Web 应用程序交互性更强。

13.3 处理事件

this 指向当前处理器注册的元素;而 event.target 指向事件发生的元素。

在祖先元素上代理事件

const table = document.getElementById('someTable');
table.addEventListener('click', function(event){
  if (event.target.tagName.toLowerCase() === 'td') {
    event.target.style.backgroundColor = 'yellow';
  }
});

自定义事件

使用自定义事件

<style>
  #whirlyThing {display: none; }
</style>
<button type="button" id="clickMe">Start</button>
<!--  使用旋转的图片表示正在加载 -->
<img id="whirlyThing" src="whirly-thing.gif" />
<script>
  function triggerEvent(target, eventType, eventDetail) {
    const event = new CustomEvent(eventType, { //  使用 CustomEvent 构造器创建一个新事件
      detail: eventDetail // 通过detail属性为事件对象传入信息
    });
    target.dispatchEvent(event); //  使用内置的dispathEvent方法解除事件绑定
  }
  function performAjaxOperation() {
    triggerEvent(document, 'ajax-start', {url: 'my-url'});
    setTimeout(() => {
      triggerEvent(document, 'ajax-complete');
    }, 5000);
  }
  const button = document.getElementById('clickMe');
  button.addEventListener('click', () => {
    performAjaxOperation();
  });
  document.addEventListener('ajax-start', e => {
    document.getElementById('whirlyThing').style.display = 'inline-block';
    assert(e.detail.url === 'my-url', 'We can pass in event data');
  });
  document.addEventListener('ajax-complete', e => {
    document.getElementById('whirlyThing').style.display = 'none';
  });
</script>

在这个例子中,我通过在前一节中描述的场景中,探讨自定义事件:在 Ajax 操作 过程中显示或隐藏一个动画纸风车图片。引发的操作是单击一个按钮。

使用完全解耦的方式,定义一个名为 ajax-start 的自定义事件,一个名为 ajax-complete 的自定义事件。在事件处理器中分别显示和隐藏纸风车图像。

小结

  • 事件循环任务代表浏览器执行的行为。任务分为以下两类。
    • 宏任务是分散的、独立的浏览器操作,如创建主文档对象、处理各种事件、更改 URL 等。
    • 微任务是应该尽快执行的任务。包括 promise 回调和 DOM 突变。
  • 由于单线程的执行模型,一次只能处理一个任务,一个任务开始执行后不能被另一个任务中断。事件循环通常至少有两个事件队列:宏任务队列和微任务队列。
  • 异步定时器提供延迟执行一段代码的能力,至少延迟指定的毫秒数。
  • 使用 setTimeout 函数在指定的延迟时间后执行回调。
  • 使用 setInterval 函数来启动一个计时器,将尝试在指定的延迟间隔执行回调,直至被清除。
  • 两个函数均返回对应的计时器 ID,通过 clearTimeoutclearInterval 函数,我们可以使用计时器 ID 来取消计时器。
  • 使用计时器,将计算开销很高的代码分解成可管理的、不阻塞浏览器的代码块。
  • DOM 是元素的分层树,发生在一个元素(target)上的事件通常是通过 DOM 进行代理的,有以下两种机制。
    • 事件捕获模式:事件从顶部元素向下传递到目标元素。
    • 事件冒泡模式:事件从目标元素向上冒泡到顶部元素。
  • 当调用事件处理器时,浏览器也会传入一个事件对象。通过该对象的属性可访问发生事件的目标元素。通过处理器,使用 this 关键字引用在处理器上注册过的元素。
  • 通过内置的 CustomEvent 构造函数和 dispatchEvent 方法,创建和分发自定义事件,减少应用程序不同部分之间的耦合。