事件循环(Event Loop)与微任务队列:彻底解析宏任务与微任务的执行顺序,以及`Promise`、`async/await`和`setTimeout`的底层差异。

事件循环(Event Loop)与微任务队列:彻底解析宏任务与微任务的执行顺序

大家好,今天我们来深入探讨 JavaScript 的事件循环(Event Loop)机制,以及它如何处理宏任务(MacroTask)和微任务(MicroTask)。理解这些概念对于编写高性能、可靠的 JavaScript 代码至关重要。我们会深入分析Promiseasync/awaitsetTimeout的底层差异,并结合实际代码案例,让大家彻底掌握事件循环的工作原理。

1. 什么是事件循环?

JavaScript 是一门单线程语言,这意味着它一次只能执行一个任务。为了处理异步操作,例如网络请求、定时器和用户交互,JavaScript 引擎使用事件循环机制。事件循环就像一个调度员,负责不断地从任务队列中取出任务并执行。

想象一个无限循环:

while (queue.waitForMessage()) {
  queue.processNextMessage();
}

这段伪代码描述了事件循环的基本流程:

  1. waitForMessage(): 事件循环等待队列中出现新的消息。这个过程通常是阻塞的。
  2. processNextMessage(): 如果队列中有消息,事件循环取出队列中的第一个消息并执行。

2. 宏任务(MacroTask)与微任务(MicroTask)

JavaScript 的任务队列实际上被分成了两类:宏任务队列和微任务队列。

  • 宏任务(MacroTask): 也称为 Task。例如:

    • 初始 script 的执行
    • setTimeout
    • setInterval
    • setImmediate (Node.js)
    • I/O 操作 (例如,文件读取)
    • UI 渲染
  • 微任务(MicroTask): 也称为 Jobs。例如:

    • Promise.thenPromise.catchPromise.finally
    • queueMicrotask() (较新的 API)
    • MutationObserver
    • process.nextTick (Node.js)

3. 事件循环的执行顺序

事件循环的执行顺序遵循以下规则:

  1. 执行栈(Call Stack)清空后,检查微任务队列。
  2. 如果微任务队列不为空,则取出队列中的第一个微任务并执行。执行完毕后,再次检查微任务队列,直到微任务队列为空。
  3. 当微任务队列为空时,事件循环会从宏任务队列中取出第一个宏任务并执行。
  4. 执行完一个宏任务后,会立刻检查微任务队列,重复步骤 2,直到微任务队列为空。
  5. 重复步骤 3 和 4,直到宏任务队列为空。

可以用如下表格描述这个循环:

阶段 描述
1. 执行脚本 浏览器解析并执行初始的 JavaScript 代码。这属于一个宏任务。
2. 执行宏任务 从宏任务队列中取出一个宏任务(例如,定时器回调、事件处理函数),将其压入执行栈并执行。
3. 清空微任务队列 在当前宏任务执行完成后,检查微任务队列。如果队列中有微任务(例如,Promise 回调),则依次取出并执行,直到微任务队列为空。重要的是,在执行微任务期间产生的新的微任务也会被添加到队列末尾,并在当前宏任务周期内执行完毕。
4. 渲染 在浏览器环境中,当微任务队列为空时,浏览器可能会进行页面渲染,更新用户界面。这通常发生在下一个宏任务开始之前。
5. 循环 事件循环重复步骤 2-4,不断从宏任务队列中取出宏任务并执行,直到宏任务队列为空。如果没有新的宏任务添加到队列中(例如,没有新的定时器触发,没有用户交互),事件循环将进入空闲状态,等待新的宏任务的到来。

4. Promiseasync/await与微任务

Promisethencatchfinally 回调函数都会被添加到微任务队列中。这意味着它们会在当前宏任务执行完成后,但在下一个宏任务开始之前执行。

console.log('script start');

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

Promise.resolve().then(() => {
  console.log('promise1');
}).then(() => {
  console.log('promise2');
});

console.log('script end');

// 输出:
// script start
// script end
// promise1
// promise2
// setTimeout

