"JavaScript 是单线程的,那它为什么不会被一个耗时操作卡死?""setTimeout(fn, 0) 为什么不是立刻执行?""为什么 Promise 总比 setTimeout 先输出?"——这些问题的答案,全都指向同一个机制:事件循环(Event Loop)。
这是 JavaScript 里最值得花一小时彻底搞懂的概念。搞懂了,异步代码的执行顺序你能直接推演出来,面试被追问也不慌,实际开发里遇到诡异的时序 bug 也知道往哪查。这篇就把它从头讲透。
为什么 JS 是单线程,却不会被卡死
JavaScript 的主线程只有一条——同一时刻只能干一件事。但浏览器(或 Node)本身不是单线程的,它还有专门处理网络请求、定时器、文件读写的其他线程。
关键就在这里:那些耗时的操作(发请求、等定时器),JS 主线程不会傻等,它把这些活交给浏览器的其他线程去办,自己继续往下跑。等那些活办完了,结果会被放进一个"队列"里排队,等主线程空闲了再回来处理。这个"交出去—继续跑—办完了排队—回头处理"的循环,就是事件循环。所以 JS 不会被卡死的原因是:它从不等待,只是把后续工作排进队列。
三个角色:调用栈、任务队列、事件循环
理解事件循环,先认清三个角色:
- 调用栈(Call Stack):主线程当前正在执行的代码。函数调用进栈,执行完出栈。同一时刻只有栈顶在跑。
- 任务队列(Task Queue):那些"已经办完、等着被主线程处理"的回调,在这里排队。它其实分两种,下面细说。
- 事件循环(Event Loop):一个永不停止的"调度员"。它的工作只有一句话:当调用栈空了,就从队列里取出下一个任务,推进栈里执行。
就这么简单。复杂的只是"队列里的任务谁先谁后"——这就引出了宏任务和微任务。
宏任务 vs 微任务
任务队列其实是两条队列,优先级不同:
- 宏任务(Macrotask):
setTimeout、setInterval、setImmediate(Node)、I/O、UI 渲染、整体的 script 代码。 - 微任务(Microtask):
Promise.then/catch/finally、await之后的代码、queueMicrotask、MutationObserver。
核心规则,记死这一条:每执行完一个宏任务,事件循环会把当前所有的微任务一次性清空,然后才去取下一个宏任务。换句话说,微任务"插队"——它总是优先于下一个宏任务执行。这就是为什么 Promise 总比 setTimeout 先输出。
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
Promise.resolve().then(() => {
console.log('3');
});
console.log('4');
// 输出顺序:1 4 3 2
推演一下:console.log(1) 同步执行 → 输出 1;setTimeout 的回调进宏任务队列;Promise.then 的回调进微任务队列;console.log(4) 同步执行 → 输出 4。同步代码跑完(第一个宏任务结束),清空微任务 → 输出 3。再取下一个宏任务 → 输出 2。所以是 1 4 3 2。
经典执行顺序题:一步步推演
再看一道连续 then 的题,关键是理解"链式 then 每一环都是一个独立的微任务":
console.log('start');
setTimeout(() => console.log('timeout'), 0);
Promise.resolve()
.then(() => console.log('promise 1'))
.then(() => console.log('promise 2'));
console.log('end');
// 输出:start end promise 1 promise 2 timeout
推演:同步部分输出 start、end,中间 setTimeout 进宏任务队列、第一个 .then 进微任务队列。同步结束,清微任务:执行第一个 then → 输出 promise 1,它的执行又产生了第二个 then,这个新微任务会在本轮微任务清空过程中继续被执行 → 输出 promise 2。微任务彻底清空后,才轮到宏任务 → 输出 timeout。
这里的易错点:微任务在执行过程中产生的新微任务,会在同一轮里一起被清掉,不会留到下一轮。所以理论上,如果微任务一直产生新微任务,宏任务会被饿死——这也是不该在微任务里写死循环的原因。
await 在事件循环里到底做了什么
async/await 是 Promise 的语法糖,理解它在事件循环里的行为,关键是这句话:await 右边的表达式是同步执行的,但 await 那一行之后的代码,等价于被塞进了 .then() 里——也就是一个微任务。
async function foo() {
console.log('A');
await bar(); // await 之后的代码,等价于塞进 .then() 里
console.log('C'); // 这一行其实是一个微任务
}
function bar() {
console.log('B');
}
console.log('start');
foo();
console.log('end');
// 输出:start A B end C
推演:console.log(start) → start;调用 foo() 进入函数,console.log(A) → A;遇到 await bar(),先同步执行 bar() → B,然后 await 让出控制权,console.log(C) 被挂起成微任务;回到外层,console.log(end) → end;同步结束,清微任务 → C。所以是 start A B end C。
实战意义:这些知识能帮你解决什么
事件循环不是纯理论,它在实战里很有用:
- 解释诡异的时序 bug:"我明明先 setState 了,为什么读到的还是旧值"——很多框架的更新是异步批处理的,理解了任务队列就懂了。
- 用对工具拆分长任务:一段计算会卡住页面,可以用
setTimeout把它切成多个宏任务,给浏览器渲染和响应用户操作的机会。 - 需要"DOM 更新后立刻读取":用微任务(
queueMicrotask或Promise.resolve().then)能在当前宏任务结束、下次渲染前精确插入逻辑。 - 看懂框架源码:Vue 的
nextTick、React 的调度,底层都建立在对宏/微任务的运用上。
面试高频追问
Q:setTimeout(fn, 0) 真的是 0 毫秒后执行吗?不是。它只是"尽快"加入宏任务队列,但要等当前宏任务跑完、所有微任务清空,才轮得到它。而且浏览器对嵌套的 setTimeout 有最小 4ms 的限制。
Q:Node 的事件循环和浏览器一样吗?大方向一致(宏任务、微任务、循环调度),但 Node 的宏任务阶段分得更细(timers、poll、check 等),还多了 process.nextTick 这个比普通微任务优先级还高的队列。基础概念通用,细节有差异。
Q:为什么微任务优先级比宏任务高?设计上,微任务是为"当前任务的收尾工作"准备的——它应该在让出控制权(去渲染、去处理下一个事件)之前尽快做完,保证状态的一致性。
写在最后
事件循环的全部精髓,其实就三句话:调用栈空了,事件循环才开工;每个宏任务跑完,清空全部微任务;微任务里产生的微任务,同一轮一起清。
把这三句话记住,再把上面三段代码亲手推演一遍——以后无论是面试题还是真实的异步时序问题,你都能像放慢镜头一样,一帧一帧把它的执行过程"看"出来。
—— 别看了 · 2026