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

前言

JavaScript作为一门广泛应用于前端开发的编程语言,其单线程特性使得它需要一种特殊的机制来管理异步任务的执行,这就是事件循环机制Event Loop)。本文将详细探讨JavaScript的事件循环机制,并解释其中的两个重要概念:宏任务(MacroTask)和微任务(MicroTask)。 JavaScript的单线程设计意味着在同一时间内只能执行一个任务。这种设计主要是为了避免多线程环境下的复杂同步问题,确保在执行DOM操作等关键任务时不会出现竞争条件。然而,单线程也带来了一个问题:如何处理那些需要长时间执行的任务,例如I/O操作或网络请求,以免阻塞主线程?答案就是异步编程事件循环机制

异步编程在之前的文章中有分享过:Callback 🥊 Promise 🥊 Async/Await:谁才是异步之王?

如果有不太懂或者感兴趣的小伙伴可以去看看喔~本期我们直接进入正题吧。

1. 什么是事件循环机制?

事件循环是JavaScript引擎用来管理异步任务执行的核心机制。它的工作流程大致如下:

  • 执行栈(Call Stack) :JavaScript首先执行栈顶的同步任务。当遇到异步任务时,会将这些任务注册到相应的任务队列中,并继续执行后续的同步任务。
  • 任务队列(Task Queue) :异步任务完成后,其回调函数会被放入任务队列中等待执行。任务队列分为宏任务队列和微任务队列。
  • 宏任务(MacroTask) :常见的宏任务包括script整体代码setTimeoutsetIntervalsetImmediate(在Node.js中)、I/O操作UI渲染等。宏任务按照先进先出的原则执行,每次事件循环只会从宏任务队列中取出一个任务执行。
  • 微任务(MicroTask) :微任务包括promise.then(), process.nextTick(), MutationObserver()以及使用async/await产生的任务。微任务会在当前宏任务执行完毕后立即执行,且会在下一个宏任务开始之前全部执行完毕。因此,微任务具有比宏任务更高的优先级。

2. 事件循环的执行顺序

在第一次执行事件循环时,都是先执行宏任务再执行微任务,再执行宏任务,所以总结eventLoop步骤如下:

  1. 执行同步代码(这属于宏任务)如果遇到微任务,将其添加到微任务队列中;
  2. 执行完同步后,检查是否有异步代码需要执行;
  3. 执行所有的微任务
  4. 如果有需要,执行渲染任务,更新UI。
  5. 执行宏任务,也是开启了下一次事件循环

重复上述步骤,不断循环

下面通过一个示例来展示事件循环的执行顺序:

JavaScript代码 复制代码
console.log('script start');
async function async1() { // 同步代码,没有调用
  await async2()  // 同步代码
  console.log('async1 end'); // 微任务1
}
async function async2() {
  console.log('async2 end');
}
async1() // 同步执行
setTimeout(() => { // 宏任务
  console.log('setTimeout');
}, 0)

new Promise((resolve, reject) => {
  console.log('promise');
  resolve()
})
.then(() => { // 微任务2
  console.log('then1');
})
.then(() => { // 微任务3
  console.log('then2');
});
console.log('script end');

// 输出:script start;async2 end;promise;script end;async1 end;then1;then2;setTimeout

在这个例子中,代码从上往下执行的过程如下:

  • 首先执行同步任务,即先打印script start
  • 然后再执行函数async1;
  • 遇到await时会将await后面接的代码直接变成同步代码立即执行 ,即执行async2 ,打印async2 end
  • 将await后的同步代码执行完后,接着的代码挤入微任务队列,即代码第四行是微任务1;
  • 执行setTimeout函数,回调函数进入宏任务队列
  • 执行new Promise()构造函数的回调函数内部同步代码,打印promise
  • 执行resolve后,回调执行完毕 ,Promise状态更新为fulfilled,所以后面接的then进入微任务队列,即微任务2;
  • 因为第一个then返回的Promise状态未更新 为fulfilled,第二个then不会进入微任务队列,所以会暂存
  • 执行同步代码最后一行,打印script end同步代码执行完毕 (即一整个宏任务执行完毕)开始执行微任务队列
  • 检查到微任务队列有任务未执行,按照先进先出 原则执行微任务1,打印async1 end
  • 执行微任务2,即执行then的回调,打印then1;
  • 第一个then的回调执行完毕,返回的Promise状态更新fulfilled,下一个then进入微任务队列,即微任务3;
  • 在执行完微任务2后会再次检查微任务队列是否还有未执行的任务,所以继续执行微任务3,打印then2
  • 微任务队列全部执行完毕,开始执行宏任务,即打印setTimeout,至此全部代码执行完毕。

3. 出现死锁的情况

一般情况下,浏览器会严格按照时间循环机制的顺序执行,但是有的时候也会出现特殊情况,比如下面的代码举例:

JavaScript代码 复制代码
function A() {
    return new Promise((resolve, reject) => {
        console.log('A');
        setTimeout(() => { // 宏任务
            console.log('setTimeout');
            resolve()
        }, 1000)
    })
}

A().then(() => { // 微任务
    console.log("then1");
})
    .then(() => {
        console.log('then2');
    })

