JavaScript 异步机制详解:Event Loop、Promise、async/await 完全理解教程
作为一名 JavaScript 学习者,在学习异步编程时,我曾经无数次被这样的问题折磨:
“为什么 setTimeout 写在前面,却最后执行?”
“await 到底算不算同步?”
“Promise.then 和 setTimeout 谁先执行?”
当我第一次接触 Event Loop 时,感觉它像一个“只可意会不可言传”的概念。直到我开始自己写代码、一步步推演执行顺序,才发现:
Event Loop 并不神秘,它只是在帮 JS 管理:先做什么、后做什么。
这篇文章是我学习 Event Loop 过程中的一次总结,希望能用尽量通俗的语言 + 可运行的代码,把这个概念讲清楚。
一、为什么 JavaScript 需要 Event Loop?
我们先从一个最基础的问题开始。
JavaScript 是单线程的。
这意味着:同一时间,JS 只能做一件事。
如果你在主线程里写了一个非常耗时的同步任务,比如死循环或复杂计算,整个页面都会“卡住”,用户点什么都没反应。
那问题来了:
JS 是如何在「单线程」的前提下,实现定时器、网络请求、点击事件这些“异步能力”的?
答案就是:
Event Loop(事件循环)
二、一个形象的比喻:餐厅厨房模型
在理解 Event Loop 之前,我先用一个比喻帮自己建立直觉。
- JS 主线程:一个厨师(只能一次做一道菜)
- 同步代码:厨师手头正在做的菜
- 异步任务:顾客点的外卖或预约单
- 任务队列:挂在墙上的订单单子
- Event Loop:负责决定“下一道做什么菜”的调度员
核心规则很简单:
厨师永远先把手头这道菜做完,再看看有没有“更着急”的订单,最后才去处理普通订单。
这个“更着急”的订单,就是我们接下来要说的 —— 微任务。
三、宏任务与微任务:Event Loop 的核心规则
在 Event Loop 中,任务被分为两大类:
3.1 宏任务
可以理解为“正常排队的订单”:
- 整个 script(代码整体)
setTimeoutsetInterval- I/O
- UI 渲染
3.2 微任务
可以理解为“插队的紧急订单”:
Promise.then / catch / finallyasync / await(await 后面的代码)queueMicrotaskMutationObserver
3.3 核心执行规则(非常重要)
一次 Event Loop 循环中:
- 执行一个宏任务
- 执行完后,立刻清空所有微任务
- 如有需要,进行页面渲染
- 进入下一个宏任务
微任务一定比下一个宏任务先执行。
四、从最简单的例子开始理解
示例:setTimeout 一定是异步的吗?
let a = 1
setTimeout(() => {
a = 2
}, 1000)
console.log(a)
输出结果:
1
原因分析:
console.log(a)是同步代码,立刻执行setTimeout的回调会被放入 宏任务队列- 当前宏任务(script)还没结束,定时器回调不可能执行
setTimeout 不是“延迟执行”,而是“延后排队”。
五、Promise + setTimeout:经典顺序题拆解
console.log(1)
new Promise((resolve) => {
console.log(2)
resolve()
}).then(() => {
console.log(3)
setTimeout(() => {
console.log(4)
}, 0)
})
setTimeout(() => {
console.log(5)
setTimeout(() => {
console.log(6)
}, 0)
}, 0)
console.log(7)
最终输出:
1 2 7 3 5 4 6
拆解执行过程
-
同步代码执行:
1 → 2 → 7 -
当前宏任务结束,开始清空微任务:
- 执行
then→ 输出3 - 注册一个
setTimeout(4)
- 执行
-
执行宏任务队列中的第一个任务:
- 输出
5 - 注册
setTimeout(6)
- 输出
-
下一个宏任务:
- 输出
4
- 输出
-
再下一个宏任务:
- 输出
6
- 输出
记住一句话:Promise.then 永远比 setTimeout 先执行(同一轮循环中)。
六、async / await:它真的“同步”吗?
这是我在学习 Event Loop 时,最容易被表象骗到的一点。
因为 async / await 的写法太像同步代码了,很容易让人误以为:
“代码会在 await 这里停住,等结果出来再往下执行。”
但只要把下面这段代码的执行顺序真正跑一遍,就会发现事情并不是这样。
console.log('script start')
async function async1() {
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2 end')
}
async1()
setTimeout(() => {
console.log('setTimeout')
}, 0)
new Promise((resolve) => {
console.log('promise')
resolve()
}).then(() => {
console.log('then1')
}).then(() => {
console.log('then2')
})
console.log('script end')
最终输出:
script start
async2 end
promise
script end
async1 end
then1
then2
setTimeout
一步一步看,这段代码到底是怎么执行的?
① 同步代码先全部执行
- 输出
script start - 调用
async1()
② 执行到 await async2()
-
async2()立刻执行,输出async2 end -
遇到
await:- 不会阻塞主线程
console.log('async1 end')不会马上执行- 它被暂时“放到一边”,等待后续再执行
③ 继续执行当前脚本中的同步代码
- 创建 Promise,执行 executor,输出
promise - Promise 的
.then回调被加入 微任务队列 - 输出
script end
到这里为止,当前这轮同步代码已经执行完了。 接下来,Event Loop 开始处理 “延后的任务”
④ 执行微任务队列(按进入顺序)
- 执行
await后续代码 → 输出async1 end - 执行第一个
.then→ 输出then1 - 执行第二个
.then→ 输出then2
⑤ 执行下一个宏任务
- 执行
setTimeout回调 → 输出setTimeout
从这个例子中,你需要真正记住的几点
async2()是普通函数调用,同步执行await不会阻塞线程await后面的代码 不会立刻执行- 它会等当前同步代码执行完之后再执行
await后的代码和Promise.then一样,执行时机非常靠前
用一句话总结:
async / await 看起来像同步代码,但它只是改变了代码“什么时候继续执行”,并没有改变 JavaScript 的执行规则。
七、再看一个 async + 定时器的真实场景
function a() {
return new Promise((resolve) => {
setTimeout(() => {
console.log('a')
resolve()
}, 1000)
})
}
function b() {
console.log('b')
}
async function foo() {
setTimeout(() => {
console.log('c')
}, 1500)
await a()
b()
console.log('hello')
}
foo()
执行顺序(常见情况):
a
b
hello
c
为什么?
await a()等待 Promise resolveb()和hello属于 await 之后的微任务c是 1500ms 的宏任务,执行更晚
await 后的代码一定早于后续的宏任务执行。
最后:我学到的不是顺序,而是模型
在真正理解 Event Loop 之前,我刷顺序题全靠“死记硬背”;
理解之后,我只需要问自己三个问题:
- 这是同步代码吗?
- 它是宏任务还是微任务?
- 它是在哪一轮 Event Loop中被执行?
如果你也正在学习 JavaScript,希望这篇文章能帮你少走一点弯路。