解释:

  1. console.log('script start')console.log('script end') 是同步代码,直接执行。
  2. setTimeout 的回调函数被添加到宏任务队列中。
  3. Promise.resolve().then(...) 的回调函数被添加到微任务队列中。
  4. 在执行完同步代码后,事件循环检查微任务队列,发现 promise1 的回调函数,执行并输出 promise1
  5. promise1 的回调函数执行完毕后,返回一个新的 Promise,其 then 回调函数被添加到微任务队列中。
  6. 事件循环再次检查微任务队列,发现 promise2 的回调函数,执行并输出 promise2
  7. 微任务队列为空后,事件循环从宏任务队列中取出 setTimeout 的回调函数并执行,输出 setTimeout

async/await 是基于 Promise 的语法糖。async 函数返回一个 Promise 对象,而 await 关键字会暂停 async 函数的执行,直到 Promise 对象的状态变为 resolved 或 rejected。await 表达式后面的代码会被封装成一个微任务。

async function test() {
  console.log('async function start');
  await Promise.resolve();
  console.log('async function end');
}

console.log('script start');
test();
console.log('script end');

// 输出:
// script start
// async function start
// script end
// async function end

解释:

  1. console.log('script start')console.log('script end') 是同步代码,直接执行。
  2. test() 函数被调用,输出 async function start
  3. await Promise.resolve() 暂停 test() 函数的执行,并将 console.log('async function end') 封装成一个微任务。
  4. 在执行完同步代码后,事件循环检查微任务队列,发现 console.log('async function end'),执行并输出 async function end

5. setTimeout与宏任务

setTimeout 的回调函数会被添加到宏任务队列中。这意味着它会在当前宏任务执行完成后,并且微任务队列为空后才会执行。setTimeout 的第二个参数指定了延迟时间,但这个延迟时间并不是精确的。实际上,setTimeout 只是将回调函数添加到宏任务队列中的时间延迟了指定的毫秒数。如果宏任务队列中已经有其他任务,或者事件循环正忙于执行其他任务,setTimeout 的回调函数可能会被延迟更长时间执行。

console.log('script start');

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

setTimeout(() => {
  console.log('setTimeout 10');
}, 10);

console.log('script end');

// 可能的输出:
// script start
// script end
// setTimeout 0
// setTimeout 10

解释:

  1. console.log('script start')console.log('script end') 是同步代码,直接执行。
  2. setTimeout 0setTimeout 10 的回调函数被添加到宏任务队列中。由于 setTimeout 0 的延迟时间为 0,因此它的回调函数会尽快被添加到宏任务队列中。
  3. 在执行完同步代码后,事件循环检查微任务队列,发现为空。
  4. 事件循环从宏任务队列中取出 setTimeout 0 的回调函数并执行,输出 setTimeout 0
  5. 事件循环再次检查微任务队列,发现为空。
  6. 事件循环从宏任务队列中取出 setTimeout 10 的回调函数并执行,输出 setTimeout 10

6. 深入案例分析

让我们来看一个更复杂的例子,它结合了 Promiseasync/awaitsetTimeout

console.log('script start');

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

Promise.resolve().then(() => {
  console.log('promise1');
}).then(() => {
  console.log('promise2');
});

async function test() {
  console.log('async1');
  await Promise.resolve();
  console.log('async2');
}

test();

console.log('script end');

// 预测输出:
// script start
// async1
// script end
// promise1
// promise2
// async2
// setTimeout

详细解释:

  1. script start: 首先,console.log('script start')被执行,输出"script start"。
  2. setTimeout: setTimeout被调用,其回调函数会被放入宏任务队列。
  3. Promise: Promise.resolve().then(...)被调用,promise1的回调函数会被放入微任务队列。
  4. async function test: test()函数被调用。
    • async1: console.log('async1')被执行,输出"async1"。
    • await Promise.resolve(): await Promise.resolve()暂停了test()函数的执行,并将async2的回调函数放入微任务队列。
  5. script end: console.log('script end')被执行,输出"script end"。
  6. 微任务队列处理: 现在,同步代码执行完毕,事件循环开始处理微任务队列。
    • promise1: promise1的回调函数被执行,输出"promise1"。它返回一个新的Promise,其then回调(promise2)被添加到微任务队列。
    • promise2: promise2的回调函数被执行,输出"promise2"。
    • async2: async2的回调函数被执行,输出"async2"。
  7. 宏任务队列处理: 微任务队列清空后,事件循环开始处理宏任务队列。
    • setTimeout: setTimeout的回调函数被执行,输出"setTimeout"。

