事件循环(Event Loop)与微任务队列:彻底解析宏任务与微任务的执行顺序
大家好,今天我们来深入探讨 JavaScript 的事件循环(Event Loop)机制,以及它如何处理宏任务(MacroTask)和微任务(MicroTask)。理解这些概念对于编写高性能、可靠的 JavaScript 代码至关重要。我们会深入分析Promise
、async/await
和setTimeout
的底层差异,并结合实际代码案例,让大家彻底掌握事件循环的工作原理。
1. 什么是事件循环?
JavaScript 是一门单线程语言,这意味着它一次只能执行一个任务。为了处理异步操作,例如网络请求、定时器和用户交互,JavaScript 引擎使用事件循环机制。事件循环就像一个调度员,负责不断地从任务队列中取出任务并执行。
想象一个无限循环:
while (queue.waitForMessage()) {
queue.processNextMessage();
}
这段伪代码描述了事件循环的基本流程:
- waitForMessage(): 事件循环等待队列中出现新的消息。这个过程通常是阻塞的。
- processNextMessage(): 如果队列中有消息,事件循环取出队列中的第一个消息并执行。
2. 宏任务(MacroTask)与微任务(MicroTask)
JavaScript 的任务队列实际上被分成了两类:宏任务队列和微任务队列。
-
宏任务(MacroTask): 也称为 Task。例如:
- 初始 script 的执行
setTimeout
setInterval
setImmediate
(Node.js)- I/O 操作 (例如,文件读取)
- UI 渲染
-
微任务(MicroTask): 也称为 Jobs。例如:
Promise.then
、Promise.catch
、Promise.finally
queueMicrotask()
(较新的 API)MutationObserver
process.nextTick
(Node.js)
3. 事件循环的执行顺序
事件循环的执行顺序遵循以下规则:
- 执行栈(Call Stack)清空后,检查微任务队列。
- 如果微任务队列不为空,则取出队列中的第一个微任务并执行。执行完毕后,再次检查微任务队列,直到微任务队列为空。
- 当微任务队列为空时,事件循环会从宏任务队列中取出第一个宏任务并执行。
- 执行完一个宏任务后,会立刻检查微任务队列,重复步骤 2,直到微任务队列为空。
- 重复步骤 3 和 4,直到宏任务队列为空。
可以用如下表格描述这个循环:
阶段 | 描述 |
---|---|
1. 执行脚本 | 浏览器解析并执行初始的 JavaScript 代码。这属于一个宏任务。 |
2. 执行宏任务 | 从宏任务队列中取出一个宏任务(例如,定时器回调、事件处理函数),将其压入执行栈并执行。 |
3. 清空微任务队列 | 在当前宏任务执行完成后,检查微任务队列。如果队列中有微任务(例如,Promise 回调),则依次取出并执行,直到微任务队列为空。重要的是,在执行微任务期间产生的新的微任务也会被添加到队列末尾,并在当前宏任务周期内执行完毕。 |
4. 渲染 | 在浏览器环境中,当微任务队列为空时,浏览器可能会进行页面渲染,更新用户界面。这通常发生在下一个宏任务开始之前。 |
5. 循环 | 事件循环重复步骤 2-4,不断从宏任务队列中取出宏任务并执行,直到宏任务队列为空。如果没有新的宏任务添加到队列中(例如,没有新的定时器触发,没有用户交互),事件循环将进入空闲状态,等待新的宏任务的到来。 |
4. Promise
、async/await
与微任务
Promise
的 then
、catch
和 finally
回调函数都会被添加到微任务队列中。这意味着它们会在当前宏任务执行完成后,但在下一个宏任务开始之前执行。
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
解释:
console.log('script start')
和console.log('script end')
是同步代码,直接执行。setTimeout
的回调函数被添加到宏任务队列中。Promise.resolve().then(...)
的回调函数被添加到微任务队列中。- 在执行完同步代码后,事件循环检查微任务队列,发现
promise1
的回调函数,执行并输出promise1
。 promise1
的回调函数执行完毕后,返回一个新的 Promise,其then
回调函数被添加到微任务队列中。- 事件循环再次检查微任务队列,发现
promise2
的回调函数,执行并输出promise2
。 - 微任务队列为空后,事件循环从宏任务队列中取出
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
解释:
console.log('script start')
和console.log('script end')
是同步代码,直接执行。test()
函数被调用,输出async function start
。await Promise.resolve()
暂停test()
函数的执行,并将console.log('async function end')
封装成一个微任务。- 在执行完同步代码后,事件循环检查微任务队列,发现
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
解释:
console.log('script start')
和console.log('script end')
是同步代码,直接执行。setTimeout 0
和setTimeout 10
的回调函数被添加到宏任务队列中。由于setTimeout 0
的延迟时间为 0,因此它的回调函数会尽快被添加到宏任务队列中。- 在执行完同步代码后,事件循环检查微任务队列,发现为空。
- 事件循环从宏任务队列中取出
setTimeout 0
的回调函数并执行,输出setTimeout 0
。 - 事件循环再次检查微任务队列,发现为空。
- 事件循环从宏任务队列中取出
setTimeout 10
的回调函数并执行,输出setTimeout 10
。
6. 深入案例分析
让我们来看一个更复杂的例子,它结合了 Promise
、async/await
和 setTimeout
:
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
详细解释:
- script start: 首先,
console.log('script start')
被执行,输出"script start"。 - setTimeout:
setTimeout
被调用,其回调函数会被放入宏任务队列。 - Promise:
Promise.resolve().then(...)
被调用,promise1
的回调函数会被放入微任务队列。 - async function test:
test()
函数被调用。- async1:
console.log('async1')
被执行,输出"async1"。 - await Promise.resolve():
await Promise.resolve()
暂停了test()
函数的执行,并将async2
的回调函数放入微任务队列。
- async1:
- script end:
console.log('script end')
被执行,输出"script end"。 - 微任务队列处理: 现在,同步代码执行完毕,事件循环开始处理微任务队列。
- promise1:
promise1
的回调函数被执行,输出"promise1"。它返回一个新的Promise,其then
回调(promise2
)被添加到微任务队列。 - promise2:
promise2
的回调函数被执行,输出"promise2"。 - async2:
async2
的回调函数被执行,输出"async2"。
- promise1:
- 宏任务队列处理: 微任务队列清空后,事件循环开始处理宏任务队列。
- setTimeout:
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
在这个例子中,queueMicrotask
和 Promise.resolve().then(...)
都将回调函数添加到微任务队列,并且 queueMicrotask
的回调函数会优先于 Promise.resolve().then(...)
的回调函数执行。 这是因为 queueMicrotask
直接将回调函数添加到微任务队列,而 Promise.resolve().then(...)
需要创建一个 Promise 对象,然后再将回调函数添加到微任务队列。
8. Node.js 环境下的事件循环
Node.js 的事件循环与浏览器中的事件循环类似,但有一些差异。 Node.js 使用 libuv 库来实现事件循环,libuv 提供了对 I/O 操作的异步处理。
Node.js 的事件循环分为以下几个阶段:
- Timers: 执行
setTimeout
和setInterval
的回调函数。 - Pending callbacks: 执行延迟到下一个循环迭代的 I/O 回调。
- Idle, prepare: 仅系统内部使用。
- Poll: 检索新的 I/O 事件; 执行与 I/O 相关的回调(几乎所有情况下,除了 timer 的回调、
setImmediate()
和close
回调之外),Node 会在适当的时候阻塞在这里。 - Check: 执行
setImmediate()
回调。 - Close callbacks: 执行
close
事件的回调,例如socket.on('close', ...)
。
微任务在每个阶段之间执行,类似于浏览器中的事件循环。
9. setImmediate
和 process.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
解释:
process.nextTick
的回调函数会被添加到微任务队列,并且优先级最高,因此它会在promise
之前执行。setTimeout
的回调函数会被添加到 timers 阶段的队列中。setImmediate
的回调函数会被添加到 check 阶段的队列中。setTimeout
和setImmediate
的执行顺序是不确定的,取决于事件循环的实现和系统负载。
10. 总结
理解事件循环、宏任务和微任务对于编写高效的 JavaScript 代码至关重要。Promise
和 async/await
使得异步编程更加简洁和易于理解,但同时也需要理解它们的底层机制。 setTimeout
、setImmediate
和 process.nextTick
在不同的环境下有不同的行为,需要根据具体情况选择合适的 API。
希望今天的讲解能够帮助大家更深入地理解 JavaScript 的事件循环机制。
宏任务与微任务的区别:任务类型影响执行顺序
宏任务和微任务是JavaScript异步编程中的两种任务类型,它们的区别在于执行时机和优先级。
异步操作的分类:Promise与setTimeout的本质差异
Promise和setTimeout分别代表着微任务和宏任务,它们的本质差异决定了它们在事件循环中的执行顺序。
事件循环的精髓:执行栈与任务队列的协作机制
事件循环是JavaScript处理异步操作的核心机制,它通过执行栈和任务队列的协作,实现了单线程环境下的并发执行。