事件循环机制:
主线程中存在一个「调用栈」(Call Stack)
function foo() {console.log('foo');setTimeout(() => console.log('foo timeout'));Promise.resolve().then(() => console.log('foo microtask'));
}function bar() {console.log('bar');setTimeout(() => console.log('bar timeout'));Promise.resolve().then(() => console.log('bar microtask'));
}foo();
bar();
🧭 二、执行顺序总览
我们分步骤看整个过程是如何“循环插入”的。
Step 1:调用 foo()
-
foo入栈 → 执行同步代码:输出foo -
setTimeout(...)注册回调(交给 Web API),计时结束后放入 宏任务队列 -
Promise.then(...)注册微任务(放入 微任务队列) -
foo()执行完毕 → 出栈
此时:
-
宏任务队列:[
foo timeout] -
微任务队列:[
foo microtask]
Step 2:调用 bar()
-
bar入栈 → 执行同步代码:输出bar -
setTimeout(...)注册宏任务 -
Promise.then(...)注册微任务 -
bar()出栈
此时:
-
宏任务队列:[
foo timeout,bar timeout] -
微任务队列:[
foo microtask,bar microtask]
Step 3:同步代码执行完毕 → 启动事件循环
事件循环规则:
每一轮循环 = 执行一个宏任务 → 执行所有微任务 → 渲染 → 下一轮宏任务
但注意:执行同步代码本身也算一个宏任务!
也就是说:
整个脚本(
<script>)的执行就是第一个宏任务。
因此,在脚本宏任务结束时,会先执行 当前的所有微任务。
⚙️ Step 4:执行微任务队列
当前同步代码(脚本)宏任务结束:
-
执行所有微任务(FIFO 顺序):
输出:
foo microtask
bar microtask
执行完微任务后 → 进入下一个宏任务。
🕐 Step 5:进入宏任务队列(定时器回调)
事件循环取出第一个宏任务:foo timeout
执行 foo timeout 回调:
该回调执行完毕 → 再次执行本轮产生的微任务(如果有的话)。
当前没有新的微任务 → 进入下一个宏任务。
再执行 bar timeout:
bar timeout
📤 最终输出顺序
foo
bar
foo microtask
bar microtask
foo timeout
bar timeout
🧠 五、重要补充:每个宏任务内部都可能触发新的微任务
setTimeout(() => {console.log('T1');Promise.resolve().then(() => console.log('T1 micro'));
});setTimeout(() => {console.log('T2');
});
输出顺序:
T1
T1 micro
T2
解释:
-
执行宏任务
T1 -
在其中注册了一个微任务 → 紧接执行
-
然后才进入下一个宏任务
T2

浏览器生命周期
🧩 一、浏览器每帧的生命周期(事件循环中的“渲染节奏”)
┌───────────────────────────────┐
│ 执行宏任务(包括脚本执行) │
│ ───────────────────────────── │
│ 执行微任务(Promise.then等)│
│ ───────────────────────────── │
│ 执行渲染前回调(requestAnimationFrame) │
│ ───────────────────────────── │
│ 执行样式计算、布局、绘制 │
│ ───────────────────────────── │
│ 下一帧开始(目标16.6ms) │
└───────────────────────────────┘
这意味着浏览器理想状态是:
-
每一帧(frame)大约 16.6ms;
-
在这一帧内,JS 执行(宏+微任务)必须在渲染前完成;
-
如果 JS 执行太久(>16ms),会阻塞渲染。
🕐 二、如果宏任务执行太久,会发生什么?
举例:
function heavyTask() {const start = Date.now();while (Date.now() - start < 50) {} // 模拟耗时任务(50ms)
}setInterval(heavyTask, 0);
情况如下:
-
setInterval的回调是一个宏任务; -
每个宏任务执行时阻塞主线程;
-
浏览器在执行 JS 时 不能渲染帧;
-
渲染机会被推迟到当前宏任务完全结束之后。
🔍 实际效果:
-
浏览器原计划在 16.6ms 时渲染下一帧;
-
但此时主线程仍在执行
heavyTask; -
所以该帧跳过(skipped frame);
-
浏览器只好等到 JS 执行完毕后,再进行一次绘制;
-
这就是你看到的「掉帧」或「卡顿」。
⚙️ 三、那宏任务会“堆积”到下一帧吗?
严格来说:
宏任务不会堆积到下一帧的“同步任务之后”执行,而是阻塞了帧的产生本身。
解释:
-
浏览器不会插入新的渲染帧,直到主线程空闲;
-
所以 “上一帧没执行完” 的 JS 任务不会被切走,也不会“跨帧”;
-
它只是拖慢了下一帧的开始时间。
也就是说:
宏任务执行完毕 → 浏览器才有机会:
执行微任务
执行
requestAnimationFrame执行渲染(Reflow/Repaint)
🎬 四、帧与事件循环的真实关系(关键理解)
浏览器的 事件循环(event loop) 与 渲染帧(frame loop) 并非严格同步。
渲染是事件循环中的一个阶段:
┌──────────────────────────────────┐
│ 宏任务(执行JS代码) │
│ → 微任务(Promise.then等) │
│ → requestAnimationFrame回调 │
│ → 浏览器渲染(layout + paint) │
│ → 下一个宏任务(或下一帧) │
└──────────────────────────────────┘
当 JS 代码执行过久时(例如阻塞 100ms),浏览器的渲染阶段就被延后。
下一帧不是「等你执行完再补一次」,而是「直接跳帧」。