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

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

相关推荐
dy171732 分钟前
element-plus表格默认展开有子的数据
前端·javascript·vue.js
2501_915918414 小时前
Web 前端可视化开发工具对比 低代码平台、可视化搭建工具、前端可视化编辑器与在线可视化开发环境的实战分析
前端·低代码·ios·小程序·uni-app·编辑器·iphone
程序员的世界你不懂5 小时前
【Flask】测试平台开发,新增说明书编写和展示功能 第二十三篇
java·前端·数据库
索迪迈科技5 小时前
网络请求库——Axios库深度解析
前端·网络·vue.js·北京百思可瑞教育·百思可瑞教育
在未来等你5 小时前
Kafka面试精讲 Day 13:故障检测与自动恢复
大数据·分布式·面试·kafka·消息队列
gnip5 小时前
JavaScript二叉树相关概念
前端
一朵梨花压海棠go6 小时前
html+js实现表格本地筛选
开发语言·javascript·html·ecmascript
attitude.x6 小时前
PyTorch 动态图的灵活性与实用技巧
前端·人工智能·深度学习
β添砖java6 小时前
CSS3核心技术
前端·css·css3
空山新雨(大队长)6 小时前
HTML第八课:HTML4和HTML5的区别
前端·html·html5