var b = new Promise(async(resolve, reject) => {
    console.log('b');
    await console.log('await1');
    console.log("c"); // 微任务1
    await (b)
    resolve(true)
    console.log('end');

})

你先试着分析一下这段代码的执行顺序和打印结果是不是正确的呢? 打印结果为:A;b;await1;c;setTimeout;then1;then2。你会发现,哎~这个end没有打印,为什么?

因为代码第20行的await结束后,会将后续的代码封装成为微任务。紧接着在执行微任务阶段时,遇到了 await (b),由于 b 是一个状态仍为 pending Promise,所以引擎会等待b的状态更新才会将后续的代码挤入微任务队列。于是引擎挂起后续代码 (不会报错或终止),这些代码被暂存,等待 b 状态改变(但永远等不到 )。这就是我们所说的出现了死锁 ,但是因为宏任务的setTimeout是独立的不会受到当前死锁的Promise影响,所以还是会继续执行。

我将流程简化如下:

text 复制代码
同步代码:
1. 打印 'A' → 注册 setTimeout(1秒)
2. 打印 'b' → 打印 'await1' → 微任务入队('c' 和 await b)

微任务阶段:
1. 打印 'c' → 遇到 `await b`(死锁,后续代码冻结)

宏任务阶段(1秒后):
1. 打印 'setTimeout' → resolve()
2. 微任务队列:
   - 打印 'then1'
   - 打印 'then2'

3-1. 死锁的本质

结合上面的代码示例,我总结这个死锁的本质:

  • await 会挂起当前 async 函数的执行 ,直到等待的 Promise 变为 fulfilledrejected
  • 如果 Promise 永远不改变状态 (如 await 一个自身的 Promise),则相关代码会无限挂起,但不会阻塞事件循环的其他部分(如宏任务)。

3-2. 死锁场景分析

  • 情况 1:宏任务不依赖死锁的 Promise
JavaScript代码 复制代码
var b = new Promise(async (resolve) => {
  await b; // 死锁:等待自身
  resolve(true); // 永远不会执行
});

setTimeout(() => {
  console.log("宏任务执行"); // 能正常执行
}, 1000);

await b 导致 b 永远 pending,但 setTimeout 是独立的宏任务,不受影响 ,1秒后会正常执行。不会报错,因为事件循环不关心死锁。

  • 情况 2:宏任务依赖死锁的 Promise
JavaScript代码 复制代码
var b = new Promise(async (resolve) => {
  await b; // 死锁
  resolve(true);
});

setTimeout(async () => {
  console.log("开始宏任务");
  await b; // 依赖死锁的 Promise
  console.log("永远不会执行");
}, 1000);

如上代码:setTimeout 的回调会执行第一步 (打印 "开始宏任务"),但遇到 await b 时,由于 b 永远 pending后续代码被挂起,既不报错也不继续执行。

所以如果是这种情况:依赖死锁 Promise 的宏任务会部分执行,然后在 await 处无限等待,但是不会报错。

3-3. 实际应用中的建议

  • 避免循环引用 :不要在一个 Promise 的解析逻辑中等待它自身(如 await b, 且 b 是当前 Promise)。
  • 超时机制 :对可能死锁的 await 添加超时拒绝(如使用withTimeout)。
  • 调试技巧:如果代码"看似卡住",检查是否有未解决的 Promise 或循环依赖的 await。

4. 结语

理解事件循环机制对于编写高效的JavaScript代码至关重要。合理利用宏任务和微任务的优先级差异,可以优化代码的执行效率和响应性。例如,将需要快速响应的操作(如用户界面更新)放在微任务中,而将不紧急的更新操作(如网络请求的响应处理)放在宏任务中,从而确保应用程序保持良好的用户体验。

总之,事件循环机制是JavaScript异步编程的核心,宏任务和微任务则是其中的重要组成部分。掌握这些概念,可以帮助开发者更好地设计和优化异步代码,提升应用程序的性能和响应速度。

好啦,今天的分享就到这儿啦,文章如果有错误欢迎指出!

相关推荐
PAK向日葵13 分钟前
【算法导论】PDD 0817笔试题题解
算法·面试
加班是不可能的,除非双倍日工资2 小时前
css预编译器实现星空背景图
前端·css·vue3
wyiyiyi3 小时前
【Web后端】Django、flask及其场景——以构建系统原型为例
前端·数据库·后端·python·django·flask
gnip3 小时前
vite和webpack打包结构控制
前端·javascript
excel4 小时前
在二维 Canvas 中模拟三角形绕 X、Y 轴旋转
前端
阿华的代码王国4 小时前
【Android】RecyclerView复用CheckBox的异常状态
android·xml·java·前端·后端
一条上岸小咸鱼4 小时前
Kotlin 基本数据类型(三):Booleans、Characters
android·前端·kotlin
Jimmy4 小时前
AI 代理是什么,其有助于我们实现更智能编程
前端·后端·ai编程
ZXT4 小时前
promise & async await总结
前端
Jerry说前后端4 小时前
RecyclerView 性能优化:从原理到实践的深度优化方案
android·前端·性能优化