事件循环与任务队列
JavaScript 同一时刻只跑一段用户代码(单线程)。异步不是「多线程一起算」,而是:当前栈跑完 → 从队列里取下一个任务。浏览器里线程怎么拆,见 浏览器架构;本篇只讲 JS 能观察到的调度。
Promise / async 怎么进微任务,见 Promise 与异步错误。Node 与浏览器宿主差在哪,见 Node 与浏览器运行时。
解决什么问题
| 痛点 | 事件循环怎么解释 |
|---|---|
setTimeout(fn, 0) 不是立刻跑 | 定时器回调进宏任务,要等当前栈 + 微任务清空 |
Promise.then 比 setTimeout 先 | then 进微任务,同一轮里微任务排在宏任务前 |
| 长循环卡住点击 / 渲染 | JS 跑着时渲染插不进去;栈不空就没有下一帧 |
| 面试「输出顺序」 | 先同步,再微任务,再宏任务;看清第几轮 |
一句话:调用栈执行当前任务;事件循环在栈空时,先清空微任务,再取一个宏任务。
三件套
调用栈(Call Stack) 正在执行的函数帧
宏任务队列(Task / Macrotask) script、setTimeout、setInterval、I/O、UI 事件
微任务队列(Microtask) Promise.then/catch/finally、queueMicrotask、MutationObserver
浏览器里还有 requestAnimationFrame(偏渲染帧,不要和 setTimeout(0) 混为一谈)以及 requestIdleCallback(空闲时,兼容性别当主路径)。
经典顺序(同一轮):
console.log("1 sync");
setTimeout(() => console.log("4 timeout"), 0);
Promise.resolve().then(() => console.log("3 microtask"));
console.log("2 sync");
// 1 sync → 2 sync → 3 microtask → 4 timeout
一轮长什么样
1. 取出一个宏任务(第一次是整份 script)
2. 跑完调用栈
3. 清空微任务队列(期间 then 又 enqueue 的微任务也要跑完)
4. 必要时渲染
5. 取下一个宏任务 → 回到 2
await 后面的代码等价于 then 回调,因此也是微任务。不要背「await 是宏任务」。
async function f() {
console.log("a");
await null;
console.log("c");
}
f();
console.log("b");
// a → b → c
和浏览器线程的关系
浏览器架构 里:定时器在计时线程到期后,把回调丢进任务队列;网络在请求线程完成后同样入队。真正执行回调的仍是 JS 引擎线程。
所以:
setTimeout的延迟是「最早何时入队」,不是「保证此时执行」- 主线程被
while (true)占满时,到期的定时器也跑不了 - HTML 规定嵌套
setTimeout有 4ms 下限,0不是 0
Node 的阶段直觉(面试够用)
浏览器用「宏/微」就够答。Node 的 event loop 还分 阶段(简化):
| 阶段 | 典型来源 |
|---|---|
| timers | setTimeout / setInterval |
| poll | I/O 回调(读文件、socket) |
| check | setImmediate |
| close | socket 关闭等 |
process.nextTick 不是 普通微任务:它比 Promise.then 更早、在当前操作之后立刻排空。生产里少用 nextTick 递归,容易饿死 I/O。
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
Promise.resolve().then(() => console.log("then"));
process.nextTick(() => console.log("tick"));
// 常见:tick → then → timeout/immediate(后两者谁先与进入 loop 的时机有关,别赌死顺序)
排障口令
| 现象 | 先怀疑 |
|---|---|
| 页面掉帧、点击延迟 | 同步长任务;用拆分 / Worker,而不是再套一层 setTimeout 当多线程 |
| 「定时器不准」 | 主线程忙;延迟只是入队下限 |
| Promise 里套 Promise,日志顺序乱 | 先画栈,再标微任务轮次 |
Node 里 nextTick 循环占满 | 微任务/nextTick 饿死 poll |
要点
- 单线程 = 同一时刻只有一个调用栈;异步靠队列
- 每轮:一个宏任务 → 清空微任务 →(浏览器)渲染
then/await后续是微任务;setTimeout是宏任务- Node 多阶段 +
nextTick优先;不要用「浏览器口令」硬套setImmediate顺序
面试速记
- 为什么 JS 是单线程还能异步? 执行线程只有一条;I/O 和定时器在宿主完成后再把回调入队,由事件循环调度。
- 微任务和宏任务谁先? 当前宏任务结束后先清空微任务,再取下一个宏任务。
setTimeout(fn, 0)做什么? 尽快作为下一个宏任务跑,不是插队到同步代码前面。- 这段
console顺序怎么排? 先同步,再then/await,再setTimeout;嵌套then仍在微任务队列里继续排。 - 卡死页面的本质? 调用栈不空,渲染和下一轮任务都进不来。