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

"JavaScript 是单线程的,那它为什么不会被一个耗时操作卡死?""setTimeout(fn, 0) 为什么不是立刻执行?""为什么 Promise 总比 setTimeout 先输出?"——这些问题的答案,全都指向同一个机制:事件循环(Event Loop)

这是 JavaScript 里最值得花一小时彻底搞懂的概念。搞懂了,异步代码的执行顺序你能直接推演出来,面试被追问也不慌,实际开发里遇到诡异的时序 bug 也知道往哪查。这篇就把它从头讲透。

为什么 JS 是单线程,却不会被卡死

JavaScript 的主线程只有一条——同一时刻只能干一件事。但浏览器(或 Node)本身不是单线程的,它还有专门处理网络请求、定时器、文件读写的其他线程。

关键就在这里:那些耗时的操作(发请求、等定时器),JS 主线程不会傻等,它把这些活交给浏览器的其他线程去办,自己继续往下跑。等那些活办完了,结果会被放进一个"队列"里排队,等主线程空闲了再回来处理。这个"交出去—继续跑—办完了排队—回头处理"的循环,就是事件循环。所以 JS 不会被卡死的原因是:它从不等待,只是把后续工作排进队列。

三个角色:调用栈、任务队列、事件循环

理解事件循环,先认清三个角色:

  • 调用栈(Call Stack):主线程当前正在执行的代码。函数调用进栈,执行完出栈。同一时刻只有栈顶在跑。
  • 任务队列(Task Queue):那些"已经办完、等着被主线程处理"的回调,在这里排队。它其实分两种,下面细说。
  • 事件循环(Event Loop):一个永不停止的"调度员"。它的工作只有一句话:当调用栈空了,就从队列里取出下一个任务,推进栈里执行。

就这么简单。复杂的只是"队列里的任务谁先谁后"——这就引出了宏任务和微任务。

宏任务 vs 微任务

任务队列其实是两条队列,优先级不同:

  • 宏任务(Macrotask):setTimeoutsetIntervalsetImmediate(Node)、I/O、UI 渲染、整体的 script 代码。
  • 微任务(Microtask):Promise.then/catch/finallyawait 之后的代码、queueMicrotaskMutationObserver

核心规则,记死这一条:每执行完一个宏任务,事件循环会把当前所有的微任务一次性清空,然后才去取下一个宏任务。换句话说,微任务"插队"——它总是优先于下一个宏任务执行。这就是为什么 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

推演:同步部分输出 startend,中间 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 更新后立刻读取":用微任务(queueMicrotaskPromise.resolve().then)能在当前宏任务结束、下次渲染前精确插入逻辑。
  • 看懂框架源码:Vue 的 nextTick、React 的调度,底层都建立在对宏/微任务的运用上。

面试高频追问

Q:setTimeout(fn, 0) 真的是 0 毫秒后执行吗?不是。它只是"尽快"加入宏任务队列,但要等当前宏任务跑完、所有微任务清空,才轮得到它。而且浏览器对嵌套的 setTimeout 有最小 4ms 的限制。

Q:Node 的事件循环和浏览器一样吗?大方向一致(宏任务、微任务、循环调度),但 Node 的宏任务阶段分得更细(timers、poll、check 等),还多了 process.nextTick 这个比普通微任务优先级还高的队列。基础概念通用,细节有差异。

Q:为什么微任务优先级比宏任务高?设计上,微任务是为"当前任务的收尾工作"准备的——它应该在让出控制权(去渲染、去处理下一个事件)之前尽快做完,保证状态的一致性。

写在最后

事件循环的全部精髓,其实就三句话:调用栈空了,事件循环才开工;每个宏任务跑完,清空全部微任务;微任务里产生的微任务,同一轮一起清。

把这三句话记住,再把上面三段代码亲手推演一遍——以后无论是面试题还是真实的异步时序问题,你都能像放慢镜头一样,一帧一帧把它的执行过程"看"出来。

—— 别看了 · 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管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索