事件循环

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

事件循环的核心是一个无限循环,它不断地检查队列中的任务,并将它们依次执行。 如果队列中有任务,事件循环就会从队列中取出一个任务并执行它,然后继续检查队列中的下一个任务。 如果队列为空,事件循环就会等待新的任务到来。

任务示例:

  • 当外部脚本 <script src="..."> 加载完成时,任务就是执行它。
  • 当用户移动鼠标时,任务就是派生出 mousemove 事件和执行处理程序。
  • 当安排的(scheduled)setTimeout 时间到达时,任务就是执行其回调。

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

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

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

alt text

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

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

两个细节:

  1. 引擎执行任务时永远不会进行渲染(render)。如果任务执行需要很长一段时间也没关系。仅在任务完成后才会绘制对 DOM 的更改。
  2. 如果一项任务执行花费的时间过长,浏览器将无法执行其他任务,例如处理用户事件。因此,在一定时间后,浏览器会抛出一个如“页面未响应”之类的警报,建议你终止这个任务。这种情况常发生在有大量复杂的计算或导致死循环的程序错误时。

示例 1

假设我们有一个 CPU 过载任务。

例如,语法高亮(用来给本页面中的示例代码着色)是相当耗费 CPU 资源的任务。为了高亮显示代码,它执行分析,创建很多着了色的元素,然后将它们添加到文档中 —— 对于文本量大的文档来说,需要耗费很长时间。

当引擎忙于语法高亮时,它就无法处理其他 DOM 相关的工作,例如处理用户事件等。它甚至可能会导致浏览器“中断(hiccup)”甚至“挂起(hang)”一段时间,这是不可接受的。

我们可以通过将大任务拆分成多个小任务来避免这个问题。高亮显示前 100 行,然后使用 setTimeout(延时参数为 0)来安排(schedule)后 100 行的高亮显示,依此类推。

为了演示这种方法,简单起见,让我们写一个从 1 数到 1000000000 的函数,而不写文本高亮。

如果你运行下面这段代码,你会看到引擎会“挂起”一段时间。对于服务端 JS 来说这显而易见,并且如果你在浏览器中运行它,尝试点击页面上其他按钮时,你会发现在计数结束之前不会处理其他事件。

let i = 0;

let start = Date.now();

function count() {

  // 做一个繁重的任务
  for (let j = 0; j < 1e9; j++) {
    i++;
  }

  alert("Done in " + (Date.now() - start) + 'ms');
}

count();

如果我们将这个任务拆分成多个小任务,就可以避免浏览器“挂起”。我们可以使用 setTimeout 来安排下一个任务。

let i = 0;
let start = Date.now();
function count() {
  // 做一个繁重的任务
  for (let j = 0; j < 1e8; j++) {
    i++;
  }

  // 安排下一个任务
  if (i < 1e9) {
    setTimeout(count, 0);
  } else {
    alert("Done in " + (Date.now() - start) + 'ms');
  }
}
count();

这样,浏览器就可以在每个小任务之间处理其他事件,例如用户点击按钮或滚动页面。 setTimeout的作用是将任务放入任务队列中,等待下一次事件循环执行。通过这种方式,我们可以避免长时间的阻塞,提高页面的响应性。

示例 2

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

<script>

  function count() {
    for (let i = 0; i < 1e6; i++) {
      i++;
      progress.innerHTML = i;
    }
  }

  count();
</script>

在上面的代码中,count 函数会直接更新页面上的进度信息,但由于它是一个长时间运行的任务,浏览器会在执行期间无法响应用户的其他操作。 我们可以使用 setTimeout 来分割任务,使得浏览器可以在每次更新后处理其他事件。

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

  let i = 0;

  function count() {
    // 做一个繁重的任务
    for (let j = 0; j < 1e5; j++) {
      i++;
      progress.innerHTML = i;
    }

    // 安排下一个任务
    if (i < 1e6) {
      setTimeout(count, 0);
    }
  }

  count();
