事件循环(Event Loop)一文吃透:宏任务/微任务/渲染时机
为什么 Promise.then 比 setTimeout 先执行?为什么 await 之后的代码像“异步”又像“同步”?这篇文章用可运行的例子把浏览器事件循环、任务队列、微任务、渲染与 Node.js 差异讲清楚,顺带给你一套面试可复述的答案。
事件循环(Event Loop)一文吃透:宏任务/微任务/渲染时机
你可以把 JS 运行时想成一个“单线程的工位”。
它一次只能做一件事,但它能把其他事情交给“外部系统”(浏览器/Node)去处理,等准备好了再回来排队。
事件循环就是这个排队与调度系统。
这篇文章目标是:
- 让你从“背顺序”变成“会推演”
- 能解释 Promise / async/await / setTimeout / 渲染更新的先后
- 面试遇到题能稳定输出
1. 基础模型:调用栈、任务队列、事件循环
你需要先把 3 个概念装进脑子:
- 调用栈(Call Stack):当前正在执行的函数栈
- 任务队列(Task Queue):等待执行的任务(宏任务队列、微任务队列)
- 事件循环(Event Loop):不停检查“栈空了吗?队列里有任务吗?”然后把任务推进栈执行
一句话:
栈在跑代码,队列在排任务,循环在做调度。
2. 宏任务 vs 微任务:为什么 Promise.then 更“急”
2.1 宏任务(Macrotask / Task)
常见来源:
setTimeout/setIntervalMessageChannelsetImmediate(Node)- I/O 回调(Node)
- UI 事件(点击、输入等)
2.2 微任务(Microtask)
常见来源:
Promise.then/catch/finallyqueueMicrotaskMutationObserver(浏览器)
核心规则:
每执行完一个宏任务后,会清空微任务队列(一直执行到微任务队列为空)。
这就是为什么 Promise.then 通常会比下一个 setTimeout 先执行。
3. 先跑一段能“背下来”的经典例子
把下面这段放到浏览器控制台(或任意 JS 运行环境)里跑:
console.log('script start')
setTimeout(() => {
console.log('timeout')
}, 0)
Promise.resolve()
.then(() => console.log('then 1'))
.then(() => console.log('then 2'))
console.log('script end')
输出顺序通常是:
script startscript endthen 1then 2timeout
推演方式:
- 整个脚本本身就是一个宏任务
- 脚本执行结束(宏任务结束)
- 清空微任务队列:执行
then 1、then 2 - 执行下一轮宏任务:
timeout
4. async/await 不是“更神秘的异步”
很多人误以为 await 会“开线程”。不会。
await 的本质可以近似理解为:
- 把
await后面的代码拆成一个 continuation - 这个 continuation 会在 Promise resolve 后,作为微任务继续执行
看例子:
async function main() {
console.log('A')
await 0
console.log('B')
}
console.log('start')
main()
console.log('end')
通常输出:
startAendB
原因:
main()在同步阶段先执行到awaitawait把后续console.log('B')放进微任务- 当前宏任务(脚本)结束后,清空微任务,打印
B
结论:
await后面“像异步”,是因为它把后续逻辑交给微任务队列。
5. 渲染(render)在什么时候发生?
这是一个常见盲区。
在浏览器里,大致可以记住一个经验规则:
- 一轮宏任务执行完
- 清空微任务队列
- 浏览器可能进行一次渲染(布局/绘制),然后进入下一轮宏任务
这解释了两个现象:
- 如果你在一个宏任务里连续做大量同步计算,即使你修改了 DOM,页面也不会立刻更新。
- 如果你在 DOM 更新后立刻读取布局信息(如
offsetHeight),可能触发强制同步布局(layout thrash)。
你可以用这个小实验感受“渲染不是你想的那样即时”:
const el = document.createElement('div')
el.textContent = 'before'
document.body.appendChild(el)
setTimeout(() => {
el.textContent = 'timeout update'
}, 0)
Promise.resolve().then(() => {
el.textContent = 'microtask update'
})
通常你会看到最终显示的是 microtask update,因为它在同一轮宏任务结束后的“清空微任务阶段”执行得更早。
6. Node.js 的事件循环:为什么和浏览器不完全一样
面试中你可以这么说:
- 浏览器的事件循环与“渲染”强绑定
- Node.js 的事件循环分多个 phase(timers、poll、check…)
- 微任务(Promise)在 Node 中也很重要,但调度点与浏览器有细微差异
如果你想写 Node 面试回答版本:
- 宏观:每轮 loop 按 phase 执行队列
- 微观:每个 phase 结束后也会清空 microtask queue
注意:Node 还有 process.nextTick,它比 Promise 微任务更“优先”,容易导致饥饿(starvation)。
7. 你要掌握的“推演模板”
碰到输出顺序题,不要靠背。
按这个模板推:
- 把同步代码按顺序执行完(都在当前宏任务里)
- 同步执行过程中:
setTimeout→ 放宏任务队列then/await→ 放微任务队列
- 当前宏任务结束
- 清空微任务队列(循环执行,直到为空)
- 进入下一轮宏任务
- 浏览器环境:宏任务与微任务之间可能插入渲染
8. 面试常见追问(直接可背)
Q1:Promise.then 和 setTimeout 谁先?
- 一般
then先,因为它是微任务,会在当前宏任务结束后立即清空。
Q2:await 后的代码算微任务吗?
- 可以近似理解为:
await把后续逻辑放进微任务队列。
Q3:为什么页面更新不及时?
- 因为渲染通常发生在“宏任务结束 + 清空微任务”之后;同步长任务会阻塞渲染。
总结
- JS 单线程靠事件循环做调度。
- 宏任务一轮一轮执行;每轮宏任务结束后清空微任务。
Promise.then、await的后续执行通常属于微任务。- 浏览器渲染一般夹在“宏任务与下一轮宏任务之间”。
如果你愿意,我可以把这篇文章再补一节:专门用 6~8 道“输出顺序题”带你做推演训练,并配上答案与解析。