前端事件循环与微任务:从 setTimeout 到 Promise 的底层执行顺序
前言:为什么你需要彻底搞懂事件循环?
打开任何一家公司的前端面试题库,事件循环永远排在前 5。但很多人对它的理解停留在「setTimeout 是异步,Promise 也是异步」这一层,一旦把两者混在一起写出代码,输出顺序就开始玄学:
console.log(1);setTimeout(() => console.log(2), 0);Promise.resolve().then(() => console.log(3));console.log(4);// 输出 1 4 3 2为什么 3 在 2 前面?setTimeout(fn, 0) 不是「立刻执行」吗?await 后面那行到底算同步还是异步?queueMicrotask 和 Promise.then 到底什么关系?
本文将从浏览器与 Node 双视角出发,把宏任务(Task)、微任务(Microtask)、渲染时机、任务优先级、requestAnimationFrame、requestIdleCallback 这条主线全部串起来。读完本文,你不仅能解释上面那段代码,更能预测任何混用场景的输出顺序。
一、JS 是单线程的:这一切的前提
JavaScript 一开始就被设计成单线程语言——主线程同一时刻只能执行一段代码。这是为了避免多线程操作 DOM 时产生的竞态条件(比如两个线程同时改一个节点的文本)。
但单线程也意味着:如果一段同步代码死循环,后面的任务就永远卡住。所以 JS 必须有一套调度机制让耗时操作(IO、定时器、网络请求)“不阻塞”主线程。这套机制就是事件循环(Event Loop)。
核心思想:JS 引擎自己不轮询,而是宿主环境(浏览器 / Node)提供任务队列,引擎在同步代码执行完后,反复检查队列并取出任务执行。
二、任务的两层结构:宏任务与微任务
事件循环里跑的”任务”分为两层,优先级完全不同:
| 维度 | 宏任务(Task / Macrotask) | 微任务(Microtask) |
|---|---|---|
| 包含 | setTimeout、setInterval、setImmediate(Node)、I/O、UI 交互事件、<script> 解析 | Promise.then/catch/finally、queueMicrotask、MutationObserver、process.nextTick(Node 专属且优先级更高) |
| 队列 | 每个来源一个队列 | 只有一个微任务队列 |
| 数量 | 一次只取一个 | 当前任务结束后清空整个微任务队列 |
| 触发时机 | 主线程空闲时 | 当前宏任务结束之后、下一轮渲染之前 |
| 数量级 | 毫秒级(setTimeout 4ms 起步) | 微秒级 |
关键结论:每执行完一个宏任务,引擎都会把微任务队列里所有任务挨个执行完,然后才可能进入下一个宏任务或渲染。
这就是为什么 Promise.then 总是比 setTimeout(fn, 0) 先执行——后者要等下一轮宏任务,前者在当前宏任务末尾就跑。
三、一道经典题的逐步拆解
回到开头的代码,我们用上面的模型逐步推演:
console.log(1); // L1: 同步执行setTimeout(() => console.log(2), 0); // L2: 注册宏任务,0ms 后入队Promise.resolve().then(() => console.log(3)); // L3: 注册微任务console.log(4); // L4: 同步执行执行过程:
- 当前宏任务(整段 script)开始
- L1 同步:输出
1 - L2:把回调注册到宏任务队列
- L3:把回调注册到微任务队列
- L4 同步:输出
4 - 当前宏任务结束 → 查微任务队列
- 执行微任务:输出
3 - 微任务队列清空 → 渲染(如有需要)→ 取下一个宏任务
- 执行
setTimeout回调:输出2
输出:1 4 3 2。是不是清晰多了?
四、浏览器事件循环:完整的执行模型
浏览器的事件循环规范定义在 HTML Standard 中。一次”循环”大致包含这些步骤:
- 执行最旧的任务(可能来自多个任务源)
- 执行微任务队列中的所有微任务(微任务队列可被多次”刷新”)
- 如果有需要,渲染 UI(
requestAnimationFrame回调 + 样式计算 + 布局 + 绘制) - 如果是空闲时间段(超过 50ms 没任务),可能触发
requestIdleCallback - 返回第 1 步
4.1 渲染时机的关键:requestAnimationFrame
requestAnimationFrame(rAF)注册的回调在渲染之前、但在微任务之后执行。它不属于微任务,也不属于宏任务,而是有自己专属的”渲染步骤”。
console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);Promise.resolve().then(() => console.log('promise1'));requestAnimationFrame(() => console.log('rAF'));
Promise.resolve().then(() => { console.log('promise2'); queueMicrotask(() => console.log('inner microtask'));});
console.log('script end');输出顺序:
script startscript endpromise1promise2inner microtasksetTimeoutrAF为什么 rAF 在 setTimeout 之后?因为 setTimeout 的最小延迟是 4ms,rAF 在下一帧渲染前触发(一般 16.67ms / 60Hz 一次)。但当两者都在同一帧内排队时,rAF 仍然在当前微任务队列清空之后才执行。
4.2 一个反直觉的细节:微任务会阻塞渲染
如果在微任务里做大量计算,会推迟渲染——浏览器必须等所有微任务跑完才走渲染步骤:
button.addEventListener('click', () => { Promise.resolve().then(() => { // 1 秒的微任务 const start = performance.now(); while (performance.now() - start < 1000) {} console.log('微任务结束'); }); console.log('点击处理结束');});// 用户会感觉页面"卡了一下"——渲染被推迟了这是大型应用卡顿的常见原因之一。在微任务里做重活是反模式,应当用 requestIdleCallback 或 Web Worker。
五、Node.js 的事件循环:6 个阶段
Node 的事件循环与浏览器不同——它有 6 个明确的阶段,由 libuv 实现:
┌───────────────────────────┐┌─>│ timers │ ← setTimeout / setInterval 回调│ └─────────────┬─────────────┘│ ┌─────────────┴─────────────┐│ │ pending callbacks │ ← 系统操作回调(如 TCP 错误)│ └─────────────┬─────────────┘│ ┌─────────────┴─────────────┐│ │ idle, prepare │ ← 内部使用│ └─────────────┬─────────────┘│ ┌─────────────┴─────────────┐│ │ poll │ ← I/O 回调(核心阶段)│ └─────────────┬─────────────┘│ ┌─────────────┴─────────────┐│ │ check │ ← setImmediate 回调│ └─────────────┬─────────────┘│ ┌─────────────┴─────────────┐│ │ close callbacks │ ← socket.on('close') 等│ └───────────────────────────┘每次循环走到每个阶段,都会把该阶段队列里所有回调清空,然后在阶段之间检查并清空微任务队列(process.nextTick 和 Promise 微任务)。
5.1 setTimeout vs setImmediate
setTimeout(() => console.log('timeout'), 0);setImmediate(() => console.log('immediate'));// 顺序不定!取决于 Node 启动时机器负载但放在 I/O 回调里,顺序就确定:
const fs = require('fs');fs.readFile(__filename, () => { setTimeout(() => console.log('timeout'), 0); setImmediate(() => console.log('immediate')); // 永远 immediate 先(poll 阶段后立即到 check 阶段)});5.2 process.nextTick 的特殊地位
process.nextTick 不是微任务,但它有比微任务更高的优先级——每个阶段切换、每个 I/O 回调结束、甚至微任务队列清空时,都会先清空 nextTick 队列:
process.nextTick(() => console.log('nextTick'));Promise.resolve().then(() => console.log('promise'));// 输出 nextTick 在前但官方不推荐滥用 process.nextTick——递归 nextTick 会饿死 I/O,因为它每次都会插队。
六、async/await 的本质:微任务的语法糖
await 不是”等待”——它让出执行权,把后续代码注册成微任务。这一点很多初学者没意识到:
async function foo() { console.log('foo start'); await bar(); console.log('foo end'); // ← 这行被注册成微任务}async function bar() { console.log('bar');}console.log('script start');foo();console.log('script end');执行步骤:
- 同步:
script start - 同步:
foo start - 同步:
bar - await 让出,后续注册为微任务
- 同步:
script end - 微任务:
foo end
输出:script start → foo start → bar → script end → foo end
6.1 await 一个非 Promise 值
await 后跟非 Promise,会被 Promise.resolve() 包装:
async function test() { console.log(1); await 42; // 等价于 await Promise.resolve(42) console.log(2);}test();console.log(3);// 输出 1 3 26.2 错误处理的执行顺序
async function foo() { try { await Promise.reject('error'); } catch (e) { console.log('caught', e); } console.log('after catch');}foo();console.log('sync');// sync → caught error → after catchcatch 块也是同步执行的——await 抛错后回到函数体,后面的代码依然是微任务。
七、实战面试题:20 道预测输出
下面这些题是面试常客,每道都可以用**“宏任务 → 清空微任务 → 下一宏任务”**的口诀推演。先遮住答案自己写,再展开看:
7.1 基础混搭
Q1
console.log(1);setTimeout(() => console.log(2));Promise.resolve().then(() => console.log(3));queueMicrotask(() => console.log(4));console.log(5);答案
1 5 3 4 2
queueMicrotask 和 Promise.then 都在微任务队列里,按注册顺序执行。
Q2
setTimeout(() => console.log(1), 0);new Promise(resolve => { console.log(2); resolve();}).then(() => console.log(3));console.log(4);答案
2 4 3 1
new Promise(executor) 的 executor 是同步执行。
7.2 async/await 派
Q3
async function f() { console.log(1); await f();}f().catch(() => console.log('error'));答案
死循环。await f() 会立即调用 f(),触发新的 f(),再 await 新的 f()……同步代码段里其实没爆栈,因为每次 await 都让出并返回新 Promise,调用栈清空。微任务队列无限增长,最终栈不溢出但内存爆掉。
Q4
async function foo() { return 1; // 包装成 Promise.resolve(1)}console.log(foo()); // 同步执行答案
Promise {<fulfilled>: 1},因为 foo 同步执行到 return,被 async 包装。
7.3 Node 专属
Q5
setImmediate(() => console.log('immediate'));setTimeout(() => console.log('timeout'), 0);process.nextTick(() => console.log('nextTick'));Promise.resolve().then(() => console.log('promise'));答案
nextTick → promise → timeout/immediate(顺序不定)
nextTick 优先级最高;setTimeout 与 setImmediate 在主模块中顺序依赖机器负载。
Q6
async function async1() { console.log('async1 start'); await async2(); console.log('async1 end');}async function async2() { console.log('async2');}console.log('script start');setTimeout(() => console.log('setTimeout'), 0);async1();new Promise(resolve => { console.log('promise1'); resolve();}).then(() => console.log('promise2'));console.log('script end');答案
script start → async1 start → async2 → promise1 → script end → async1 end → promise2 → setTimeout
async1 end 在 promise2 之前——await async2() 内部产生的微任务比显式 .then 更早入队。这是 Node 18+ 的行为。
7.4 queueMicrotask vs Promise
Q7
queueMicrotask(() => console.log('m1'));queueMicrotask(() => console.log('m2'));Promise.resolve().then(() => console.log('p1'));答案
m1 → m2 → p1
queueMicrotask 创建一个新的微任务,与 Promise 微任务在同一队列,按入队顺序。
7.5 渲染时序
Q8(浏览器)
console.log('a');requestAnimationFrame(() => console.log('b'));queueMicrotask(() => console.log('c'));setTimeout(() => console.log('d'), 0);答案
a → c → d → b
rAF 在渲染前,最晚执行。
Q9
button.addEventListener('click', () => { console.log('handler'); Promise.resolve().then(() => console.log('microtask')); setTimeout(() => console.log('macrotask'), 0);});答案
点击时:handler → microtask → macrotask(事件回调是宏任务,回调内再注册的两者按微任务优先)。
7.6 进阶陷阱
Q10
const p = Promise.resolve();p.then(() => console.log(1));p.then(() => console.log(2));p.then(() => console.log(3));答案
1 2 3——同一个 Promise 多次 .then 创建多个微任务,各自独立。
Q11
Promise.resolve().then(() => { return Promise.resolve(42); // 返回 Promise}).then(v => console.log(v));答案
输出 42,但多一轮微任务——.then 回调返回 Promise 时,下一个 .then 会等该 Promise resolve 后才执行。
Q12
async function t1() { await t2(); console.log('t1');}async function t2() { console.log('t2');}t1();Promise.resolve().then(() => console.log('p1'));console.log('sync');答案
t2 → sync → t1 → p1(Node 18+),或 t2 → sync → p1 → t1(旧 V8)。
Node 18+ 把 await 优化为”快速路径”——非 thenable 直接同步推进。这块是 V8 内部的微优化,详细见 V8 博客。
Q13
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
执行到 setTimeout 回调时,里面又注册了 Promise 微任务,这个微任务会在当前宏任务结束时清空。
Q14(Node I/O)
const fs = require('fs');fs.readFile(__filename, () => { setTimeout(() => console.log('timeout'), 0); setImmediate(() => console.log('immediate')); process.nextTick(() => console.log('nextTick')); Promise.resolve().then(() => console.log('promise'));});答案
nextTick → promise → immediate → timeout
I/O 回调内:setImmediate 永远在下个 check 阶段跑;setTimeout 进入下轮 timers 阶段。
Q15(Promise 链)
Promise.resolve(1) .then(v => { console.log(v); return v + 1; }) .then(v => { console.log(v); return v + 1; }) .then(v => { console.log(v); });答案
1 2 3,每个 .then 是新微任务。
Q16(错误处理)
Promise.resolve() .then(() => { throw new Error('A'); }) .catch(e => console.log('catch', e.message)) .then(() => console.log('after catch'));答案
catch A → after catch,catch 后链不断,继续往下。
Q17(MutationObserver)
const obs = new MutationObserver(() => console.log('mutation'));obs.observe(document.body, { childList: true });document.body.appendChild(document.createElement('div'));Promise.resolve().then(() => console.log('promise'));答案
promise → mutation,MutationObserver 回调在微任务末尾执行。
Q18(queueMicrotask 抛错)
queueMicrotask(() => { throw new Error('micro error');});queueMicrotask(() => console.log('next'));答案
next 仍会执行——一个微任务抛错不会中断微任务队列(但会冒泡到 unhandledrejection)。
Q19(setTimeout 嵌套)
setTimeout(() => { console.log(1); setTimeout(() => console.log(2), 0);}, 0);Promise.resolve().then(() => console.log(3));答案
3 1 2——内层 setTimeout 要等下一轮 timers 阶段。
Q20(终极综合题)
async function async1() { console.log('async1 start'); await async2(); console.log('async1 end');}async function async2() { console.log('async2');}console.log('script start');setTimeout(() => console.log('setTimeout'), 0);async1();new Promise(resolve => { console.log('promise1'); resolve();}).then(() => console.log('promise2'));console.log('script end');答案
script start → async1 start → async2 → promise1 → script end → async1 end → promise2 → setTimeout
参见 jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/——这是 Jake Archibald 出名的同款题。
八、性能优化:用对任务时机
理解事件循环不只是为了面试,更是性能优化的关键武器:
8.1 长任务拆分:把同步代码切成宏任务
function bigTask(items) { return new Promise(resolve => { let i = 0; function chunk() { const start = performance.now(); while (i < items.length && performance.now() - start < 5) { process(items[i++]); } if (i < items.length) { setTimeout(chunk, 0); // 让出主线程 } else { resolve(); } } chunk(); });}每 5ms 让出一次,保证主线程响应。也可以用 scheduler.yield()(Chrome 129+ 实验 API)或 MessageChannel 模拟更精确的”yield”。
8.2 动画 / 高频事件用 rAF
let ticking = false;window.addEventListener('scroll', () => { if (!ticking) { requestAnimationFrame(() => { updatePosition(); ticking = false; }); ticking = true; }});把 scroll/resize 处理放在 rAF 里,与浏览器渲染节奏对齐,避免布局抖动(layout thrashing)。
8.3 用微任务实现”同步感的异步”
// 状态批量更新function setState(newState) { state = newState; if (!pending) { pending = true; queueMicrotask(() => { flushState(); pending = false; }); }}在同一事件循环 tick 内多次 setState 只会触发一次 flushState,避免重渲染。Vue 3 的 scheduler 和 React 18 的 batchUpdates 都是这思路。
8.4 避免微任务死循环
// ❌ 死循环function recurse() { Promise.resolve().then(recurse);}recurse();
// ✅ 改用 setTimeoutfunction recurse(i) { if (i < 1000) setTimeout(() => recurse(i + 1), 0);}recurse(0);九、调试技巧:可视化任务执行
在 Chrome DevTools 的 Performance 面板录制一段操作,可以看到:
- Main 线程的火焰图
- 黄色块 = 脚本执行
- 紫色块 = 渲染/布局/绘制
- 绿色块 = 绘制后
- 灰底 = 任务
任务之间的细缝就是微任务队列清空 + 渲染 + 等待新任务的窗口。
另一个工具:在代码里打点:
function trace(label) { console.log(`${performance.now().toFixed(2)}ms [${label}]`);}
trace('script start');setTimeout(() => trace('timeout'), 0);Promise.resolve().then(() => trace('microtask'));trace('script end');输出会清晰展示每个时间点的任务归属。
十、总结:一张图记住核心模型
┌────────────── 事件循环 ──────────────┐ │ │ │ ┌─ 当前宏任务 ─┐ │ │ │ script / I/O│ │ │ │ setTimeout │ │ │ │ event │ │ │ └──────┬───────┘ │ │ ↓ │ │ ┌─ 清空微任务队列 ─┐ │ │ │ Promise.then │ │ │ │ queueMicrotask │ │ │ │ MutationObserver │ │ │ process.nextTick (Node) │ │ └──────┬──────────┘ │ │ ↓ │ │ ┌─ 渲染(仅浏览器)──┐ │ │ │ requestAnimationFrame │ │ │ 样式 → 布局 → 绘制 │ │ └──────┬──────────┘ │ │ ↓ │ │ 回到顶端取下一个宏任务 │ └───────────────────────────────────────┘五条铁律:
- 微任务优先级永远高于下一个宏任务——这是顺序预测的根。
await不是等待,是让出——后续代码必为微任务。new Promise(executor)的 executor 是同步——只注册then才是微任务。- rAF 在渲染前——不阻塞微任务,但微任务会阻塞它。
- Node 有 6 阶段,浏览器只有 1 个混合队列——跨端代码要考虑差异。
延伸阅读
- HTML Standard - Event loops —— 规范定义
- Jake Archibald: Tasks, microtasks, queues and schedules —— 入门必读
- MDN: EventLoop —— MDN 综述
- Node.js Event Loop Docs —— Node 官方
- V8: Faster async functions and promises —— 内部优化
- Philip Roberts: What the heck is the event loop anyway? —— 经典演讲
- Tasks, microtasks, nanoseconds —— 浏览器视角
掌握事件循环,你不仅能写出正确顺序的代码,更能精确调度 JS 程序的每一处执行时机——这是高级前端的分水岭。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!