</script>

示例 3

在事件之后做一些事情

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

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

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

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

宏任务和微任务

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

还有一个特殊的函数 queueMicrotask(func),它对 func 进行排队,以在微任务队列中执行。

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

微任务示例

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

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

alert("code");

在上面的代码中,执行顺序如下:

  1. 执行 setTimeout,将其回调放入宏任务队列。
  2. 执行 Promise.resolve(),将其回调放入微任务队列。
  3. 执行 alert("code"),显示代码的警告框。
  4. 执行微任务队列中的所有任务,显示 alert("promise")
  5. 执行宏任务队列中的下一个任务,显示 alert("timeout")

因此,最终的执行逻辑是:

  1. 整个脚本算作一个宏任务
  2. 执行 setTimeout → 把回调放到宏任务队列
  3. 执行 Promise.resolve().then(…) → 把回调放到微任务队列
  4. 执行 alert(“code”) (同步,立即弹出)
  5. 当前宏任务结束,开始清空微任务队列 → 执行 .then 回调 alert(“promise”)
  6. 然后才去下一个宏任务(即 setTimeout 的回调)→ alert(“timeout”)

微任务会在执行任何其他事件处理,或渲染,或执行任何其他宏任务之前完成。

这很重要,因为它确保了微任务之间的应用程序环境基本相同(没有鼠标坐标更改,没有新的网络数据等)。

如果我们想要异步执行(在当前代码之后)一个函数,但是要在更改被渲染或新事件被处理之前执行,那么我们可以使用 queueMicrotask 来对其进行安排(schedule)。

这是一个与前面那个例子类似的,带有“计数进度条”的示例,但是它使用了 queueMicrotask 而不是 setTimeout。你可以看到它在最后才渲染。就像写的是同步代码一样:

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

  let i = 0;

  function count() {
    // 做一个繁重的任务
    for (let j = 0; j < 1e5; j++) {
      i++;
      progress.innerHTML = i;
    }

    // 安排下一个任务
    if (i < 1e6) {
      queueMicrotask(count);
    }
  }

  count();
</script>

总结

宏队列微队列
任务类型script, setTimeout, setInterval, I/O 操作等Promise.then, queueMicrotask 等
执行时机每个宏任务结束后在每个宏任务之前执行微队列内所有任务
执行顺序先进先出(FIFO)先进先出(FIFO)

例子

console.log(1);

setTimeout(() => console.log(2));

Promise.resolve().then(() => console.log(3));

Promise.resolve().then(() => setTimeout(() => console.log(4)));

Promise.resolve().then(() => console.log(5));

setTimeout(() => console.log(6));

console.log(7);

下面这个例子的输出顺序是: 1 7 3 5 2 6 4

  1. console.log(1) 输出 1。
  2. setTimeout 将回调放入宏任务队列。
  3. Promise.resolve().then(() => console.log(3)) 将回调放入微任务队列。
  4. Promise.resolve().then(() => setTimeout(() => console.log(4))) 将回调放入微任务队列,但它的回调是一个宏任务,所以它会在微任务队列中被处理。
  5. Promise.resolve().then(() => console.log(5)) 将回调放入微任务队列。
  6. setTimeout 将回调放入宏任务队列。
  7. console.log(7) 输出 7。

当脚本执行完后,任务如下图,O 为已打印,H 为宏任务,M 为微任务:

alt text

脚本执行完后我们要执行微任务队列中的所有任务:

  • 执行 console.log(3) 输出 3。
  • 执行 setTimeout(() => console.log(4)) 把回调放入宏任务队列。
  • 执行 console.log(5) 输出 5。

alt text

最后执行宏任务队列中的任务:

  • 执行 console.log(2) 输出 2。
  • 执行 console.log(6) 输出 6。
  • 执行 console.log(4) 输出 4。

所以最终输出顺序是: 1 7 3 5 2 6 4