js异步内容——事件循环机制

64 阅读12分钟

零、前言

遇到一道题目,搞了很久没搞懂,写下了这篇文章。题目是这样的

async function async1() {
  console.log("async1 start");
  await async2();
  console.log("async1 end");
}

async function async2() {
  console.log("async2");
}

console.log("script start");

setTimeout(function () {
  console.log("setTimeout0");
}, 0);

setTimeout(function () {
  console.log("setTimeout2");
}, 300);

setImmediate(
  () => console.log("setImmediate")
);

process.nextTick(() => console.log("nextTick1"));

async1();

process.nextTick(() => console.log("nextTick2"));

new Promise(function (resolve) {
  console.log("promise1");
  resolve();
  console.log("promise2");
}).then(function () {
  console.log("promise3");
});

console.log("script end");

然后问,依次输出的是什么?

答案为:

// script start
// async1 start
// async2
// promise1
// promise2
// script end
// nexttick1
// nexttick2
// async1 end
// promise3
// settimeout0
// setImmediate
// setTimeout2

详细解释为以下:

  1. 首先,执行全局代码,输出 "script start"。

  2. 接下来,遇到第一个 setTimeout,但由于延迟时间为 0,它会被放入宏任务队列,不会立即执行。

  3. 然后,遇到第二个 setTimeout,延迟时间为 300ms,也会被放入宏任务队列,不会立即执行。

  4. 遇到 setImmediate,它会被放入宏任务队列。

  5. 遇到第一个 process.nextTick,它会被放入当前微任务队列。

  6. 接着,执行 async1() 函数,进入 async1 的执行。

    • 输出 "async1 start"。
    • 遇到 await async2(),调用 async2 函数。
    • 输出 "async2"。
    • async2 执行完毕后,回到 async1,输出 "async1 end"。
  7. 执行第二个 process.nextTick,它会被放入当前微任务队列。

  8. 遇到 new Promise,立即执行 Promise 构造函数内的代码。

    • 输出 "promise1"。
    • 执行 resolve(),但不会立即执行后续的 .then 回调。
    • 输出 "promise2"。
  9. 输出 "script end"。

  10. 事件循环进入微任务阶段,依次执行微任务队列中的任务:

    • 执行第一个 process.nextTick 回调,输出 "nextTick1"。
    • 执行第二个 process.nextTick 回调,输出 "nextTick2"。
    • 执行 Promise 的 .then 回调,输出 "promise3"。
  11. 事件循环进入宏任务阶段,依次执行宏任务队列中的任务:

    • 执行第一个 setTimeout 的回调,输出 "setTimeout0"。
    • 执行 setImmediate 的回调,输出 "setImmediate"。
    • 执行第二个 setTimeout 的回调,输出 "setTimeout2"。

最终自己做了个动画展示放在这里,为了让后续的读者读完这篇文章后再来看动画,能完全理解事件循环机制相关题目的运行原理和解题思路。

也相信你看完这篇文章也能慢慢理解事件循环机制的内容

事件循环机制演示——高清.gif

一、什么是事件循环机制

JavaScript的事件循环(Event Loop)机制是用于处理异步代码执行的机制。它确保了JavaScript代码的顺序执行,同时处理异步操作,使得在异步操作完成后能够正确执行回调函数。

事件循环机制由以下几个主要组成部分组成:

  1. 调用栈(Call Stack) :调用栈是一个用于存储函数调用的数据结构。当一个函数被调用时,它会被推入调用栈中,当函数执行完成后,它会被从调用栈中弹出。JavaScript是单线程执行的,因此调用栈保证了函数的顺序执行。
  2. 消息队列(Message Queue) :消息队列是用于存储待执行的回调函数的队列。当异步事件完成(如定时器、用户交互、网络请求等),相应的回调函数被放入消息队列中等待执行。
  3. 事件循环(Event Loop) :事件循环是负责处理调用栈和消息队列的机制。它不断地从消息队列中取出待执行的回调函数,推入调用栈中执行。当调用栈为空时,事件循环会等待新的事件进入消息队列。

二、事件循环机制的过程以及场景

2.1 过程

事件循环的执行过程如下:

  1. 从消息队列中取出一个待执行的回调函数。
  2. 将该回调函数推入调用栈中执行。
  3. 如果在回调函数执行过程中遇到了异步操作(如定时器、网络请求等),会将相应的回调函数放入消息队列中等待执行。
  4. 当调用栈为空时,事件循环会继续从消息队列中取出下一个待执行的回调函数。

这样,事件循环机制确保了异步操作的回调函数能够在适当的时机执行,而不会阻塞主线程。

2.2 场景

下面是一个简单的示例,展示了事件循环的执行过程:

console.log('1');
setTimeout(() => console.log('2'), 0);
console.log('3');

// 输出:1 3 2

在上面的示例中,首先输出1和3,然后使用setTimeout函数设置一个定时器,回调函数被放入消息队列中。由于定时器时间设为0,所以回调函数会立即执行,但是在事件循环的下一个迭代中。因此,最后输出2。

三、微任务与宏任务