7. queueMicrotask API

queueMicrotask 是一个相对较新的 API,用于将函数添加到微任务队列。 它的作用与 Promise.resolve().then(...) 类似,但更加简洁。

console.log('script start');

queueMicrotask(() => {
  console.log('queueMicrotask');
});

Promise.resolve().then(() => {
  console.log('promise');
});

console.log('script end');

// 输出:
// script start
// script end
// queueMicrotask
// promise

在这个例子中,queueMicrotaskPromise.resolve().then(...) 都将回调函数添加到微任务队列,并且 queueMicrotask 的回调函数会优先于 Promise.resolve().then(...) 的回调函数执行。 这是因为 queueMicrotask 直接将回调函数添加到微任务队列,而 Promise.resolve().then(...) 需要创建一个 Promise 对象,然后再将回调函数添加到微任务队列。

8. Node.js 环境下的事件循环

Node.js 的事件循环与浏览器中的事件循环类似,但有一些差异。 Node.js 使用 libuv 库来实现事件循环,libuv 提供了对 I/O 操作的异步处理。

Node.js 的事件循环分为以下几个阶段:

  1. Timers: 执行 setTimeoutsetInterval 的回调函数。
  2. Pending callbacks: 执行延迟到下一个循环迭代的 I/O 回调。
  3. Idle, prepare: 仅系统内部使用。
  4. Poll: 检索新的 I/O 事件; 执行与 I/O 相关的回调(几乎所有情况下,除了 timer 的回调、setImmediate()close 回调之外),Node 会在适当的时候阻塞在这里。
  5. Check: 执行 setImmediate() 回调。
  6. Close callbacks: 执行 close 事件的回调,例如 socket.on('close', ...)

微任务在每个阶段之间执行,类似于浏览器中的事件循环。

9. setImmediateprocess.nextTick (Node.js)

  • setImmediate: 将回调函数添加到 check 阶段的队列中。这意味着它会在 poll 阶段完成后执行。
  • process.nextTick: 将回调函数添加到微任务队列中,并且优先级高于 Promise.then 等微任务。
console.log('script start');

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

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

process.nextTick(() => {
  console.log('process.nextTick');
});

Promise.resolve().then(() => {
  console.log('promise');
});

console.log('script end');

// 可能的输出:
// script start
// script end
// process.nextTick
// promise
// setTimeout
// setImmediate

解释:

  1. process.nextTick 的回调函数会被添加到微任务队列,并且优先级最高,因此它会在 promise 之前执行。
  2. setTimeout 的回调函数会被添加到 timers 阶段的队列中。
  3. setImmediate 的回调函数会被添加到 check 阶段的队列中。
  4. setTimeoutsetImmediate 的执行顺序是不确定的,取决于事件循环的实现和系统负载。

10. 总结

理解事件循环、宏任务和微任务对于编写高效的 JavaScript 代码至关重要。Promiseasync/await 使得异步编程更加简洁和易于理解,但同时也需要理解它们的底层机制。 setTimeoutsetImmediateprocess.nextTick 在不同的环境下有不同的行为,需要根据具体情况选择合适的 API。

希望今天的讲解能够帮助大家更深入地理解 JavaScript 的事件循环机制。

宏任务与微任务的区别:任务类型影响执行顺序

宏任务和微任务是JavaScript异步编程中的两种任务类型,它们的区别在于执行时机和优先级。

异步操作的分类:Promise与setTimeout的本质差异

Promise和setTimeout分别代表着微任务和宏任务,它们的本质差异决定了它们在事件循环中的执行顺序。

事件循环的精髓:执行栈与任务队列的协作机制

事件循环是JavaScript处理异步操作的核心机制,它通过执行栈和任务队列的协作,实现了单线程环境下的并发执行。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注