你有没有遇到过这种情况:代码里写了
setTimeout(fn, 0),心想这下该马上执行了吧?结果发现,还是慢了一拍。还有,为什么Promise比setTimeout先执行?async/await到底在等什么?
今天,用餐厅点餐的故事,来讲讲 JavaScript 事件循环。
原文地址
墨渊书肆/setTimeout设为0就马上执行?JS异步背后的秘密
为什么需要事件循环?
单线程的困境
JavaScript 是单线程的------同一时间只能做一件事。
就像只有一个厨师的小餐厅:如果厨师做完一道菜才接下一单,客人等得头发都白了。
所以 JavaScript 采用了异步回调的方式:点完单先去干别的,菜好了再叫你。
事件循环就是"传唤员"
事件循环就像餐厅里的传唤员:
- 厨房做好了菜,传唤员看看单子,喊"33号,你的菜好了"
- 如果你正在吃饭(执行其他代码),传唤员就等着
- 轮到你的时候,你放下筷子(执行完当前代码),去取菜(执行回调)
调用栈 --- 厨师的工作台
代码是怎么"跑起来"的?
当你调用一个函数,这个函数就被放进调用栈里执行。
就像厨师在工作台上,一边做菜一边接新单,做完一单马上处理下一单:
javascript
function cooking() {
console.log('开始炒菜');
fry();
console.log('炒好了');
}
function fry() {
console.log('放油');
console.log('放菜');
console.log('翻炒');
}
cooking();
执行顺序:
yaml
调用栈:
1. cooking() 入栈
2. console.log('开始炒菜') 入栈,执行,出栈
3. fry() 入栈
4. fry() 内的 console.log 依次执行
5. fry() 出栈
6. console.log('炒好了') 入栈,执行,出栈
7. cooking() 出栈
调用栈的特点
- 后进先出:就像叠盘子,最后放上去的先被用
- 同步执行:每个函数必须执行完,下一个才能进来
- 栈溢出:如果递归没终止,栈会无限增长直到崩溃
javascript
// 栈溢出示例
function recursive() {
recursive();
}
recursive();
// RangeError: Maximum call stack size exceeded
任务队列 --- 取餐口
异步代码放哪儿?
当遇到 setTimeout、Promise、事件回调 这些异步任务 时,它们不会马上执行,而是被放到任务队列里。
就像点完单,服务员把单子放到取餐口,等叫号再去取。
事件循环的运行机制
yaml
┌─────────────────────┐
│ 调用栈 │ ← 正在执行
│ (Call Stack) │
└─────────────────────┘
↓
┌─────────────────────┐
│ 任务队列 │ ← 等待执行
│ (Task Queue) │
└─────────────────────┘
↓
事件循环 (Event Loop)
"栈空了?好,取下一个"
事件循环的规则:
- 首先执行调用栈里的所有同步代码
- 调用栈清空后,去任务队列取一个任务执行
- 完成后回到步骤1
javascript
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
console.log('3');
// 输出:1 → 3 → 2
// 因为 setTimeout 的回调在任务队列,要等调用栈空才能执行
微任务 vs 宏任务 --- VIP和普通号
两种不同的"队"
任务队列其实分两种:
| 类型 | 例子 | 优先级 |
|---|---|---|
| 宏任务(Macrotask) | setTimeout、setInterval、I/O、UI渲染 |
低 |
| 微任务(Microtask) | Promise.then()回调、MutationObserver、queueMicrotask |
高 |
就像餐厅里:
- 宏任务 = 普通取餐号,要排队
- 微任务 = VIP会员卡,来了直接优先处理
注意:不是 Promise 本身是微任务,而是 Promise.then() 的回调函数是微任务。
执行顺序
javascript
console.log('1');
setTimeout(() => {
console.log('2'); // 宏任务
}, 0);
Promise.resolve().then(() => {
console.log('3'); // 微任务
});
console.log('4');
// 输出:1 → 4 → 3 → 2
// 同步代码 → 微任务 → 宏任务
完整执行流程
javascript
setTimeout(() => console.log('setTimeout'), 0);
Promise.resolve()
.then(() => console.log('Promise1'))
.then(() => console.log('Promise2'));
Promise.resolve()
.then(() => console.log('Promise3'));
console.log('同步代码');
// 输出顺序:
// 同步代码
// Promise1
// Promise3
// Promise2 ← Promise.then 链式调用在同一个微任务队列
// setTimeout ← 所有微任务完成后,才执行宏任务
嵌套的 Promise
javascript
Promise.resolve().then(() => {
console.log('第一个微任务');
Promise.resolve().then(() => {
console.log('嵌套的微任务');
});
});
console.log('同步代码');
// 输出:
// 同步代码
// 第一个微任务
// 嵌套的微任务
// 微任务队列清空后,才会执行下一个宏任务
async/await --- 语法糖的秘密
async/await 是什么?
async/await 是 Promise 的语法糖,让异步代码看起来像同步代码。
javascript
// Promise 写法
function getData() {
return fetch('/api/user')
.then(res => res.json())
.then(data => console.log(data));
}
// async/await 写法
async function getData() {
const res = await fetch('/api/user');
const data = await res.json();
console.log(data);
}
await 到底在等什么?
await 会暂停当前 async 函数的执行,等待 Promise 完成,然后继续执行后面的代码。
暂停期间,其他代码可以继续执行:
javascript
async function example() {
console.log('1');
await fetch('/api/data'); // 这里"暂停"
console.log('3'); // ← 这行去哪了?
}
console.log('2');
example();
console.log('4');
// 输出:2 → 1 → 4 → 3
await 后面那行代码去哪了?
await 后面的代码不会马上执行,而是被包成一个微任务。等 await 的 Promise resolve 后,这个微任务才会执行:
javascript
async function example() {
console.log('1');
await fetch('/api/data'); // Promise pending...
// 下面的代码被包成微任务,要等 Promise 完成才执行
console.log('3'); // ← 这行实际上是 await 的 resolve 后的回调
}
// 等价于:
function example() {
console.log('1');
return fetch('/api/data').then(() => {
console.log('3'); // ← 这里
});
}
async 函数返回值
async 函数总是返回一个 Promise:
javascript
async function getNumber() {
return 42;
}
getNumber().then(console.log); // 42
// 等价于:
async function getNumber() {
return Promise.resolve(42);
}
错误处理
javascript
// try-catch
async function fetchData() {
try {
const res = await fetch('/api/data');
const data = await res.json();
} catch (error) {
console.log('出错了:', error);
}
}
// Promise catch
async function fetchData() {
const res = await fetch('/api/data').catch(err => console.log(err));
}
requestAnimationFrame --- 动画的正确姿势
为什么不用 setInterval?
setInterval 不保证什么时候执行,也不保证每次间隔精确:
javascript
setInterval(() => {
moveBall(); // 可能丢帧、卡顿
}, 16); // 约60fps,但不一定准
requestAnimationFrame 的特点
- 浏览器优化:在下一次重绘之前执行,不丢帧
- 页面不可见时:自动暂停,节省性能
- 约60fps:和屏幕刷新率同步
javascript
function animate() {
moveBall();
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
// 取消动画
const id = requestAnimationFrame(animate);
cancelAnimationFrame(id);
执行顺序
yaml
用户点击
↓
事件触发
↓
微任务(全部清空)← 先清空所有微任务
↓
宏任务
↓
requestAnimationFrame ← 所有微任务清空后,渲染之前
↓
浏览器渲染
深入了解事件循环 🔬
Node.js 的事件循环
Node.js 和浏览器的事件循环不一样:
md
┌───────────────────────────────────────────────────────┐
│ Node.js 事件循环 │
├───────────────────────────────────────────────────────┤
│ ① Timers → setTimeout, setInterval 回调 │
│ ② Pending I/O → I/O callbacks(延迟到下一循环) │
│ ③ Idle/Prepare → 内部使用 │
│ ④ Poll → 获取新 I/O 事件 │
│ ⑤ Check → setImmediate 回调 │
│ ⑥ Close → close 事件回调 │
└────────────────────────────────────────── ────────────┘
浏览器和 Node.js 的区别:
javascript
// 浏览器
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('microtask'));
// 输出:microtask → timeout
// Node.js(可能不同)
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('microtask'));
// 可能输出:microtask → timeout
// 但 setImmediate 可能更早
queueMicrotask vs Promise.then
queueMicrotask 显式创建一个微任务:
javascript
queueMicrotask(() => {
console.log('我也是微任务');
});
Promise.resolve().then(() => {
console.log('Promise微任务');
});
// 两者都是微任务,执行顺序相同
浏览器渲染时机
不是每次事件循环都会渲染,浏览器会批量处理:
javascript
// 可能只触发一次重排/重绘
div.style.top = '100px';
div.style.left = '100px';
div.style.width = '200px';
// 而不是三次单独的重排
任务分解 --- 避免卡顿
长时间任务可以分解,让页面保持响应:
javascript
function processItems(items) {
let i = 0;
function step() {
// 处理一项
process(items[i]);
i++;
if (i < items.length) {
// 用 setTimeout 让出主线程
setTimeout(step, 0);
}
}
step();
}
// 现代浏览器可以用 scheduler.yield()
async function processItems(items) {
for (const item of items) {
process(item);
await scheduler.yield(); // 让出主线程
}
}
横向对比
| API | 类型 | 优先级 | 使用场景 |
|---|---|---|---|
setTimeout |
宏任务 | 低 | 延迟执行、轮询 |
setInterval |
宏任务 | 低 | 定时任务(慎用) |
Promise.then |
微任务 | 高 | 异步结果处理 |
async/await |
微任务 | 高 | 异步代码写法 |
requestAnimationFrame |
宏任务 | 中 | 动画、游戏循环 |
MutationObserver |
微任务 | 高 | DOM 变化监听 |
怎么选?
| 场景 | 推荐 |
|---|---|
| 延迟执行 | setTimeout |
| 等待 Promise | await / Promise.then |
| 动画/游戏 | requestAnimationFrame |
| 批量 DOM 操作 | MutationObserver |
| 分解长任务 | setTimeout / scheduler.yield() |
总结
| 概念 | 像什么 | 作用 |
|---|---|---|
| 调用栈 | 厨师灶台 | 同步代码执行 |
| 任务队列 | 取餐口 | 等待执行的异步任务 |
| 宏任务 | 普通取餐号 | setTimeout、setInterval |
| 微任务 | VIP会员卡 | Promise、queueMicrotask |
| 事件循环 | 传唤员 | 协调调用栈和任务队列 |
同步代码 → 微任务 → 宏任务 → 渲染 → 下一轮
写在最后
现在你应该明白了:
setTimeout(fn, 0)不是马上执行,要等调用栈空、微任务清空后才轮到你Promise比setTimeout先执行,因为微任务优先级更高async/await只是 Promise 的语法糖,本质还是异步requestAnimationFrame是做动画的正确方式,别用 setInterval
下次你的代码执行顺序不对,先看看是微任务 还是宏任务------可能就是它插队了。