在JavaScript的事件循环机制中,除了消息队列中的任务(也称为宏任务),还有一个特殊的队列,用于存储微任务。微任务具有比宏任务更高的优先级,并且会在宏任务执行结束之后、下一个宏任务开始之前立即执行。

微任务和宏任务的主要区别在于它们的调度时机和执行顺序。

宏任务(Macro Task) :宏任务是由 JavaScript 引擎(宿主环境)提供的任务。宏任务包括整体的 script 代码块、setTimeout、setInterval、I/O 操作、UI 渲染等。宏任务会依次进入消息队列,并在事件循环的每个迭代中执行一个宏任务。

微任务(Micro Task) :微任务是一个在当前任务执行结束后立即执行的任务。微任务的触发时机在每个宏任务执行结束后,会检查是否存在微任务队列,如果存在,则依次执行微任务队列中的任务。微任务包括 Promise 的回调函数、MutationObserver 的回调函数、process.nextTick 等。

以下是一个示例,展示了微任务和宏任务的执行顺序:

console.log('1');

setTimeout(() => {
  console.log('2 - Macro Task');
}, 0);

Promise.resolve().then(() => {
  console.log('3 - Micro Task');
});

console.log('4');

// 输出:1 4 3 2 - Macro Task

在上面的示例中,首先输出1和4。然后,setTimeout函数设置了一个定时器,该回调函数作为宏任务进入消息队列。接着,使用Promise.resolve().then()创建了一个微任务,并立即执行,输出3。最后,宏任务队列中的任务执行,输出2。

理解微任务和宏任务的区别以及它们的执行顺序,对于处理异步代码中的执行顺序和优先级非常重要。

四、微任务与宏任务优先级

4.1 微任务

4.1.1 微任务的种类

当谈到微任务时,通常提到的微任务包括以下几种:

  1. Promise回调函数:当使用Promise对象时,通过调用then方法可以注册一个回调函数,该回调函数将作为微任务执行。例如:Promise.resolve().then(() => { /* 微任务回调函数 */ });
  2. process.nextTick:在Node.js环境中,process.nextTick方法可以将回调函数作为微任务执行,它会在当前操作完成后立即执行。例如:process.nextTick(() => { /* 微任务回调函数 */ });
  3. queueMicrotaskqueueMicrotask函数是一个在ES2020中引入的全局函数,它可以将回调函数作为微任务添加到微任务队列中。例如:queueMicrotask(() => { /* 微任务回调函数 */ });

4.1.2 微任务的优先级

这些微任务的优先级顺序如下:

  1. process.nextTickqueueMicrotask:在浏览器和Node.js环境中,它们的优先级相同,并且高于其他微任务。
  2. Promise回调函数:Promise回调函数的优先级低于process.nextTickqueueMicrotask,但高于其他异步任务,如宏任务。

需要注意的是,微任务的执行顺序是按照它们被添加到微任务队列的顺序执行的。当一个微任务执行时,如果又产生了新的微任务,它们会被添加到微任务队列的末尾,并在之后的事件循环迭代中依次执行。

4.2 MutationObserver函数

MutationObserver 是一种用于监视 DOM 变化的异步 API,它可以注册一个回调函数,当指定的 DOM 节点或其子节点发生变化时,该回调函数将被触发执行。MutationObserver 的回调函数执行时机与事件循环的执行阶段无关,而是在浏览器执行渲染步骤之后下一次事件循环之前执行

  1. MutationObserver回调函数:MutationObserver是用于监听DOM变化的API,当监听到DOM的变化时,注册的回调函数将会作为微任务执行。例如:
const observer = new MutationObserver(() => {
  /* 微任务回调函数 */
});
observer.observe(document.body, { childList: true });

具体来说,MutationObserver 的回调函数会在以下情况下被调用:

  1. 在当前宏任务执行完毕后,如果有待处理的 DOM 变化,MutationObserver 的回调函数会被添加到微任务队列中,并在下一次事件循环的微任务执行阶段被调用。
  2. 如果 MutationObserver 的回调函数本身导致了 DOM 的更改(例如,修改了 DOM 属性),则这些变化将在下一次事件循环的渲染阶段被应用,然后 MutationObserver 的回调函数会被触发执行。

总结:

  1. 该回调函数既不属于微任务也不属于宏任务
  2. 该回调函数的执行时间为所有微任务结束后下一个宏任务执行前,即:在微任务与宏任务之间
  3. 本质上,MutationObserver 的回调函数的执行时机与微任务和宏任务的优先级无关,它在当前宏任务执行完毕后、渲染阶段之前被调用。这使得它可以用于监视 DOM 变化并采取相应的操作。

4.3 宏任务

4.3.1 宏任务的种类

