"JavaScript 是单线程的,那它为什么不会被一个耗时操作卡死?" "setTimeout(fn, 0) 为什么不是立刻执行?" "为什么 Promise 总比 setTimeout 先输出?" "为什么我明明先 setState 了,读到的还是旧值?" —— 这些问题的答案,全都指向同一个机制:事件循环(Event Loop)。
这是 JavaScript 里最值得花一小时彻底搞懂的概念。搞懂了,异步代码的执行顺序你能像放慢镜头一样一帧一帧推演出来,面试被追问也不慌,实际开发里遇到诡异的时序 bug 也知道往哪儿查。这篇会把它从头讲透:从"为什么单线程不卡死"开始,到三个核心角色、宏任务微任务、经典执行顺序题的逐步推演、await 的真相、Node 与浏览器的差异,再到这套知识在实战里到底能帮你解决什么。文章偏长,但建议把里面的代码都亲手推演一遍 —— 这是真正"内化"它的唯一办法。
为什么 JS 是单线程,却不会被卡死
先破除一个常见的混淆。JavaScript 的主线程确实只有一条 —— 同一时刻,它只能执行一件事(这是为了避免多线程操作 DOM 带来的混乱,是个有意的设计)。但是,运行 JavaScript 的宿主环境(浏览器、Node)本身不是单线程的:浏览器还有专门处理网络请求的线程、处理定时器的线程、处理文件读写的线程。
关键就在这里。当 JS 主线程遇到一个耗时操作 —— 发一个网络请求、设一个定时器 —— 它不会傻等。它把这件耗时的活,交给宿主环境的其他线程去办,自己立刻继续往下执行后面的代码。等那些活办完了,办的结果(对应的回调函数)会被放进一个"队列"里排队。等主线程把手头的事干完、空闲下来,再回过头来,从队列里取出这些排队的回调来执行。
这个"遇到耗时操作就交出去 → 自己继续跑 → 别人办完了排进队列 → 自己空了再回头处理"的循环,就是事件循环。所以 JS 不会被卡死的真正原因是:它从不"等待",它只是把"等待的结果"安排进了队列。
反过来说,什么时候 JS 真的会卡死?当你写了一个同步的死循环、或者一段同步的超重计算 —— 这种代码不会"交出去",它会一直占着主线程,事件循环根本没机会运转,页面就真的卡死了。所以"异步的耗时操作不卡页面,同步的重计算才卡页面",这是理解事件循环的第一个重要结论。
三个核心角色:调用栈、任务队列、事件循环
理解事件循环,先认清三个角色,它们的关系是这样的:
事件循环的三个角色:
┌─────────────┐ 函数调用进栈、执行完出栈。
│ 调用栈 │ 同一时刻只有栈顶在跑。栈空了,事件循环才有机会工作。
│ Call Stack │
└─────────────┘
▲ 取任务推进栈
│
┌─────────────┐ "已经办完、等着被主线程处理"的回调在这里排队。
│ 任务队列 │ 它其实分两条:宏任务队列 + 微任务队列。
│ Task Queues │
└─────────────┘
▲ 调度
│
┌─────────────┐ 一个永不停止的"调度员"。它只干一件事:
│ 事件循环 │ 调用栈一空,就从队列里取下一个任务推进栈。
│ Event Loop │
└─────────────┘
- 调用栈(Call Stack):主线程当前正在执行的代码。函数被调用就进栈,执行完就出栈。同一时刻,只有栈顶的那个函数在跑。这就是"单线程"的体现。
- 任务队列(Task Queue):那些"已经办完、等着被主线程处理"的回调,在这里排队。它其实不止一条队列 —— 至少分"宏任务队列"和"微任务队列",这是后面的重点。
- 事件循环(Event Loop):一个永不停止的"调度员"。它的工作只有一句话能概括:盯着调用栈,一旦栈空了,就从队列里取出下一个任务,推进栈里去执行。
就这么简单。整个机制的骨架,就是这三个角色的配合。真正复杂(也真正有用)的,只是"队列里的任务,谁先谁后" —— 这就引出了宏任务和微任务。
宏任务 vs 微任务:最核心的一条规则
前面说"任务队列"其实是多条队列。最关键的是把任务分成两类:宏任务和微任务。
宏任务 vs 微任务:
宏任务(Macrotask):
setTimeout / setInterval / setImmediate(Node)
I/O、UI 渲染、整体的 <script> 代码
微任务(Microtask):
Promise.then / catch / finally
await 之后的代码
queueMicrotask、MutationObserver
★ 核心规则,记死这一条:
每执行完【一个】宏任务,事件循环会把【当前所有】微任务
一次性清空,然后才去取下一个宏任务。
—— 微任务"插队",它总是优先于下一个宏任务。
你需要记住的核心规则,只有一条:每执行完一个宏任务,事件循环会把当前所有的微任务一次性清空,然后才去取下一个宏任务。
换句话说:微任务会"插队"。它总是优先于"下一个宏任务"被执行。这就是为什么 Promise(微任务)总是比 setTimeout(宏任务)先输出 —— 哪怕 setTimeout 的延时写的是 0。
用第一段代码验证一下:
console.log('1');
setTimeout(() => { console.log('2'); }, 0);
Promise.resolve().then(() => { console.log('3'); });
console.log('4');
// 输出顺序:1 4 3 2
逐步推演:整段 <script> 代码本身,就是第一个宏任务。在执行这个宏任务的过程中 —— console.log(1) 同步执行,输出 1;setTimeout 的回调被放进宏任务队列;Promise.then 的回调被放进微任务队列;console.log(4) 同步执行,输出 4。同步代码跑完 —— 第一个宏任务结束了。这时事件循环按规则:先清空所有微任务 → 执行那个 then,输出 3。微任务清空后,才去取下一个宏任务 → 执行 setTimeout 回调,输出 2。所以是 1 4 3 2。
经典执行顺序题:一步步推演
再看一道连续 then 的题。这道题的关键,是理解"链式 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 进微队列
// 同步结束(第一个宏任务"整体script"结束)→ 清微任务:
// 执行第一个 then → promise 1,它又产生了第二个 then(新微任务)
// 新微任务在【本轮】继续被清 → promise 2
// 微任务彻底清空 → 才取下一个宏任务 → timeout
这里的易错点必须强调:微任务在执行过程中产生的新微任务,会在同一轮里被继续清掉,不会留到下一轮。所以 promise 2 是紧跟着 promise 1 输出的,而不是等到 timeout 之后。
这也带出一个重要的推论:如果微任务一直产生新的微任务,宏任务就会被"饿死" —— 永远轮不到下一个宏任务,页面渲染也会被卡住(渲染是在宏任务之间进行的)。这就是为什么"绝对不要在微任务里写死循环、或者递归地产生微任务"。
await 在事件循环里到底做了什么
async / await 是 Promise 的语法糖,很多人觉得它"玄",其实理解它在事件循环里的行为,只需要记住一句话:await 右边的表达式是同步执行的;但 await 那一行之后的代码,等价于被塞进了一个 .then() 里 —— 也就是一个微任务。
async function foo() {
console.log('A');
await bar(); // await 右边同步执行;await 之后的代码 = 一个微任务
console.log('C'); // 这一行其实被"塞进了 .then() 里"
}
function bar() { console.log('B'); }
console.log('start');
foo();
console.log('end');
// 输出:start A B end C
// 推演:start → 进 foo → A → 同步执行 bar() → B
// → 遇到 await,让出控制权,console.log('C') 挂成微任务
// → 回外层 → end → 同步结束 → 清微任务 → C
逐步推演这段:console.log('start') → 输出 start;调用 foo() 进入函数,console.log('A') → 输出 A;遇到 await bar(),先同步执行 bar() → 输出 B,然后 await 让出控制权,foo 函数里 await 之后的 console.log('C') 被"挂起"成一个微任务;控制权回到外层,console.log('end') → 输出 end;同步代码结束,清微任务 → 输出 C。所以是 start A B end C。
很多人会错以为 await 是"卡在这里等" —— 不是。await 的意思是"把这个 async 函数暂停在这里,把线程让出去;等被 await 的 Promise 完成了,再把「函数剩下的部分」作为一个微任务,排队等着继续执行"。理解了这个,你看任何 async/await 的执行顺序题都不会再懵。
把一轮完整的事件循环串起来
现在把所有碎片拼成一张完整的图 —— 浏览器一轮事件循环的完整顺序:
浏览器一轮事件循环的完整顺序(简化): 1. 取【一个】宏任务,执行它 2. 执行完后,清空【所有】微任务(包括清的过程中新产生的微任务) 3. (如果需要)进行一次渲染:样式计算、布局、绘制 4. 回到第 1 步,取下一个宏任务 关键体会: · 微任务在"渲染之前"被清空 —— 所以微任务里改 DOM,用户看到的是改完的结果 · 一个宏任务 + 它带出的所有微任务,才算"一帧的 JS 工作" · 微任务里如果无限产生新微任务,会把渲染和下一个宏任务"饿死"
这张图里有几个值得反复体会的点。第一,渲染是在"宏任务之后、下一个宏任务之前"进行的,而且在它之前微任务已经被清空了 —— 所以"在微任务里改 DOM",用户下一帧看到的就是改完的结果,不会有中间态。第二,"一个宏任务 + 它带出的所有微任务",合起来才是事件循环的"一拍"。第三,微任务无限增殖会饿死渲染 —— 前面说过。
顺便说一句:并不是每一轮事件循环都一定会渲染。浏览器会根据屏幕刷新率"按需"渲染 —— 通常约 16.6ms 一次。如果两个宏任务之间隔得很近,中间可能并不渲染。
Node 的事件循环:大同小异,但有区别
上面讲的是浏览器。Node.js 也有事件循环,大方向完全一致(宏任务、微任务、循环调度),但有几个区别值得知道:
- Node 的宏任务阶段分得更细。浏览器里宏任务大致就是一个队列;Node 把它分成了好几个"阶段"(timers、pending、poll、check、close 等),不同来源的回调进不同阶段。比如
setTimeout在 timers 阶段,setImmediate在 check 阶段。 - Node 多了一个
process.nextTick。它的优先级比普通微任务(Promise)还要高 —— 每个阶段切换之间,会先清空nextTick队列,再清空微任务队列。所以在 Node 里,process.nextTick>Promise.then> 其他。 - 这些差异主要影响"几个特定 API 之间的精确顺序",但"调用栈空了才处理队列、微任务优先于宏任务"这套核心模型,浏览器和 Node 是完全一致的。基础概念通用,细节有差异。
实战意义:这些知识能帮你解决什么
事件循环不是纯理论,搞懂它在实战里非常有用:
- 解释诡异的时序 bug。"我明明先
setState/ 先改了数据,为什么紧接着读到的还是旧值?" —— 很多框架的状态更新是异步批处理的,理解了"任务队列"你就懂了:你的"改"和"读"在同一个同步流程里,而真正的更新被排到了之后。 - 用对工具拆分长任务。一段重计算会卡住页面?可以用
setTimeout/MessageChannel把它切成多个宏任务,给浏览器在中间插入"渲染"和"响应用户操作"的机会 —— 这就是"时间切片"的基本原理。 - 需要"DOM 更新后立刻读取"。用微任务(
queueMicrotask或Promise.resolve().then)能在"当前宏任务结束、下次渲染之前"精确地插入逻辑。 - 看懂框架源码。Vue 的
nextTick(它就是基于微任务实现的,把多次数据变更合并到一次 DOM 更新)、React 的调度机制 —— 底层都建立在对宏/微任务的精确运用上。不懂事件循环,这些源码你看不进去。 - 避免"饿死渲染"。知道了微任务无限增殖会卡住渲染,你就不会写出那种"递归 Promise"导致页面假死的代码。
面试高频追问
Q:setTimeout(fn, 0) 真的是 0 毫秒后执行吗?不是。它只是"尽快"把回调加入宏任务队列,但要等当前宏任务跑完、所有微任务清空,才轮得到它。而且浏览器对嵌套的 setTimeout 有最小延时(嵌套层级深了大概 4ms)。"0" 只是"尽快",不是"立刻"。
Q:为什么微任务的优先级比宏任务高?设计上,微任务是为"当前任务的收尾工作"准备的 —— 它应该在"让出控制权去渲染、去处理下一个事件"之前尽快做完,以保证状态的一致性。如果一个 Promise resolve 之后,它的 .then 要等到好几个 setTimeout 之后才执行,那中间这段时间状态就是不一致的、不可预期的。
Q:requestAnimationFrame 算宏任务还是微任务?都不算,它是一个独立的、专门的回调队列,在"渲染之前"被调用。可以理解为:它的执行时机比普通宏任务更"贴近渲染"。这也是为什么动画相关的代码推荐用 rAF 而不是 setTimeout。
Q:同一个 Promise 多次 .then,和链式 .then 有区别吗?有。p.then(a); p.then(b); 是给同一个 Promise 注册两个回调,它们会在 Promise resolve 后被一起放进微队列;而 p.then(a).then(b) 是链式 —— b 要等 a 返回的那个新 Promise resolve 之后,才被注册成微任务。顺序和时机都不同。
再练两道:综合执行顺序题
光看不练假把式,再来一道"宏任务里有微任务、微任务里又有宏任务"的综合题,跟着推演一遍:
再来一道综合题:
console.log(1);
setTimeout(() => {
console.log(2);
Promise.resolve().then(() => console.log(3));
}, 0);
Promise.resolve().then(() => {
console.log(4);
setTimeout(() => console.log(5), 0);
});
console.log(6);
// 输出:1 6 4 2 3 5
// 推演:
// 同步:1、6;setTimeout(A) 进宏队列;then(B) 进微队列
// 同步结束 → 清微任务:执行 B → 打印 4,B 里的 setTimeout 进宏队列(记作 C)
// 微任务空了 → 取下一个宏任务 A → 打印 2,A 里的 then 进微队列(记作 D)
// A 这个宏任务结束 → 清微任务:执行 D → 打印 3
// 微任务空 → 取下一个宏任务 C → 打印 5
这道题的关键考点是:宏任务和微任务会"互相生出对方" —— 一个宏任务执行时可能产生新的微任务,一个微任务执行时也可能产生新的宏任务。但规则始终不变:任何一个宏任务执行完,立刻把当前所有微任务清空,再取下一个宏任务。你只要严格按这条规则,拿张纸把"宏队列""微队列"画出来、一步步往里加往外取,再绕的题也能推对。推演事件循环题的诀窍,就是手动维护那两个队列,别在脑子里硬想。
不止宏任务和微任务:几种"安排任务"的 API
除了 setTimeout(宏任务)和 Promise.then(微任务),浏览器还有几个"安排一段代码稍后执行"的 API,它们的执行时机各不相同,知道它们能让你在合适的场景用上合适的工具:
几种"安排任务"的 API,执行时机各不相同: 同步代码 立即,在当前调用栈里 queueMicrotask 本宏任务结束后,清微任务时 Promise.then 同上(微任务) requestAnimationFrame 下一次"渲染"之前 setTimeout(fn, 0) 作为一个新宏任务,排在后面 requestIdleCallback 浏览器"这一帧还有空闲时间"时才执行 requestIdleCallback 适合放"不紧急、能拖就拖"的活: 上报埋点、预加载、非关键的清理 —— 不和渲染抢这一帧的预算。
这里特别值得一提的是 requestIdleCallback —— 它会在浏览器"这一帧渲染完、还有空闲时间"的时候才执行你的回调。它适合放那些"不紧急、能拖就拖"的活:数据上报、资源预加载、非关键的缓存清理。把这类活用 requestIdleCallback 安排,就不会和"渲染""响应用户操作"去抢那宝贵的 16.6ms 帧预算 —— 这是"时间切片""不阻塞主线程"这类性能优化思路的一块重要拼图。理解了事件循环,你才知道这些 API 各自卡在时间线的哪个位置、该在什么场景用哪个。
写在最后
事件循环的全部精髓,其实就这么几句话,记住它你就真的搞懂了:
- JS 主线程从不"等待",它把耗时操作交出去,结果回调排进队列;调用栈空了,事件循环才从队列取任务。
- 队列分宏任务和微任务;每执行完一个宏任务,清空全部微任务,再取下一个宏任务。
- 微任务执行中产生的新微任务,在同一轮一起清掉 —— 所以它能"饿死"宏任务和渲染。
await不是"卡住等",是"暂停函数、让出线程,之后的代码作为微任务排队"。- 渲染发生在宏任务之间、且在微任务清空之后。
把这几句记住,再把这篇里每一段代码都亲手推演一遍 —— 以后无论是面试题,还是真实项目里那些"为什么这行代码的时机不对"的 bug,你都能像放慢镜头一样,一帧一帧地把它的执行过程"看"出来。这种"看得见执行过程"的能力,是区分"会写 JS"和"懂 JS"的一道分水岭。
—— 别看了 · 2026