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

0 阅读7分钟

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

为什么 JavaScript 是单线程的?

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

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

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

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

核心概念解析

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

同步任务 (Synchronous)

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

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

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
image.png ---

Event Loop 执行流程

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

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

口诀:

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

代码案例

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

// 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

最终输出结果

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

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

易错点与避坑指南

Promise 构造函数是同步的

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

Await 的本质

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

微任务死循环 (Microtask Loop)

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

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

function loop() {
  Promise.resolve().then(loop); // 无限递归微任务
}
loop();

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

总结

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

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

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