JS 事件循环完整解析:宏任务、微任务,浏览器到底怎么执行代码
很多人对事件循环的理解,停留在两句口诀:
"先执行同步代码,再执行异步回调。"
"微任务先于宏任务执行。"
听着像那么回事,但一遇到复杂场景就懵了:
Promise.then和setTimeout谁先执行?async/await算微任务吗?MutationObserver什么时候跑?- 为什么
requestAnimationFrame既不像宏任务,也不像微任务? - 浏览器一帧里到底干了哪些事?
这篇文章不背八股,我们从 JS 引擎 + 渲染引擎 + 浏览器宿主环境 的整体视角,把事件循环这件事彻底拆清楚。
一、先纠正一个常见误区:JS 不是"多线程语言"
JS 本身是 单线程 的:
- 同一时刻,只有一个执行栈
- 一次只做一件事
- 不会被两个函数同时打断
那为什么 JS 又能发请求、监听点击、定时执行?
👉 因为 JS 运行在一个"宿主环境"里(浏览器 / Node.js) 。
异步能力来自宿主,而不是 JS 本身。
JS 负责"执行代码",宿主负责"调度任务"。
二、事件循环的本质:一个永不停止的调度器
浏览器的事件循环(Event Loop)本质上是一个 任务调度机制:
不断从"任务队列"里取出任务,丢进 JS 执行栈中执行。
简化成伪代码就是:
markdown
while (true) {
1. 执行一个宏任务
2. 执行所有微任务
3. 如有必要,执行渲染
}
这个循环,每一轮被称为 一次 Tick(滴答) 。
三、执行栈 + 任务队列:JS 运行的真实模型
1. 执行栈(Call Stack)
javascript
function a() {
b();
}
function b() {
console.log('b');
}
a();
执行栈变化:
css
→ a()
→ a() -> b()
→ a()
→ empty
特点:
- LIFO(后进先出)
- 只有栈空了,事件循环才能继续
2. 任务队列(Task Queue)
异步任务不会立刻进入执行栈,而是:
- 宿主环境先把回调放进对应的队列
- 执行栈为空时,事件循环才去取
而任务队列,又分为两类:
| 类型 | 常见来源 |
|---|---|
| 宏任务(Macrotask) | script 整体代码、setTimeout、setInterval、UI 渲染、I/O、setImmediate(Node) |
| 微任务(Microtask) | Promise.then/catch/finally、queueMicrotask、MutationObserver |
四、宏任务 vs 微任务:不是"快慢",而是"优先级"
1. 执行规则(核心)
每一次事件循环:
- 取一个宏任务执行
- 把这个宏任务产生的所有微任务一次性清空
- 才可能进入下一次宏任务
一句话记住:
一个宏任务 + 一批微任务 = 一个事件循环 tick
2. 经典示例
javascript
console.log('start');
setTimeout(() => {
console.log('timeout');
}, 0);
Promise.resolve().then(() => {
console.log('promise1');
}).then(() => {
console.log('promise2');
});
console.log('end');
输出顺序:
sql
start
end
promise1
promise2
timeout
为什么?
script整体代码是 第一个宏任务- 同步代码依次入栈执行
setTimeout回调进入 宏任务队列Promise.then回调进入 微任务队列- 当前宏任务执行完 → 清空微任务队列
- 再取下一个宏任务(
setTimeout)
3. 微任务的"连锁反应"
微任务里可以继续产生微任务:
javascript
Promise.resolve().then(() => {
console.log('micro1');
Promise.resolve().then(() => {
console.log('micro2');
});
});
结果:
micro1
micro2
✅ 只要微任务队列不为空,事件循环就不会继续。
这也是为什么滥用微任务会阻塞渲染。
五、浏览器的一帧(Frame):事件循环 + 渲染的真相
很多人不知道:并不是每次事件循环都会渲染页面。
1. 一帧里发生了什么?
浏览器通常以 60 FPS 为目标,也就是约 16.7ms 一帧。
一帧大致流程:
markdown
1. 执行一个宏任务
2. 执行所有微任务
3. 判断是否达到渲染时机
└─ 是 → 执行 render 相关操作
└─ 否 → 直接进入下一轮事件循环
4. 执行 requestAnimationFrame 回调
5. 渲染(Layout → Paint → Composite)
⚠️ 重点:
- 微任务总是在渲染前执行
rAF在渲染前、微任务之后执行- 渲染不是每轮 tick 都发生
2. requestAnimationFrame 的定位
javascript
requestAnimationFrame(() => {
console.log('rAF');
});
Promise.resolve().then(() => {
console.log('promise');
});
输出:
arduino
promise
rAF
原因:
- 微任务先于渲染
rAF属于渲染前钩子- 所以它既不是宏任务,也不是微任务,而是一个 渲染阶段的回调
六、MutationObserver:微任务里的"监听者"
javascript
const observer = new MutationObserver(() => {
console.log('mutated');
});
observer.observe(document.body, { childList: true });
document.body.appendChild(document.createElement('div'));
console.log('append');
输出:
go
append
mutated
原因:
- DOM 变更是同步的
- 回调被放入 微任务队列
- 当前 task 结束后立即执行
✅ 这也是为什么 MutationObserver 能"批量感知 DOM 变化",而不会频繁触发。
七、async / await:披着语法糖外衣的 Promise
1. async 函数的本质
javascript
async function foo() {
console.log('1');
await bar();
console.log('2');
}
function bar() {
console.log('3');
}
foo();
console.log('4');
等价于:
javascript
function foo() {
console.log('1');
return Promise.resolve(bar()).then(() => {
console.log('2');
});
}
执行顺序:
1
3
4
2
关键点:
await后面的代码,相当于.then()里的回调- 属于微任务
await本身之前的代码是同步执行的
2. 常见陷阱
javascript
setTimeout(() => console.log('timeout'), 0);
async function test() {
await null;
console.log('after await');
}
test();
console.log('sync');
输出:
bash
sync
after await
timeout
八、Node.js 与浏览器的差异(简要对照)
| 项目 | 浏览器 | Node.js |
|---|---|---|
| 宏任务 | setTimeout、UI、I/O | setTimeout、setImmediate、I/O |
| 微任务 | Promise、MutationObserver | Promise、process.nextTick |
| nextTick | ❌ | ✅(优先级最高) |
| 调度粒度 | 帧级 | 阶段级(phase-based) |
一句话区分:
Node 的事件循环更复杂,但浏览器模型更贴近前端日常。
九、为什么"微任务先于宏任务"很重要?
1. UI 一致性
微任务在渲染前执行,可以保证:
- DOM 更新
- 状态计算
- 数据同步
都在 同一帧内完成,避免"闪屏"或"中间态"。
2. 性能隐患
scss
let i = 0;
function loop() {
Promise.resolve().then(loop);
}
loop();
这是一个 微任务死循环:
- 微任务永远清不完
- 浏览器无法进入渲染阶段
- 页面卡死
✅ 正确做法:
- 耗时逻辑放宏任务
- 或用
setTimeout / rAF拆分
十、一张图总结(文字版)
javascript
┌──────────────┐
│ 执行一个宏任务 │
└──────┬───────┘
↓
┌──────────────────┐
│ 执行所有微任务 │ ← Promise.then / queueMicrotask
│ (直到队列清空) │
└──────┬───────────┘
↓
┌──────────────────┐
│ requestAnimationFrame │
└──────┬───────────┘
↓
┌──────────────────┐
│ 渲染(Layout/Paint)│
└──────┬───────────┘
↓
回到顶部
十一、一句话总结
JS 事件循环不是"异步怎么跑",而是"浏览器如何调度一切"。
- JS 单线程,靠宿主调度
- 宏任务是"大块工作"
- 微任务是"当前工作后的收尾"
- 渲染发生在微任务之后、下一轮宏任务之前
async/await本质是 Promise 微任务- 理解事件循环,本质是理解 执行时机
当你不再用"先同步后异步"这种模糊说法,而是能用 宏任务 / 微任务 / 渲染帧 来解释一段代码的执行顺序时,
你对 JS 的理解,就已经超过 80% 的前端开发者了。