JS 事件循环完整解析:宏任务、微任务,浏览器到底怎么执行代码

JS 事件循环完整解析:宏任务、微任务,浏览器到底怎么执行代码

很多人对事件循环的理解,停留在两句口诀:

"先执行同步代码,再执行异步回调。"

"微任务先于宏任务执行。"

听着像那么回事,但一遇到复杂场景就懵了:

  • Promise.thensetTimeout 谁先执行?
  • async/await 算微任务吗?
  • MutationObserver 什么时候跑?
  • 为什么 requestAnimationFrame 既不像宏任务,也不像微任务?
  • 浏览器一帧里到底干了哪些事?

这篇文章不背八股,我们从 JS 引擎 + 渲染引擎 + 浏览器宿主环境​ 的整体视角,把事件循环这件事彻底拆清楚。


一、先纠正一个常见误区:JS 不是"多线程语言"

JS 本身是 单线程​ 的:

  • 同一时刻,只有一个执行栈
  • 一次只做一件事
  • 不会被两个函数同时打断

那为什么 JS 又能发请求、监听点击、定时执行?

👉 因为 JS 运行在一个"宿主环境"里(浏览器 / Node.js)

异步能力来自宿主,而不是 JS 本身。

JS 负责"执行代码",宿主负责"调度任务"。


二、事件循环的本质:一个永不停止的调度器

浏览器的事件循环(Event Loop)本质上是一个 任务调度机制

不断从"任务队列"里取出任务,丢进 JS 执行栈中执行。

简化成伪代码就是:

markdown 复制代码
while (true) {
  1. 执行一个宏任务
  2. 执行所有微任务
  3. 如有必要,执行渲染
}

这个循环,每一轮被称为 一次 Tick(滴答)


三、执行栈 + 任务队列:JS 运行的真实模型

1. 执行栈(Call Stack)

javascript 复制代码
function a() {
  b();
}

function b() {
  console.log('b');
}

a();

执行栈变化:

css 复制代码
→ a()
→ a() -> b()
→ a()
→ empty

特点:

  • LIFO(后进先出)
  • 只有栈空了,事件循环才能继续

2. 任务队列(Task Queue)

异步任务不会立刻进入执行栈,而是:

  1. 宿主环境先把回调放进对应的队列
  2. 执行栈为空时,事件循环才去取

而任务队列,又分为两类:

类型 常见来源
宏任务(Macrotask) script 整体代码、setTimeout、setInterval、UI 渲染、I/O、setImmediate(Node)
微任务(Microtask) Promise.then/catch/finally、queueMicrotask、MutationObserver

四、宏任务 vs 微任务:不是"快慢",而是"优先级"

1. 执行规则(核心)

每一次事件循环:

  1. 取一个宏任务执行
  2. 把这个宏任务产生的所有微任务一次性清空
  3. 才可能进入下一次宏任务

一句话记住:

一个宏任务 + 一批微任务 = 一个事件循环 tick


2. 经典示例

javascript 复制代码
console.log('start');

setTimeout(() => {
  console.log('timeout');
}, 0);

Promise.resolve().then(() => {
  console.log('promise1');
}).then(() => {
  console.log('promise2');
});

console.log('end');

输出顺序:

sql 复制代码
start
end
promise1
promise2
timeout

