前端事件循环与微任务:从 setTimeout 到 Promise 的底层执行顺序

3717 字
19 分钟
前端事件循环与微任务:从 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 后面那行到底算同步还是异步?queueMicrotaskPromise.then 到底什么关系?

本文将从浏览器与 Node 双视角出发,把宏任务(Task)、微任务(Microtask)、渲染时机、任务优先级、requestAnimationFramerequestIdleCallback 这条主线全部串起来。读完本文,你不仅能解释上面那段代码,更能预测任何混用场景的输出顺序。

一、JS 是单线程的:这一切的前提#

JavaScript 一开始就被设计成单线程语言——主线程同一时刻只能执行一段代码。这是为了避免多线程操作 DOM 时产生的竞态条件(比如两个线程同时改一个节点的文本)。

但单线程也意味着:如果一段同步代码死循环,后面的任务就永远卡住。所以 JS 必须有一套调度机制让耗时操作(IO、定时器、网络请求)“不阻塞”主线程。这套机制就是事件循环(Event Loop)

核心思想:JS 引擎自己不轮询,而是宿主环境(浏览器 / Node)提供任务队列,引擎在同步代码执行完后,反复检查队列并取出任务执行。

二、任务的两层结构:宏任务与微任务#

事件循环里跑的”任务”分为两层,优先级完全不同

维度宏任务(Task / Macrotask)微任务(Microtask)
包含setTimeoutsetIntervalsetImmediate(Node)、I/O、UI 交互事件、<script> 解析Promise.then/catch/finallyqueueMicrotaskMutationObserverprocess.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: 同步执行

执行过程:

  1. 当前宏任务(整段 script)开始
  2. L1 同步:输出 1
  3. L2:把回调注册到宏任务队列
  4. L3:把回调注册到微任务队列
  5. L4 同步:输出 4
  6. 当前宏任务结束 → 查微任务队列
  7. 执行微任务:输出 3
  8. 微任务队列清空 → 渲染(如有需要)→ 取下一个宏任务
  9. 执行 setTimeout 回调:输出 2

输出:1 4 3 2。是不是清晰多了?

四、浏览器事件循环:完整的执行模型#

浏览器的事件循环规范定义在 HTML Standard 中。一次”循环”大致包含这些步骤:

  1. 执行最旧的任务(可能来自多个任务源)
  2. 执行微任务队列中的所有微任务(微任务队列可被多次”刷新”)
  3. 如果有需要,渲染 UI(requestAnimationFrame 回调 + 样式计算 + 布局 + 绘制)
  4. 如果是空闲时间段(超过 50ms 没任务),可能触发 requestIdleCallback
  5. 返回第 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 start
script end
promise1
promise2
inner microtask
setTimeout
rAF

为什么 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');

执行步骤:

  1. 同步:script start
  2. 同步:foo start
  3. 同步:bar
  4. await 让出,后续注册为微任务
  5. 同步:script end
  6. 微任务: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 2

6.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 catch

catch 块也是同步执行的——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

queueMicrotaskPromise.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 优先级最高;setTimeoutsetImmediate 在主模块中顺序依赖机器负载。

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 endpromise2 之前——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 catchcatch链不断,继续往下。

Q17MutationObserver

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 → mutationMutationObserver 回调在微任务末尾执行。

Q18queueMicrotask 抛错)

queueMicrotask(() => {
throw new Error('micro error');
});
queueMicrotask(() => console.log('next'));
答案

next 仍会执行——一个微任务抛错不会中断微任务队列(但会冒泡到 unhandledrejection)。

Q19setTimeout 嵌套)

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();
// ✅ 改用 setTimeout
function 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 │
│ │ 样式 → 布局 → 绘制 │
│ └──────┬──────────┘ │
│ ↓ │
│ 回到顶端取下一个宏任务 │
└───────────────────────────────────────┘

五条铁律

  1. 微任务优先级永远高于下一个宏任务——这是顺序预测的根。
  2. await 不是等待,是让出——后续代码必为微任务。
  3. new Promise(executor) 的 executor 是同步——只注册 then 才是微任务。
  4. rAF 在渲染前——不阻塞微任务,但微任务会阻塞它。
  5. Node 有 6 阶段,浏览器只有 1 个混合队列——跨端代码要考虑差异。

延伸阅读#

掌握事件循环,你不仅能写出正确顺序的代码,更能精确调度 JS 程序的每一处执行时机——这是高级前端的分水岭。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

前端事件循环与微任务:从 setTimeout 到 Promise 的底层执行顺序
https://boke.hackerdream.xyz/posts/frontend-event-loop-microtask/
作者
晴天
发布于
2026-06-07
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
晴天
Hello, I'm 晴天.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
155
分类
24
标签
387
总字数
345,424
运行时长
0
最后活动
0 天前

目录