前言
js是单线程执行的,也就是同一时间点只有一个线程在执行代码,主线程一次只能处理一个任务。那么当我处理用户交互(输入输出操作)、网络请求(fetch等)、开启定时器(setTimeout)这些需要耗费较长时间的操作的时候,如果只能等当前任务处理完才能去执行接下来代码,就会浪费到很多时间,也就是阻塞了线程,即使线程当前没有事情要做,下面的代码也无法获得执行机会。如何让js在单线程环境下处理多个任务不会阻塞线程?事件循环机制(Event Loop)说:哥们,别担心,我来解决!
PS:事件循环机制最早并没有一个单一的提议者,它是在多种编程模型和技术的演进中逐渐形成并被采纳的,浏览器开发者在实现js引擎的时候,也在不同程度上支持和优化了事件循环机制。
一、基本概念
1.执行栈(Call Stack)
用于存放正在执行的代码,同步代码在这个栈中直接运行。每次执行函数的时候,该函数会被推入栈顶,执行完后从栈中弹出。比如下面代码在运行过程中,压入执行栈的顺序如下:
2.任务队列 (Task Queue)
也叫消息队列(message queue),浏览器中各种Web API为异步代码提供了单独的运行空间,当异步代码运行完毕后,代码中的回调会被推到任务队列中。事件循环会定期检查任务队列,并将其中的任务逐个推入执行栈中执行。
异步任务分为宏任务(Macro Task) 和微任务(Micro Task) ,任务队列对应的分为宏任务队列和微任务队列。
2.1 宏任务:
事件循环的外部事件,指的是在js环境之外由浏览器或Node.js环境触发的任务。常见的包括:
setTimeout和setInterval: 定时器事件。如:setTimeout(() => { console.log('swan') }, 1000)。- 用户输入事件: 比如
click点击事件、输入事件、mousemove鼠标移动事件等。这些事件通常由浏览器的事件系统触发。 - I/O操作: 包括网络请求、文件读取等。
- UI渲染事件: 浏览器渲染界面时会触发的事件,如回流、重绘、
RequestAnimationFrame(用于在浏览器下一次重绘之前执行动画代码)等。 - Web API: 比如
fetch请求,虽然fetch本身返回的通常是一个Promise微任务,但是发起网络请求的这个动作时由浏览器Web API处理的。
2.2 微任务
通常由js的内部机制触发,例如Promise、MutaionObserver和process.nextTick等。微任务具有比宏任务更高的优先级,事件循环会先执行微任务,执行完所有微任务再执行一个宏任务,执行完一个宏任务后又会检查是否有微任务,接着继续循环进行。常见的微任务有如下:
Promise: 注意Promise的执行器里的代码是同步代码,Promise.then和Promise.catch里的任务才是微任务。MutationObserver: 用于监听和响应 DOM 的变化,提供高效的变更检测机制。process.nextTick: 在Node.js环境中,process.nextTick可以将一个函数添加到微任务队列中。它会在当前执行栈清空后、微任务队列处理之前执行。(Node.js特有的功能,不适用在浏览器环境)
3.事件循环执行机制
-
执行同步代码:
首先执行主线程上的所有同步代码(也就是立即执行的代码)。
-
执行微任务:
同步代码执行完后,检查微任务队列(Microtask Queue)是否有微任务(如
Promise的.then回调和MutationObserver回调)。如果有,依次执行所有的微任务。 -
执行宏任务:
微任务队列中的任务执行完后,检查宏任务队列(Task Queue 或 Callback Queue)中是否有宏任务(如
setTimeout、setInterval、I/O操作等)。如果有,从宏任务队列中取出一个任务并执行。 -
更新渲染:
执行宏任务之后,浏览器会更新渲染(重新绘制页面)。这包括布局计算、绘制等操作。
-
重复:
完成上述步骤后,重新回到第2步,继续处理微任务。直到微任务队列为空,再处理宏任务,直到宏任务队列也为空。
二、示例图解
下面用一个示例来说明事件循环的执行过程,看完后你会发现,原来事件循环如此简单!
// 代码示例
console.log('Start');
setTimeout(() => {
console.log('Timeout');
}, 0);
Promise.resolve().then(() => {
console.log('Promise');
});
console.log('End');
图解简化了js引擎的执行栈、宏任务队列、微任务队列、打印输出,以上代码执行的过程如下:
因此,最后代码的输出顺序即为:
Start
End
Promise
Timeout
三、实战为快
再来看一段代码加深理解
代码片段
console.log('Start');
setTimeout(() => {
console.log('Timeout 1');
Promise.resolve().then(() => {
console.log('Promise 2');
});
}, 0);
Promise.resolve().then(() => {
console.log('Promise 1');
});
console.log('End');
输出顺序:
Start
End
Promise 1
Timeout 1
Promise 2
输出解释
- 执行同步代码
console.log('Start'),输出Start。 - 执行
setTimeout(() => {...}, 0),将回调函数() => {...}放入宏任务队列。 - 执行
Promise.resolve().then(() => { console.log('Promise 1'); }),将回调函数放入微任务队列。 - 执行同步代码
console.log('End'),输出End。 - 微任务队列中的回调
() => { console.log('Promise 1'); }执行,输出Promise 1。 - 执行宏任务队列中的回调
() => { console.log('Timeout 1'); Promise.resolve().then(() => { console.log('Promise 2'); }); }。 - 在宏任务回调中,
Promise.resolve().then(() => { console.log('Promise 2'); })会将回调函数() => { console.log('Promise 2'); }放入微任务队列。 - 微任务队列中的回调
() => { console.log('Promise 2'); }执行,输出Promise 2。