彻底搞懂 JavaScript 事件循环(Event Loop):宏任务、微任务与执行顺序详解

"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 更新后立刻读取"。用微任务(queueMicrotaskPromise.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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Flexbox 还是 Grid?2026 前端布局终极选择指南(附决策清单)

2026-5-14 16:32:30

技术教程

Ollama 完全上手指南:在本地免费跑 DeepSeek、Llama 等大模型

2026-5-14 16:32:31

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索