0%

事件循环 & 微任务、宏任务

浏览器中的 JS 的执行流程NodeJS 中的流程都是遵循事件循环的。理解事件循环的工作方式对于代码优化很重要,有时对于正确的架构也很重要。

事件循环——微任务和宏任务

浏览器中的 JS 的执行流程NodeJS 中的流程都是遵循事件循环的。

一、事件循环

定义

事件循环是 JavaScript 引擎在等待任务、执行任务、以及进入休眠等待更多任务,这三个状态之间转换的无限循环。

引擎的一般流程(循环):

  1. 设置任务
  2. 引擎处理任务(如果有多个任务,则它们会组成一个队列即“宏任务队列”,按照先进先出的原则来处理任务即先进入的任务会被先执行)
  3. 所有任务处理完成,引擎进入休眠等待新任务出现(休眠期间几乎不消耗任何CPU资源),然后转到第1步

注意:

  • 引擎执行任务时永远不会进行渲染(render)。即使任务执行需要很长时间,它也会等到任务完成之后才会绘制对 DOM 的更改。
  • 如果一项任务执行时间过长,则在该任务完成前,浏览器将无法执行后续其它任务。因此,在预设的某个时间段后,浏览器会抛出一个诸如“页面未响应”之类的警告,建议终止这个任务。(这种情况通常发生在有大量复杂计算或导致死循环的程序错误时)

二、拆分CPU过载任务

问题场景:如果 JS 引擎正在执行一个 CPU 过载的任务即该任务需要耗费大量的 CPU 资源和时间。那么在该任务完成前,引擎无法处理其它的DOM相关的任务,例如处理用户事件,这会表现为“网页卡顿很长一段时间,并且无法响应用户的其它交互行为”。这种情况是需要被避免的。

解决方法:这时,我们可以通过将大任务拆分为多个小任务来避免这个问题。每完成一个小任务,就使用setTimeout(延时参数设置为0)方法来再次安排一个小任务,直到所有小任务都被完成。

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
let i = 0;
let start = Date.now();
function count() {
// 一个大任务
for (let j = 0; j < 1e9; j++) {
j++;
}

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

count();

上面的代码块展示了一个耗时很长的大任务,该任务可能会使浏览器显示一个“脚本执行时间过长”的警告。

下面,我们使用setTimeout方法来拆分这个大任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let i = 0;
let start = Date.now();
function count() {
// 小任务 (*)
do {
i++;
} while (i % 1e6 !== 0)

if (i === 1e9) {
alert('Done in ' + (Date.now() - start) + 'ms!');
} else {
setTimeout(count, 0); // (**)
}
}

count();

现在,JS 引擎可以分批处理每个小任务,并且不影响后续其它任务的执行。原因在于:

  1. 引擎首先执行(*)的小任务
  2. 完成一个小任务后,如果该任务不是最后一个小任务,则调用setTimeout来安排一个新的小任务
  3. 执行宏任务队列中的剩余任务,然后重复步骤一、二,直到所有小任务都完成。

可见,关键在于**setTimeout函数,setTimeout会在时间到达时,为 JS 引擎安排一个回调函数事件,该事件会被插入到宏任务队列中**。在此之前,如果有其他事件发生,如用户点击事件,则会被先安排入宏任务队列,并按照先进先出的原则,优先执行,从而避免“脚本执行时间过长”、浏览器“挂起(hang)”的问题。

此外注意:多个嵌套的setTimeout调用在浏览器中的最小延迟为 4ms。因此即使我们设置了延迟为0,但还是至少需要 4ms的延迟。所以如果安排的越早,运行会越快,上述代码可以改写一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let i = 0;
let start = Date.now();
function count() {
if (i < 1e9 - 1e6) {
setTimeout(count); // 安排新的调用
}

// 小任务 (*)
do {
i++;
} while (i % 1e6 !== 0)

if (i === 1e9) {
alert('Done in ' + (Date.now() - start) + 'ms!');
}
}

count();

三、宏任务&微任务

除了宏任务(macrotask),还有微任务(microtask)

微任务仅来自我们的代码:

  1. 由 Promise 创建的 promise 对象的.then/catch/finally的处理程序
  2. await的幕后(因此 await 是 promise 的另一种形式)
  3. **特殊函数queueMicrotask(func)**,它会对func进行排队,以在微任务队列中执行

规则

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

举例如下:

1
2
3
4
5
6
setTimeout(() => alert('3'));

Promise.resolve()
.then(() => alert('2'));

alert('1');

对上述代码,引擎的执行顺序是:

  1. 首先执行同步代码alert('1'),所以首先显示“1”
  2. 然后执行 Promise 中的then中的回调函数,因为then是一个微任务,它会在微任务队列中,等一个宏任务执行结束后,会被立即执行,所以第二个显示的是“2”
  3. 最后异步调用setTimeout中的回调函数会被从宏任务队列中取出执行,所以最后显示的是“3”

注:微任务会在执行任何其他操作(宏任务或渲染)前被完成,因此它确保了微任务之间的应用程序环境基本相同(即没有新的网络数据、没有DOM更改等)。

四、总结事件循环算法

  1. 宏任务队列遵循先进先出原则,首先取出并执行最早的任务

  2. 执行微任务队列中所有的微任务

    • 微任务队列也遵循先进先出原则,首先取出并执行最早的微任务
  3. 渲染(如果DOM改变)

  4. 重复前三个步骤

  5. 直到宏任务队列为空,则 JS 引擎休眠等待新任务

五、安排新的宏任务

可以使用零延迟的setTimeout。它同时还可以将大任务拆分为多个小任务,以便浏览器能够对用户事件作出反应。