# 事件循环

浏览器中 JavaScript 的执行流程和 Node.js 中的流程都是基于 事件循环 的。

理解事件循环的工作方式对于代码优化很重要,有时对于正确的架构也很重要。

# 事件循环的概念

事件循环 的概念非常简单。它是一个在 JavaScript 引擎等待任务,执行任务和进入休眠状态等待更多任务这几个状态之间转换的无限循环。

引擎的一般算法:

当有任务时:

  • 从最先进入的任务开始执行。
  • 休眠直到出现任务,然后转到第 1 步

当我们浏览一个网页时就是上述这种形式。JavaScript 引擎大多数时候不执行任何操作,它仅在脚本/处理程序/事件激活时执行。

设置任务 —— 引擎处理它们 —— 然后等待更多任务(即休眠,几乎不消耗 CPU 资源)。

一个任务到来时,引擎可能正处于繁忙状态,那么这个任务就会被排入队列。

多个任务组成了一个队列,即所谓的“宏任务队列”(v8 术语)

例如,当引擎正在忙于执行一段 script 时,用户可能会移动鼠标而产生 mousemove 事件,setTimeout 或许也刚好到期,以及其他任务,这些任务组成了一个队列,如上图所示。

队列中的任务基于“先进先出”的原则执行。当浏览器引擎执行完 script 后,它会处理 mousemove 事件,然后处理 setTimeout 处理程序,依此类推。

# 实例:进度指示

对浏览器脚本中的过载型任务进行拆分的另一个好处是,我们可以显示进度指示。

正如前面所提到的,仅在当前运行的任务完成后,才会对 DOM 中的更改进行绘制,无论这个任务运行花费了多长时间。

如果我们使用 setTimeout 将繁重的任务拆分成几部分,那么变化就会被在它们之间绘制出来。

<div id="progress"></div>

<script>
  let i = 0;

  function count() {

    // 做繁重的任务的一部分 (*)
    do {
      i++;
      progress.innerHTML = i;
    } while (i % 1e3 != 0);

    if (i < 1e7) {
      setTimeout(count);
    }

  }

  count();
</script>

# 实例:在事件之后做一些事情

在事件处理程序中,我们可能会决定推迟某些行为,直到事件冒泡并在所有级别上得到处理后。我们可以通过将该代码包装到零延迟的 setTimeout 中来做到这一点。

menu.onclick = function () {
  // ...

  // 创建一个具有被点击的菜单项的数据的自定义事件
  let customEvent = new CustomEvent("menu-open", {
    bubbles: true
  });

  // 异步分派(dispatch)自定义事件
  setTimeout(() => menu.dispatchEvent(customEvent));
};

# 宏任务与微任务

除了宏任务,还有微任务。微任务仅来自于我们的代码。它们通常是由 promise 创建的:对 .then/catch/finally 处理程序的执行会成为微任务。微任务也被用于 await 的“幕后”,因为它是 promise 处理的另一种形式。

TIP

有一个特殊的函数 queueMicrotask(func),它对 func 进行排队,以在微任务队列中执行,可以使用这个方法将一个任务安排到微任务队列中

每个宏任务之后,引擎会立即执行微任务队列中的所有任务,然后再执行其他的宏任务,或渲染,或进行其他任何操作。

setTimeout(() => alert("timeout"));

Promise.resolve().then(() => alert("promise"));

alert("code");

document.body.onmousemove = () => {
  alert("mouseove");
};

这里的执行顺序是怎样的?

  • code 首先显示,因为它是常规的同步调用。
  • promise 第二个出现,因为 then 会通过微任务队列,并在当前代码之后执行。
  • timeout 最后显示,因为它是一个宏任务需要在下一轮 Event Loop 才会执行。

script alert("code")
promise alert("promise")
render
setTimeout alert("timeout")
render
mousemove alert("mousemove")
render
mousemove alert("mousemove")
render

# 总结

更详细的事件循环算法(尽管与 规范 相比仍然是简化过的):

  • 从 宏任务 队列(例如 “script”)中出队(dequeue)并执行最早的任务。
  • 执行所有 微任务
  • 如果有变更,则将变更渲染出来。
  • 如果宏任务队列为空,则休眠直到出现宏任务。

安排(schedule)一个新的 宏任务:

  • 使用零延迟的 setTimeout(f)。

它可被用于将繁重的计算任务拆分成多个部分,以使浏览器能够对用户事件作出反应,并在任务的各部分之间显示任务进度。 此外,也被用于在事件处理程序中,将一个行为(action)安排(schedule)在事件被完全处理(冒泡完成)后。

安排一个新的 微任务:

  • 使用 queueMicrotask(f)。
  • promise 处理程序也会通过微任务队列。

在微任务之间没有 UI 或网络事件的处理:它们一个立即接一个地执行。 所以,我们可以使用 queueMicrotask 来在保持环境状态一致的情况下,异步地执行一个函数。

Web Workers

除了宏任务切片以外,对于不应该阻塞事件循环的耗时长的繁重计算任务,我们可以使用 Web Workers。 这是在另一个并行线程中运行代码的方式。

async function a() {
  console.log(1);
  await console.log(2);
  console.log(3);
  await console.log(4);
  console.log(5);
  await console.log(6);
  console.log(7);
}

console.log("loop1");
queueMicrotask(() => {
  console.log("loop2");
  queueMicrotask(() => {
    console.log("loop3");
    queueMicrotask(() => {
      console.log("loop4");
      queueMicrotask(() => {
        console.log("loop5");
      });
    });
  });
});

console.log(8);
a();
console.log(9);

setTimeout(() => {
  console.log("sync1");
  setTimeout(() => {
    console.log("sync2");
  });
});
loop1
8
1
2
9
loop2
3
4
loop3
5
6
loop4
7
loop5
sync1
sync2