JS 事件循环从小白视角入门:宏任务、微任务与 async/await 一网打尽

本文是事件循环学习笔记的整理版。作为一个刚学 JS 不久的小白,我尽量用大白话把自己踩过的坑和验证过的例子讲清楚。如果有理解不到位的地方,欢迎评论区指正~


前言

第一次看到 setTimeoutPromise 一起出现,并且输出顺序"莫名其妙"时,我整个人是懵的:

js 复制代码
console.log("script start");
setTimeout(() => {
  console.log("setTimeout");
}, 0);
Promise.resolve().then(() => {
  console.log("promise");
});
console.log("script end");

猜猜输出顺序?答案是:

arduino 复制代码
script start
script end
promise
setTimeout

为什么 setTimeout(0) 里的代码反而最后 执行?要搞懂这个,就得先弄明白三件事:JS 为什么是单线程任务分几类事件循环怎么转


一、JS 为什么是单线程?

  • 默认情况下,JS 引擎只启用一个线程来执行代码。
  • 我们的代码里一定有耗时任务(定时器、HTTP 请求、文件 I/O)和不耗时任务(普通计算、赋值)。

那为什么不设计成多线程呢?主要有两个原因:

  1. JS 能操作 DOM 结构。 如果多个线程同时去改同一个 DOM,页面渲染会变得不安全、结果不可预测。
  2. 多线程需要加锁。 加锁会显著增加开发难度,还会带来额外的设备性能开销。

一句话:为了简单和安全,JS 选择单线程 + 异步协作,而不是多线程 + 锁。


二、任务分类:同步任务 & 异步任务

既然是单线程,那遇到耗时任务怎么办?答案是:把耗时(异步)任务先存进队列,先把不耗时的(同步)任务执行完。

异步任务又分成两类:

类型 常见成员
宏任务 (macrotask) scriptsetTimeout()setInterval()、ajax 请求、I/O、UI-rendering
微任务 (microtask) promise.then()process.nextTick()MutationObserver

记住这个分类,后面的事件循环全靠它。


三、事件循环到底怎么转的?

这是全文的核心。事件循环一轮的完整流程是:

用文字画个图(ASCII 版,纯文本保证任何地方都能显示):

markdown 复制代码
① 开始执行宏任务(整个 script 就是第一个宏任务)
         │
         ▼
② 遇到异步任务?──是──► 存入对应的宏任务/微任务队列
         │否
         ▼
③ 继续同步执行
         │
         ▼
④ 同步代码执行完?──否──► 回到 ②
         │是
         ▼
⑤ 执行微任务队列中的所有微任务(新加的也要本轮清完)
         │
         ▼
⑥ 需要渲染页面?──需要──► 渲染页面
         │不需要
         ▼
⑦ 取出一个宏任务执行(这也是下一轮循环的开始)
         │
         └────────────► 回到 ①,继续循环

拆成 4 步就是:

  1. 先执行同步任务 (整个 script 就属于一个宏任务)。执行过程中遇到异步任务,就存入对应的队列。
  2. 去微任务队列里取微任务,全部取出来执行。
  3. 有需要的话,就渲染页面。
  4. 去宏任务队列里取宏任务执行------而这一步,也是下一轮循环的开始。

几个容易忽略的细节:

  • 微任务队列要清空到空为止:执行一个微任务时如果又产生了新的微任务,这些新微任务也要在本轮里继续执行完,然后才轮到渲染和宏任务。
  • 每一轮循环只取出一个宏任务执行(执行完它,再回到步骤 2 清一遍微任务),而不是一次性把所有宏任务全执行掉。
  • promise 的构造函数是同步执行 的,只有 .then() 里的回调才是微任务------很多小白在这里栽过跟头。

四、setTimeout 的坑:先进队列的不一定先执行

有一条很重要的规律需要记住:所有的 setTimeout 共用同一份时间,先进宏任务队列的不一定先执行,而是根据计时时间长短来判断是否先执行。

什么意思?看一个真实跑过的例子:

js 复制代码
console.log(1);
new Promise((resolve) => {
  console.log(2);
  resolve();
}).then(() => {
  console.log(3);
  setTimeout(() => {
    console.log(4); // 0ms
  }, 0);
});
setTimeout(() => {
  console.log(5); // 1000ms
}, 1000);
console.log(6);

输出顺序:

复制代码
1
2
6
3
4
5

关键在第 4 和第 5 步:setTimeout(5) 是先进宏任务队列的 (在 console.log(6) 之前就注册了),而 setTimeout(4) 是在微任务里才注册的。按"先进先出"的直觉,应该先输出 5 再输出 4------但结果是先输出 4

原因就是:setTimeout 不是"入队即执行",而是按计时时间 来决定。4 只要 0ms,5 要 1000ms,所以哪怕 5 先进队,也要等到 1 秒后才轮得到。定时器到点了,任务才会被"投递"进宏任务队列,队列里谁先被投递谁先执行。


五、JS 引擎线程 和 渲染线程 互斥

JS 引擎线程渲染线程互斥的:同一时刻只能有一个在工作。

  • JS 在跑的时候,页面渲染被阻塞------这就是为什么遇到死循环、超大计算量时页面会"卡死",按钮点了没反应。
  • 反过来,渲染的时候 JS 也不会执行。

用一个简单的 HTML 页面可以直观感受渲染时机:

html 复制代码
<script>
  let a = 1;
  console.log(a);
  new Promise((resolve) => {
    a = 2;
    console.log(a);
    resolve();
  }).then(() => {
    a = 3;
    console.log(a);
  });

  setTimeout(() => {
    a = 4;
    console.log(a);
  }, 1000);
</script>

<h2>hello</h2>

过程是这样:

  1. 同步代码跑完:a = 1a = 2,控制台输出 12
  2. 清微任务:输出 3a = 3)。
  3. 此时微任务清完了,事件循环"有需要就渲染页面"hello 才真正被画出来。
  4. 1 秒后宏任务触发:输出 4

这正好对应事件循环里的第 3 步"有需要的情况下,就渲染页面"------渲染发生在微任务之后、下一个宏任务之前


六、async/await 到底干了啥?

我最初对 async/await 的理解就是"把异步代码写得像同步一样",但它的底层机制是:

  1. 函数前面加 async,等同于函数内部 return 了一个 Promise 对象。
  2. await xxx 会把 xxx 当成同步来执行;await 之后的代码,会被挤进微任务队列。

用一道经典面试题来验证:

js 复制代码
console.log("script start"); // 1
async function async1() {
  await async2();
  console.log("async1 end"); // 5
}
async function async2() {
  console.log("async2 end"); // 2
}
async1();
setTimeout(() => {
  console.log("setTimeout"); // 8
}, 0);
new Promise((resolve) => {
  console.log("promise"); // 3
  resolve();
})
  .then(() => console.log("then1")) // 6
  .then(() => console.log("then2")); // 7
console.log("script end"); // 4

输出:

arduino 复制代码
script start
async2 end
promise
script end
async1 end
then1
then2
setTimeout

逐步拆解:

  • 第 1 步:script start(同步)。
  • 第 2 步:调用 async1(),执行到 await async2() ------ async2()同步执行 的,所以立刻输出 async2 end注意:await 之后的 console.log("async1 end") 现在还没执行,它已经被挤进微任务队列了。
  • 第 3 步:new Promise 的构造器同步执行,输出 promise.then() 的回调(then1)进微任务队列。
  • 第 4 步:script end(同步)------到这里,宏任务(整个 script)执行完毕
  • 第 5~7 步:开始清微任务队列,按注册顺序执行:async1 endthen1then2then1 执行后又产生了 then2,也在本轮清完)。
  • 第 8 步:微任务清空后,渲染时机过了,最后执行宏任务 setTimeout,输出 setTimeout

一个很容易踩的坑(看下面这个例子):

js 复制代码
function A() {
  setTimeout(() => {
    console.log("a");
  }, 1000);
}
function B() {
  console.log("b");
}

async function fn() {
  await A();
  await B();
  C();
}
fn();

A() 没有返回 Promise (它返回的是 undefined)。所以 await A() 并不会"等待"那个 1 秒的定时器,而是把 undefined 当成已经 resolved 的 Promise,立刻继续往下走:B() 马上输出 b,然后 C() 直接报错(未定义)。而 a 要等 1 秒后才输出。

结论:想让 await 真正起到"等待"作用,被 await 的函数必须返回 Promise 对象 ,比如 return new Promise((resolve) => {})。小白(比如我)最容易在这上面栽跟头。


七、总结

把今天的内容浓缩成几句话:

  1. JS 单线程:为了操作 DOM 的安全和简单,不用多线程 + 锁。
  2. 任务分两类 :宏任务(scriptsetTimeoutsetInterval、ajax、I/O、UI 渲染)和微任务(promise.then()process.nextTick()MutationObserver)。
  3. 事件循环顺序:同步代码(script 宏任务)→ 清空微任务 →(需要时)渲染 → 取一个宏任务 → 再回到清微任务......循环往复。
  4. setTimeout 不按入队顺序执行,按计时时间投递到队列后先到先执行。
  5. 渲染线程与 JS 引擎线程互斥,渲染插在微任务清空之后、下一个宏任务之前。
  6. async/awaitasync 函数返回 Promise;await 后的代码进微任务队列;被 await 的函数必须返回 Promise,否则等于白等。

一句话速记事件循环的节奏:

同步 → 微任务 →(渲染)→ 宏任务 → 循环

把这六句话记住,再遇到"这道题输出顺序是什么"的面试题,就能一步步推出来了。我也是这么练出来的,共勉 💪

相关推荐
aloha_1 小时前
Linux服务器上指定目录的文件下载
前端
szp20052 小时前
为了在浏览器里跑多线程 ONNX 推理,我把自己的支付浮层弄挂了
前端·webassembly
kisshyshy3 小时前
从 useRef 到 Web Worker:理解 React 可变对象与浏览器多线程
前端·javascript·react.js
fatcoder3 小时前
玩转Nginx 04 — 反向代理:给 nginx 接上后端
前端·后端·nginx
Data_Journal3 小时前
什么是 CAPTCHA,它是如何工作的?
java·大数据·服务器·前端·数据库
计算机魔术师4 小时前
我看了这个更新,把原来的检索方案推翻了
前端
zhanghaha13144 小时前
HTML系列教程:3_HTML 基础标签 — 标题、段落、超链接、图像
前端·html
李高钢4 小时前
C# WPF Prism 进阶(二):区域(Region)与模块化(Module)
java·前端·数据库
xyphf_和派孔明4 小时前
Vite 与 Webpack 对比及常见面试题
前端·webpack·vite