深入理解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异步编程的核心,宏任务和微任务则是其中的重要组成部分。掌握这些概念,可以帮助开发者更好地设计和优化异步代码,提升应用程序的性能和响应速度。

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

相关推荐
赛博丁真Damon11 分钟前
【VSCode插件】【p2p网络】为了硬写一个和MCP交互的日程表插件(Cursor/Trae),我学习了去中心化的libp2p
前端·cursor·trae
江城开朗的豌豆20 分钟前
Vue的keep-alive魔法:让你的组件"假死"也能满血复活!
前端·javascript·vue.js
BillKu40 分钟前
Vue3 + TypeScript 中 let data: any[] = [] 与 let data = [] 的区别
前端·javascript·typescript
GIS之路1 小时前
OpenLayers 调整标注样式
前端
_一条咸鱼_1 小时前
Android Gson基础数据类型转换逻辑(6)
android·面试·gson
爱吃肉的小鹿1 小时前
Vue 动态处理多个作用域插槽与透传机制深度解析
前端
GIS之路1 小时前
OpenLayers 要素标注
前端
前端付豪1 小时前
美团 Flink 实时路况计算平台全链路架构揭秘
前端·后端·架构
sincere_iu1 小时前
#前端重铸之路 Day7 🔥🔥🔥🔥🔥🔥🔥🔥
前端·面试
设计师也学前端1 小时前
SVG数据可视化组件基础教程7:自定义柱状图
前端·svg