在 JavaScript 中,常见的宏任务包括以下几种:

  1. setTimeout:通过 setTimeout 函数可以注册一个在指定延迟后执行的回调函数作为宏任务。例如:setTimeout(() => { /* 宏任务回调函数 */ }, delay);
  2. setInterval:使用 setInterval 函数可以按照指定的时间间隔重复执行回调函数作为宏任务。例如:setInterval(() => { /* 宏任务回调函数 */ }, interval);
  3. setImmediate:在Node.js环境中,setImmediate 函数用于将回调函数作为宏任务推迟到下一次事件循环的检查阶段执行。例如:setImmediate(() => { /* 宏任务回调函数 */ });
  4. requestAnimationFramerequestAnimationFrame 是一个用于优化动画效果的 API,它会在浏览器的下一次重绘之前调用回调函数。例如:requestAnimationFrame(() => { /* 宏任务回调函数 */ });
  5. I/O 操作:包括文件读写、网络请求等异步 I/O 操作都是宏任务。

4.3.2 宏任务的优先级

这些宏任务的执行顺序如下:

  1. I/O 操作:由于 I/O 操作涉及与外部系统的交互,其执行时间较长,因此它们通常作为优先级较高的宏任务执行。
  2. setImmediate:在 Node.js 环境中,setImmediate 的优先级介于 I/O 操作和其他宏任务之间。
  3. setTimeoutsetInterval:它们的执行顺序是根据设置的延迟时间或间隔时间来确定的。当达到指定的时间后,它们的回调函数将作为宏任务进入消息队列。
  4. requestAnimationFramerequestAnimationFrame 通常在浏览器的重绘之前执行,用于优化动画效果。

需要注意的是,宏任务的执行顺序可以根据实现环境和宿主的不同而有所差异。不同的浏览器或环境可能对宏任务的优先级和执行顺序有所调整。

console.log('1');

setTimeout(() => {
  console.log('2 - setTimeout');
}, 0);

setImmediate(() => {
  console.log('3 - setImmediate');
});

console.log('4');

// 输出:1 4 3 - setImmediate 2 - setTimeout

在上面的示例中,首先输出 1 和 4。然后,通过 setTimeout 设置一个定时器,回调函数作为宏任务进入消息队列。接着,setImmediate 的回调函数作为宏任务执行,输出 3。最后,定时器的回调函数作为宏任务执行,输出 2。

这只是一个示例,实际的执行顺序可能因环境而异。但一般而言,I/O 操作的优先级最高,setImmediate 的优先级次之,而 setTimeoutsetInterval 的执行顺序取决于设置的延迟时间或间隔时间。

四、其他见到过的题目

4.1 题目1

setTimeout(function () {
  console.log("setTimeout1");
  new Promise(function (resolve) {
    resolve();
  }).then(function () {
    new Promise(function (resolve) {
      resolve();
    }).then(function () {
      console.log("then4");
    });
    console.log("then2");
  });
});

new Promise(function (resolve) {
  console.log("promise1");
  resolve();
}).then(function () {
  console.log("then1");
});

setTimeout(function () {
  console.log("setTimeout2");
});

console.log(2);

queueMicrotask(() => {
  console.log("queueMicrotask1")
});

new Promise(function (resolve) {
  resolve();
}).then(function () {
  console.log("then3");
});

答案

// promise1
// 2
// then1
// queueMicrotask1
// then3
// setTimeout1
// then2
// then4
// setTimeout2

4.2 题目2

async function async1() {
  console.log("async1 start");
  await async2();
  console.log("async1 end");
}

async function async2() {
  console.log("async2");
}

console.log("script start");

setTimeout(function () {
  console.log("setTimeout");
}, 0);

async1();

new Promise(function (resolve) {
  console.log("promise1");
  resolve();
}).then(function () {
  console.log("promise2");
});

console.log("script end");

答案

// script start
// async1 start
// async2
// promise1
// script end
// async1 end
// promise2
// setTimeout

如果你还想做更多的题目,到这篇文章看看juejin.cn/post/684490…

五、总结

一般遇到这种相关题目应该如何理解:

  1. 分清楚,哪些代码是主线程(直接执行)、哪些是微任务、哪些是宏任务
  2. 先执行主线程、后执行微任务、然后执行宏任务
  3. 若一个宏任务里面又创建了微任务,那么得先去执行微任务,做完了微任务才去做宏任务
  4. 分清楚微任务中的优先级,特别注意setTimeout中,有时间设置时的时间先后顺序;async函数中,await后面的才属于微任务。
  5. 分清楚宏任务中的优先级
  6. 注意代码,哪些是运行微/宏任务、那些是执行微/宏任务

综上,理解了以上事件循环机制的内容,基本上对于一般题目应该是没什么问题的。

六、补充

6.1 问题1

文章开始的那道题目,我们很显然的会发现有一个问题:setTimeout0为什么会输出在setImmediate之前?

解释是这样的:

在 Node.js 环境中,setImmediate 的优先级确实高于 setTimeout,即 setImmediate 的回调函数会在 setTimeout 的回调函数之前执行。之前的答案是基于浏览器环境下的行为。

在浏览器环境中,setTimeout 的回调函数在延迟时间为 0 的情况下会被放到至少 4 毫秒之后才执行,而 setImmediate 的回调函数会在当前宏任务执行完毕后立即执行。

因此,在浏览器环境中,setTimeout 的回调函数会在 setImmediate 的回调函数之后执行,即先输出 "setImmediate",然后再输出 "setTimeout0"。这是由于浏览器在处理 setTimeout 的延迟时间时会进行一些优化,确保它不会立即执行。