一文详解JS中的执行顺序——事件循环(宏任务、微任务)

一文详解JS中的执行顺序------事件循环(宏任务、微任务)

为什么 JavaScript 是单线程的?

JavaScript 诞生的初衷是为了处理网页上的简单交互,比如表单验证。试想一下,如果 JavaScript 是多线程的:

  • 线程 A 想修改某个 DOM 节点的内容
  • 线程 B 想删除同一个 DOM 节点

这就会导致复杂的同步问题(锁机制),对于轻量级的网页脚本来说太重了。因此,JavaScript 从诞生起就是单线程的,这意味着它在同一时间只能做一件事。

但"单线程"并不意味着它慢。JavaScript 巧妙地利用了异步非阻塞 机制,配合 Event Loop (事件循环),让它能够高效地处理大量并发任务(如网络请求、定时器、DOM 事件)。

核心概念解析

为了理解 Event Loop,我们需要先搞清楚几个"角色":

同步任务 (Synchronous)

那些立即执行 、不等待 其他操作完成、并且按顺序在主线程上依次执行的任务就是同步任务。

你可以直接把这段代码复制到浏览器的控制台(F12 -> Console)运行:

javascript 复制代码
console.log('1. 任务开始');

// 【同步任务】:一个极其耗时的循环
// 假设我们要计算 10 亿次加法
let sum = 0;
const limit = 1000000000; // 10 亿

console.log('2. 开始执行耗时同步任务 (请观察页面是否卡住)...');

for (let i = 0; i < limit; i++) {
  sum += i;
  // 注意:在这个循环结束前,JS 引擎绝对不会去处理任何其他事情
  // 你的鼠标点击、页面滚动、定时器回调、网络请求完成等,全部被阻塞!
}

console.log('3. 耗时任务结束,结果:', sum);

当今浏览器的性能虽说不至于卡死,但是在进行计算的这一秒内你尝试滚动页面,发现页面似乎无响应了,这就是JS的主进程被阻塞,无法执行其他任务(页面滚动)。

异步任务 (Asynchronous)

异步任务就是"现在不执行,将来某个时刻再执行"的任务。 它们不会阻塞主线程,而是将回调函数注册好,交给浏览器(或 Node.js)的 API 去处理,等处理完了,再把回调函数放入队列,等待 Event Loop 在合适的时机执行。

宏任务 (MacroTask)

  • 代表一个个离散的、独立的任务。
  • 浏览器为了能够使 JS 内部 task 与 DOM 任务能够有序的执行,会在一个 task 执行结束后,在下一个 task 执行开始前,对页面进行重新渲染。
  • 常见宏任务 :
    • 整体代码 script (可以理解为第一个宏任务)
    • setTimeout / setInterval
    • UI 渲染 / I/O

微任务 (MicroTask)

  • 优先级高于宏任务(除了当前的 script)。
  • 在当前宏任务执行结束后,下一次渲染之前,会立即清空所有的微任务。
  • 常见微任务 :
    • Promise.then / catch / finally
    • async/await (本质是 Promise)
    • MutationObserver (监听 DOM 变化)
    • queueMicrotask

---

Event Loop 执行流程

这就是 JavaScript 永不停歇的"心脏"跳动机制:

  1. 执行栈 (Call Stack) 选择最先进入队列的宏任务(通常是整体 script 代码),执行其同步代码。
  2. 执行过程中,遇到微任务 ,将其放入微任务队列。
  3. 执行过程中,遇到宏任务 (如 setTimeout),将其回调放入宏任务队列。
  4. 当前宏任务执行完毕(Call Stack 清空)。
  5. 关键步骤 :检查微任务队列 。如果有微任务,依次执行所有微任务 ,直到队列清空。
    • 注意:如果在执行微任务的过程中又产生了新的微任务,会继续添加到队列末尾并在本次循环中一并执行!这可能导致"死循环"阻塞页面渲染。
  6. 渲染页面(如果有必要)。
  7. 检查宏任务队列,取出下一个宏任务,回到步骤 1。

口诀:

同步先行 -> 清空微任务 -> 渲染 -> 下一个宏任务

代码案例

让我们通过一段复杂的代码来彻底捋清楚执行顺序。

javascript 复制代码
// 1. 同步代码
console.log('同步代码 1');

// 2. 宏任务 (setTimeout)
setTimeout(() => {
  console.log('setTimeout 1');
  Promise.resolve().then(() => {
    console.log('setTimeout 1 内部微任务');
  });
}, 0);

// 3. Promise 构造函数 (同步)
const promise1 = new Promise((resolve) => {
  console.log('Promise 构造函数');
  resolve();
  console.log('Promise 构造函数内 resolve 后');
});

// 4. 微任务 (Promise.then)
promise1.then(() => {
  console.log('Promise.then 1');
  setTimeout(() => {
    console.log('Promise.then 1 内部 setTimeout');
  }, 0);
});

// 5. Async/Await (同步+微任务)
async function asyncFn() {
  console.log('async 函数同步部分');
  // await 相当于 Promise.resolve().then(...)
  // await 这一行及之后的代码会被放入微任务队列
  await Promise.resolve(); 
  console.log('await 后微任务');
}

asyncFn();

// 6. 同步代码
console.log('同步代码 2');

// 7. 微任务 (queueMicrotask)
queueMicrotask(() => {
  console.log('queueMicrotask 微任务');
});

