js事件循环讲解

78 阅读5分钟

一文带你搞懂 JavaScript 事件循环(Event Loop):微任务、宏任务解析

JavaScript 是单线程的,就是说同一时间只能干一件事。但如果遇到加载文件、请求数据这种费时间的操作,一直等着就会让页面卡住,所以才有了事件循环(Event Loop) 这个机制。它能让这些费时间的操作后台处理,不耽误主线程干活,是浏览器和 Node.js 里很核心的执行规则,面试也经常考。下面就用简单的话,结合例子帮大家搞明白。

一、基础常识:单线程和任务怎么分

  1. 为啥 JS 只能单线程?

JS 主要是用来操作页面上的元素(比如按钮、文字)的,如果能同时有多个线程改同一个元素,浏览器就乱了,不知道该听哪个的。所以单线程是 JS 天生的特点,改不了。

  1. 任务分三类,干活规则不一样

· 同步任务:按代码写的顺序一步步执行,上一个干完才能干下一个,中间不能停。比如定义变量、调用普通函数、写在 Promise 大括号里的代码、async 函数内 await 之前的代码,都是同步的。 · 异步任务:不用等着上一个干完,先交给浏览器的“辅助工具”(比如处理定时器、网络请求的模块)去做,做完了会告诉主线程。异步任务又分两种: · 宏任务:比较大的任务,比如整个脚本文件(script)、setTimeout、setInterval、读取文件/发网络请求(I/O)、页面渲染、点击/滚动这类事件的回调函数,还有 Node 里的 setImmediate。 · 微任务:比较小,优先级高的任务,比如 Promise 后面的 then/catch/finally 里的代码、async 函数中 await 之后的代码、MutationObserver,还有 Node 里的 process.nextTick。

二、核心逻辑:事件循环是怎么干活的

事件循环其实就是主线程反复做“干活→处理小事→获取下一个大事”的循环,每一轮循环就像完成一次“任务批次”,步骤很简单:

  1. 先执行第一个宏任务(一般就是整个脚本文件);
  2. 顺着代码执行同步任务(包括 async 函数内 await 之前的代码),遇到宏任务就先存到“宏任务队列”里,遇到微任务(包括 await 之后的代码、Promise.then 回调)就存到“微任务队列”里;
  3. 当前这个宏任务干完后,马上把“微任务队列”里的所有任务都干完(按顺序来,先存的先干);
  4. 微任务都干完了,浏览器可能会更新一下页面;
  5. 从“宏任务队列”里取下一个任务,重复上面的步骤,直到所有任务都干完。

关键要点:同一轮循环里,微任务一定比下一个宏任务先执行;每一轮循环都是从一个宏任务开始,干完微任务再换下一个宏任务;async/await 本质是 Promise 语法糖,其异步逻辑完全遵循微任务执行规则。

三、容易踩坑的点

  1. Promise 本身不是异步的,只有后面跟的 then/catch/finally 里的代码才是微任务;
  2. async/await 核心规则:async 函数执行时,先同步执行到 await 处,await 右侧表达式立即执行(同步),之后的代码会被包装成微任务,等当前同步任务执行完后再执行;
  3. 多个 await 连续使用时,后续的 await 会等前一个 await 对应的微任务执行完后才会触发自己的微任务,按顺序排队;
  4. 如果有多个 script 标签,每个标签都会被当成一个独立的宏任务,执行完一个标签里的代码,会先清它的微任务,再执行下一个标签;
  5. 微任务队列只有一个,宏任务队列可能有多个,但不管哪个队列,都是先存的先执行。

四、面试常见题

例题 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 右侧同步执行,后续代码入微任务队列”的细节。