一文带你搞懂 JavaScript 事件循环(Event Loop):微任务、宏任务解析
JavaScript 是单线程的,就是说同一时间只能干一件事。但如果遇到加载文件、请求数据这种费时间的操作,一直等着就会让页面卡住,所以才有了事件循环(Event Loop) 这个机制。它能让这些费时间的操作后台处理,不耽误主线程干活,是浏览器和 Node.js 里很核心的执行规则,面试也经常考。下面就用简单的话,结合例子帮大家搞明白。
一、基础常识:单线程和任务怎么分
- 为啥 JS 只能单线程?
JS 主要是用来操作页面上的元素(比如按钮、文字)的,如果能同时有多个线程改同一个元素,浏览器就乱了,不知道该听哪个的。所以单线程是 JS 天生的特点,改不了。
- 任务分三类,干活规则不一样
· 同步任务:按代码写的顺序一步步执行,上一个干完才能干下一个,中间不能停。比如定义变量、调用普通函数、写在 Promise 大括号里的代码、async 函数内 await 之前的代码,都是同步的。 · 异步任务:不用等着上一个干完,先交给浏览器的“辅助工具”(比如处理定时器、网络请求的模块)去做,做完了会告诉主线程。异步任务又分两种: · 宏任务:比较大的任务,比如整个脚本文件(script)、setTimeout、setInterval、读取文件/发网络请求(I/O)、页面渲染、点击/滚动这类事件的回调函数,还有 Node 里的 setImmediate。 · 微任务:比较小,优先级高的任务,比如 Promise 后面的 then/catch/finally 里的代码、async 函数中 await 之后的代码、MutationObserver,还有 Node 里的 process.nextTick。
二、核心逻辑:事件循环是怎么干活的
事件循环其实就是主线程反复做“干活→处理小事→获取下一个大事”的循环,每一轮循环就像完成一次“任务批次”,步骤很简单:
- 先执行第一个宏任务(一般就是整个脚本文件);
- 顺着代码执行同步任务(包括 async 函数内 await 之前的代码),遇到宏任务就先存到“宏任务队列”里,遇到微任务(包括 await 之后的代码、Promise.then 回调)就存到“微任务队列”里;
- 当前这个宏任务干完后,马上把“微任务队列”里的所有任务都干完(按顺序来,先存的先干);
- 微任务都干完了,浏览器可能会更新一下页面;
- 从“宏任务队列”里取下一个任务,重复上面的步骤,直到所有任务都干完。
关键要点:同一轮循环里,微任务一定比下一个宏任务先执行;每一轮循环都是从一个宏任务开始,干完微任务再换下一个宏任务;async/await 本质是 Promise 语法糖,其异步逻辑完全遵循微任务执行规则。
三、容易踩坑的点
- Promise 本身不是异步的,只有后面跟的 then/catch/finally 里的代码才是微任务;
- async/await 核心规则:async 函数执行时,先同步执行到 await 处,await 右侧表达式立即执行(同步),之后的代码会被包装成微任务,等当前同步任务执行完后再执行;
- 多个 await 连续使用时,后续的 await 会等前一个 await 对应的微任务执行完后才会触发自己的微任务,按顺序排队;
- 如果有多个 script 标签,每个标签都会被当成一个独立的宏任务,执行完一个标签里的代码,会先清它的微任务,再执行下一个标签;
- 微任务队列只有一个,宏任务队列可能有多个,但不管哪个队列,都是先存的先执行。
四、面试常见题
例题 1:基础入门题(同步+宏任务+微任务基础组合)
console.log(1);
setTimeout(() => {
console.log(2);
}, 0);
new Promise((resolve) => {
console.log(3);
resolve();
}).then(() => {
console.log(4);
});
console.log(5);
注意点:先执行所有同步代码,再清微任务,最后执行宏任务,重点区分 Promise 构造体和 then 回调的执行时机。
输出结果:1 → 3 → 5 → 4 → 2。
例题 2:async/await 基础题(单纯 async/await 与同步、微任务结合)
console.log("start");
async function test() {
console.log("async 内同步代码");
await console.log("await 右侧表达式");
console.log("await 之后的微任务");
}
test();
new Promise((resolve) => {
console.log("Promise 构造体");
resolve();
}).then(() => {
console.log("Promise.then 微任务");
});
console.log("end");
注意点:async 函数内 await 之前是同步代码,await 右侧表达式同步执行,之后的代码才是微任务,与 Promise.then 微任务按入队顺序执行。
例题 3:async/await 进阶题(多个 await 与宏任务混合)
console.log(1);
async function asyncFn() {
await new Promise((resolve) => {
console.log(2);
resolve();
}).then(() => {
console.log(3);
});
console.log(4);
await console.log(5);
console.log(6);
}
asyncFn();
setTimeout(() => {
console.log(7);
}, 0);
console.log(8);
注意点:第一个 await 会等 Promise.then 微任务执行完后,才会触发自身后续的微任务;第二个 await 继续按顺序排队,宏任务最后执行。
输出结果:1 → 2 → 8 → 3 → 4 → 5 → 6 → 7。
例题 4:嵌套宏任务题(宏任务内部包含同步+微任务)
console.log(1);
setTimeout(() => {
console.log(2);
new Promise((resolve) => {
console.log(3);
resolve();
}).then(() => {
console.log(4);
});
}, 0);
new Promise((resolve) => {
console.log(5);
resolve();
}).then(() => {
console.log(6);
setTimeout(() => {
console.log(7);
}, 0);
});
console.log(8);
注意点:宏任务执行时产生的微任务,会在当前宏任务结束后立即清空,新产生的宏任务需排队等待下一轮循环。
输出结果:1 → 5 → 8 → 6 → 2 → 3 → 4 → 7。
例题 5:多 script 标签题(多个宏任务模块的执行顺序)
<script>
console.log(1);
setTimeout(() => {
console.log(2);
}, 0);
</script>
<script>
console.log(3);
async function fn() {
await console.log(4);
console.log(5);
}
fn();
new Promise((resolve) => {
console.log(6);
resolve();
}).then(() => {
console.log(7);
});
</script>
注意点:每个 script 标签都是独立宏任务,执行完一个 script 后先清其微任务,再执行下一个 script,async/await 产生的微任务也遵循这个规则。
输出结果:1 → 3 → 4 → 6 → 5 → 7 → 2。
五、总结
事件循环其实没那么复杂,核心就是记住“先同步、再微任务、最后下一个宏任务”的顺序,而 async/await 只是在这个规则上增加了“await 右侧同步执行,后续代码入微任务队列”的细节。