深入理解 JavaScript 事件循环机制:宏任务与微任务的执行顺序

JavaScript 是一门单线程语言,这意味着它在任意时刻只能执行一个任务。然而,在实际开发中,我们却经常需要处理异步操作,比如网络请求、定时器、用户交互等。这些异步操作之所以能够非阻塞地运行,背后依赖的正是**事件循环(Event Loop)**机制。理解事件循环,尤其是宏任务与微任务的执行顺序,是每一位 JavaScript 开发者进阶的必修课。

为什么需要事件循环?

JavaScript 最初被设计为运行在浏览器中的脚本语言,主要用来操作 DOM。如果 JavaScript 是多线程的,那么两个线程同时修改同一个 DOM 元素就会产生竞态条件,导致页面渲染混乱。因此,JavaScript 被设计为单线程语言,所有任务都在一个主线程上排队执行。

但单线程并不意味着“卡死”。事件循环机制让 JavaScript 可以处理异步任务:当遇到异步操作时,主线程不会等待它完成,而是将其交给浏览器的其他线程(如定时器线程、网络线程)处理,并在合适的时机将回调函数放入任务队列中,等待主线程空闲时执行。

任务队列:宏任务与微任务

在事件循环中,任务被分为两类:宏任务(MacroTask)微任务(MicroTask)

宏任务

宏任务代表一个个离散的、独立的工作单元。常见的宏任务包括:

  • setTimeout
  • setInterval
  • setImmediate(Node.js 环境)
  • I/O 操作
  • UI 渲染
  • MessageChannel
  • 整个 <script> 脚本的初始执行

每次事件循环从宏任务队列中取出一个宏任务执行,执行完毕后,会检查微任务队列。

微任务

微任务是在当前宏任务执行结束后、下一个宏任务开始前执行的任务。常见的微任务包括:

  • Promise.then / Promise.catch / Promise.finally
  • queueMicrotask
  • MutationObserver
  • process.nextTick(Node.js 环境,优先级高于 Promise)

微任务的特点是:一旦开始执行,就会一直执行到微任务队列为空。这意味着如果在微任务中又产生了新的微任务,它会继续执行,直到队列彻底清空。

事件循环的执行顺序

事件循环的核心规则可以用以下步骤概括:

  1. 执行当前宏任务(通常是初始的 <script> 脚本)。
  2. 宏任务执行完毕后,检查微任务队列。
  3. 依次执行所有微任务,直到微任务队列为空。
  4. 如果此时需要进行 UI 渲染,浏览器会进行渲染。
  5. 从宏任务队列中取出下一个宏任务,重复上述过程。

用一句话总结:每个宏任务执行完毕后,都会清空所有微任务,然后才进入下一个宏任务。

经典示例分析

让我们通过几个示例来验证上述规则。

示例一:基本顺序

console.log('script start');

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

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

console.log('script end');

输出结果:

script start
script end
promise then
setTimeout

分析:

  • 初始 <script> 作为第一个宏任务开始执行。
  • 同步代码 console.log('script start')console.log('script end') 依次输出。
  • setTimeout 的回调被放入宏任务队列。
  • Promise.then 的回调被放入微任务队列。
  • 宏任务(初始脚本)执行完毕,检查微任务队列,输出 promise then
  • 微任务清空后,取出下一个宏任务,输出 setTimeout

示例二:微任务中产生微任务

console.log('start');

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

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

console.log('end');

输出结果:

start
end
promise1
promise2
timeout

分析:

  • 同步代码输出 startend
  • 微任务队列中有 promise1 的回调。
  • 执行 promise1 回调时,又产生了一个新的微任务 promise2
  • 因为微任务队列会一直执行到空,所以 promise2 紧接着执行。
  • 最后才执行宏任务 timeout

示例三:async/await 的本质

async/await 本质上是 Promise 的语法糖,理解这一点对分析执行顺序至关重要。

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

分析:

  • 同步代码依次输出 script start
  • 调用 async1,输出 async1 start
  • 执行 await async2(),先执行 async2 函数体,输出 async2await 之后的代码 console.log('async1 end') 被放入微任务队列。
  • 继续执行同步代码,new Promise 的执行器函数是同步的,输出 promise1then 回调被放入微任务队列。
  • 输出 script end
  • 宏任务(初始脚本)结束,清空微任务队列:先输出 async1 end,再输出 promise2
  • 最后执行宏任务 setTimeout

宏任务与微任务的关键区别

特性 宏任务 微任务
执行时机 每个事件循环取一个 当前宏任务结束后全部执行
是否阻塞渲染 可能阻塞 会阻塞渲染,需谨慎使用
常见 API setTimeout、setInterval、I/O Promise.then、queueMicrotask
队列清空方式 每次取一个 一次性清空

实际开发中的注意事项

  1. 避免在微任务中无限递归:由于微任务队列会一直执行到空,如果在微任务中不断产生新的微任务,会导致页面无法渲染,甚至卡死。

  2. Promise 回调不一定是微任务:只有 thencatchfinally 的回调是微任务,Promise 的执行器函数是同步执行的。

  3. setTimeout(fn, 0) 并不立即执行:它只是将回调放入宏任务队列,需要等待当前宏任务和所有微任务执行完毕。

  4. Node.js 与浏览器的差异:Node.js 中有 process.nextTick,它的优先级高于 Promise 微任务;此外 Node.js 的事件循环分为多个阶段,宏任务的执行顺序与浏览器略有不同。

总结

JavaScript 事件循环是理解异步编程的基石。核心规则可以归纳为:

  • 同步代码在宏任务中执行。
  • 每个宏任务执行完毕后,清空所有微任务。
  • 微任务执行过程中产生的新微任务,会在当前轮次继续执行。
  • 微任务全部清空后,才进入下一个宏任务。

掌握宏任务与微任务的执行顺序,不仅能帮助你在面试中游刃有余,更能让你在实际开发中写出可预测、高性能的异步代码。下次遇到 setTimeoutPromise 混用的场景时,不妨在脑海中模拟一遍事件循环的流程,答案自然会清晰浮现。

未经允许不得转载:任鹏个人博客 » 深入理解 JavaScript 事件循环机制:宏任务与微任务的执行顺序

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