前言
JavaScript作为一门广泛应用于前端开发的编程语言,其单线程特性使得它需要一种特殊的机制来管理异步任务的执行,这就是事件循环机制 (Event Loop
)。本文将详细探讨JavaScript的事件循环机制,并解释其中的两个重要概念:宏任务(MacroTask)和微任务(MicroTask)。 JavaScript的单线程设计意味着在同一时间内只能执行一个任务。这种设计主要是为了避免多线程环境下的复杂同步问题,确保在执行
DOM
操作等关键任务时不会出现竞争条件。然而,单线程也带来了一个问题:如何处理那些需要长时间执行的任务,例如I/O操作
或网络请求,以免阻塞主线程?答案就是异步编程
和事件循环机制
。
异步编程在之前的文章中有分享过:Callback 🥊 Promise 🥊 Async/Await:谁才是异步之王?
如果有不太懂或者感兴趣的小伙伴可以去看看喔~本期我们直接进入正题吧。
1. 什么是事件循环机制?
事件循环是JavaScript引擎用来管理异步任务执行的核心机制。它的工作流程大致如下:
- 执行栈(Call Stack) :JavaScript首先执行栈顶的
同步任务
。当遇到异步任务时,会将这些任务注册到相应的任务队列中,并继续执行后续的同步任务。 - 任务队列(Task Queue) :异步任务完成后,其
回调函数会被放入任务队列
中等待执行。任务队列分为宏任务队列和微任务队列。 - 宏任务(MacroTask) :常见的宏任务包括
script整体代码
、setTimeout
、setInterval
、setImmediate
(在Node.js中)、I/O操作
和UI渲染
等。宏任务按照先进先出的原则执行,每次事件循环只会从宏任务队列中取出一个任务执行。 - 微任务(MicroTask) :微任务包括
promise.then()
,process.nextTick()
,MutationObserver()
以及使用async/await
产生的任务。微任务会在当前宏任务执行完毕后立即执行,且会在下一个宏任务开始之前全部执行完毕。因此,微任务具有比宏任务更高的优先级。
2. 事件循环的执行顺序
在第一次执行事件循环时,都是先执行宏任务再执行微任务,再执行宏任务,所以总结eventLoop步骤如下:
- 执行同步代码(这属于宏任务)如果遇到微任务,将其添加到微任务队列中;
- 执行完同步后,检查是否有异步代码需要执行;
- 执行所有的微任务
- 如果有需要,执行渲染任务,更新UI。
- 执行宏任务,也是开启了下一次事件循环
重复上述步骤,不断循环。
下面通过一个示例来展示事件循环的执行顺序:
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 变为fulfilled
或rejected
。- 如果 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异步编程的核心,宏任务和微任务则是其中的重要组成部分。掌握这些概念,可以帮助开发者更好地设计和优化异步代码,提升应用程序的性能和响应速度。
好啦,今天的分享就到这儿啦,文章如果有错误欢迎指出!