// 8. 微任务 (MutationObserver)
const observer = new MutationObserver(() => {
  console.log('MutationObserver 微任务');
});
const div = document.createElement('div');
observer.observe(div, { attributes: true });
div.setAttribute('data-test', '1'); // 触发

执行步骤详解

第一轮:执行 Script 宏任务(同步代码)

  1. 执行 console.log('同步代码 1')

    • 控制台输出 :同步代码 1
  2. 执行 setTimeout(...)

    • 宏任务队列 :[SetTimeout1]
  3. 执行 new Promise(...)

    • 控制台输出 :Promise 构造函数
    • 控制台输出 :Promise 构造函数内 resolve 后
  4. 执行 promise1.then(...)

    • 微任务队列 :[Then1]
  5. 执行 asyncFn()

    • 控制台输出 :async 函数同步部分
    • 微任务队列 :[Then1, Await]
  6. 执行 console.log('同步代码 2')

    • 控制台输出 :同步代码 2
  7. 执行 queueMicrotask(...)

    • 微任务队列 :[Then1, Await, Queue]
  8. 执行 MutationObserver

    • 微任务队列 :[Then1, Await, Queue, Observer]

第二轮:清空微任务队列

  1. 取出 Then1 执行

    • 控制台输出 :Promise.then 1
    • 宏任务队列 :[SetTimeout1, SetTimeout2]
  2. 取出 Await 执行

    • 控制台输出 :await 后微任务
  3. 取出 Queue 执行

    • 控制台输出 :queueMicrotask 微任务
  4. 取出 Observer 执行

    • 控制台输出 :MutationObserver 微任务

第三轮:执行下一个宏任务

  1. 取出 SetTimeout1 执行

    • 控制台输出 :setTimeout 1
    • 微任务队列 :[InnerThen] (宏任务中产生的微任务)
  2. 清空微任务队列 (执行 InnerThen)

    • 控制台输出 :setTimeout 1 内部微任务

第四轮:执行再下一个宏任务

  1. 取出 SetTimeout2 执行
    • 控制台输出 :Promise.then 1 内部 setTimeout

最终输出结果

text 复制代码
同步代码 1
Promise 构造函数
Promise 构造函数内 resolve 后
async 函数同步部分
同步代码 2
Promise.then 1
await 后微任务
queueMicrotask 微任务
MutationObserver 微任务
setTimeout 1
setTimeout 1 内部微任务
Promise.then 1 内部 setTimeout

(注:微任务之间的顺序主要取决于入队顺序,await 和 Promise.then 的具体先后可能因浏览器版本/ECMAScript 规范版本略有差异,但在现代浏览器中通常如上所示。MutationObserver 和 queueMicrotask 通常也在微任务队尾)

易错点与避坑指南

Promise 构造函数是同步的

很多人误以为 new Promise 里的代码是异步的。错!只有 .then()、.catch() 里的回调才是异步微任务。

Await 的本质

await xxx 相当于 Promise.resolve(xxx).then(() => { ...后续代码... })。它把异步代码写得像同步一样,但本质上它是让出了线程,把后续代码扔进了微任务队列。

微任务死循环 (Microtask Loop)

这是一个非常危险的操作! 宏任务 执行完一个,会给 UI 渲染的机会。 微任务则是"死磕到底"------只要队列不空,就不停地执行。

如果你在微任务里不断添加新的微任务:

javascript 复制代码
function loop() {
  Promise.resolve().then(loop); // 无限递归微任务
}
loop();

结果:页面完全卡死 。因为主线程一直忙着清空微任务,根本没机会去执行 UI 渲染,也没机会去执行下一个宏任务(如点击事件、定时器)。这比 while(true) 更隐蔽,但同样致命。

总结

JavaScript 的 Event Loop 就像一个不知疲倦的调度员:

  1. 先处理手里现有的急事(同步代码)。
  2. 处理完急事,马上看看有没有"小纸条"(微任务),有就一口气全处理完。
  3. 如果"小纸条"处理完了,喘口气,看看能不能画画(UI渲染)。
  4. 最后再去信箱里拿下一封信(宏任务),开始新的轮回。

理解了这个机制,你就能明白为什么 setTimeout(fn, 0) 不一定准时,为什么大量计算要放在 Web Worker 里,以及为什么你的页面有时候会莫名其妙地卡顿了。

相关推荐
派小心.22 分钟前
DAU和MAU怎么计算?先确定活跃事件、用户键和统计窗口
大数据·服务器·前端·人工智能·数据分析
代码猎人22 分钟前
前端怎么配合后端完成bug修复
前端
叫我铁汁儿吧25 分钟前
JS作用域
前端·面试
定栓26 分钟前
Webpack迁到Vite第七节(四):全家桶移除 — 保留项、脚本与数据收尾
前端·webpack·node.js
linweidong28 分钟前
RTC 走偏、时间跳变?嵌入式时间管理的工程避坑
嵌入式硬件·面试·实时音视频·rtc·嵌入式面经·嵌入式ntp·嵌入式对时
VimorphTech33 分钟前
React 任务轮询,为什么不能只写一个 setInterval?
javascript·typescript
代码猎人34 分钟前
前端怎么优化首屏加载速度
前端
代码猎人34 分钟前
前端怎么保障项目稳定上线与运行
前端
数据掘金35 分钟前
小程序埋点方案的埋点文档怎么自动生成?
前端
deli00735 分钟前
cron 表达式为什么总跑错?10 个场景实测,下次执行时间一眼看懂
前端