为什么?

  1. script 整体代码是 第一个宏任务
  2. 同步代码依次入栈执行
  3. setTimeout 回调进入 宏任务队列
  4. Promise.then 回调进入 微任务队列
  5. 当前宏任务执行完 → 清空微任务队列
  6. 再取下一个宏任务(setTimeout

3. 微任务的"连锁反应"

微任务里可以继续产生微任务:

javascript 复制代码
Promise.resolve().then(() => {
  console.log('micro1');
  Promise.resolve().then(() => {
    console.log('micro2');
  });
});

结果:

复制代码
micro1
micro2

只要微任务队列不为空,事件循环就不会继续

这也是为什么滥用微任务会阻塞渲染。


五、浏览器的一帧(Frame):事件循环 + 渲染的真相

很多人不知道:并不是每次事件循环都会渲染页面

1. 一帧里发生了什么?

浏览器通常以 60 FPS ​ 为目标,也就是约 16.7ms 一帧

一帧大致流程:

markdown 复制代码
1. 执行一个宏任务
2. 执行所有微任务
3. 判断是否达到渲染时机
   └─ 是 → 执行 render 相关操作
   └─ 否 → 直接进入下一轮事件循环
4. 执行 requestAnimationFrame 回调
5. 渲染(Layout → Paint → Composite)

⚠️ 重点:

  • 微任务总是在渲染前执行
  • rAF 在渲染前、微任务之后执行
  • 渲染不是每轮 tick 都发生

2. requestAnimationFrame 的定位

javascript 复制代码
requestAnimationFrame(() => {
  console.log('rAF');
});

Promise.resolve().then(() => {
  console.log('promise');
});

输出:

arduino 复制代码
promise
rAF

原因:

  • 微任务先于渲染
  • rAF 属于渲染前钩子
  • 所以它既不是宏任务,也不是微任务,而是一个 渲染阶段的回调

六、MutationObserver:微任务里的"监听者"

javascript 复制代码
const observer = new MutationObserver(() => {
  console.log('mutated');
});

observer.observe(document.body, { childList: true });

document.body.appendChild(document.createElement('div'));
console.log('append');

输出:

go 复制代码
append
mutated

原因:

  • DOM 变更是同步的
  • 回调被放入 微任务队列
  • 当前 task 结束后立即执行

✅ 这也是为什么 MutationObserver 能"批量感知 DOM 变化",而不会频繁触发。


七、async / await:披着语法糖外衣的 Promise

1. async 函数的本质

javascript 复制代码
async function foo() {
  console.log('1');
  await bar();
  console.log('2');
}

function bar() {
  console.log('3');
}

foo();
console.log('4');

等价于:

javascript 复制代码
function foo() {
  console.log('1');
  return Promise.resolve(bar()).then(() => {
    console.log('2');
  });
}

执行顺序:

复制代码
1
3
4
2

关键点:

  • await 后面的代码,相当于 .then() 里的回调
  • 属于微任务
  • await 本身之前的代码是同步执行的

2. 常见陷阱

javascript 复制代码
setTimeout(() => console.log('timeout'), 0);

async function test() {
  await null;
  console.log('after await');
}

test();
console.log('sync');

输出:

bash 复制代码
sync
after await
timeout

八、Node.js 与浏览器的差异(简要对照)

项目 浏览器 Node.js
宏任务 setTimeout、UI、I/O setTimeout、setImmediate、I/O
微任务 Promise、MutationObserver Promise、process.nextTick
nextTick ✅(优先级最高)
调度粒度 帧级 阶段级(phase-based)

一句话区分:

Node 的事件循环更复杂,但浏览器模型更贴近前端日常。


九、为什么"微任务先于宏任务"很重要?

1. UI 一致性

微任务在渲染前执行,可以保证:

  • DOM 更新
  • 状态计算
  • 数据同步

都在 同一帧内完成,避免"闪屏"或"中间态"。


2. 性能隐患

scss 复制代码
let i = 0;
function loop() {
  Promise.resolve().then(loop);
}
loop();

这是一个 微任务死循环

  • 微任务永远清不完
  • 浏览器无法进入渲染阶段
  • 页面卡死

✅ 正确做法:

  • 耗时逻辑放宏任务
  • 或用 setTimeout / rAF 拆分

十、一张图总结(文字版)

javascript 复制代码
┌──────────────┐
│ 执行一个宏任务 │
└──────┬───────┘
       ↓
┌──────────────────┐
│ 执行所有微任务     │ ← Promise.then / queueMicrotask
│ (直到队列清空)   │
└──────┬───────────┘
       ↓
┌──────────────────┐
│ requestAnimationFrame │
└──────┬───────────┘
       ↓
┌──────────────────┐
│ 渲染(Layout/Paint)│
└──────┬───────────┘
       ↓
    回到顶部

十一、一句话总结

JS 事件循环不是"异步怎么跑",而是"浏览器如何调度一切"。

  • JS 单线程,靠宿主调度
  • 宏任务是"大块工作"
  • 微任务是"当前工作后的收尾"
  • 渲染发生在微任务之后、下一轮宏任务之前
  • async/await 本质是 Promise 微任务
  • 理解事件循环,本质是理解 执行时机

当你不再用"先同步后异步"这种模糊说法,而是能用 宏任务 / 微任务 / 渲染帧​ 来解释一段代码的执行顺序时,

你对 JS 的理解,就已经超过 80% 的前端开发者了。

相关推荐
长大19882 小时前
写了多年 JS 却仍被“变量提升”拿捏?这篇文章一次讲透
后端
大勇前进2 小时前
闭包到底有什么用?别只背概念,看完秒懂实际业务场景
后端
神奇小汤圆3 小时前
深入理解 TiDB 分布式事务:Percolator 模型与工程实践
后端
神奇小汤圆3 小时前
Flink SQL 从编写到提交运行的全过程解析
后端
饼干哥哥3 小时前
Codex 必改的8 个基础配置
前端·人工智能·后端
lingran__3 小时前
Linux 基础常用指令万字详解(上)|文件目录命令,结合底层原理剖析
linux·运维·服务器·后端·centos·linux基础指令
情可轻 秦任之4 小时前
ASP.NET页面优化,性能提升8倍的方法
后端·asp.net
王中阳Go4 小时前
用TRAE Work批量优化学员简历,原来2天的活现在2小时就干完了
后端·面试·agent
Zane19944 小时前
同一个 list 为什么能被两个 for 循环同时遍历?一文讲透可迭代对象与迭代器的真相
后端·python