一文搞懂事件循环机制

2,306 阅读5分钟

前言

js是单线程执行的,也就是同一时间点只有一个线程在执行代码,主线程一次只能处理一个任务。那么当我处理用户交互(输入输出操作)、网络请求(fetch等)、开启定时器(setTimeout)这些需要耗费较长时间的操作的时候,如果只能等当前任务处理完才能去执行接下来代码,就会浪费到很多时间,也就是阻塞了线程,即使线程当前没有事情要做,下面的代码也无法获得执行机会。如何让js在单线程环境下处理多个任务不会阻塞线程?事件循环机制(Event Loop)说:哥们,别担心,我来解决!

PS:事件循环机制最早并没有一个单一的提议者,它是在多种编程模型和技术的演进中逐渐形成并被采纳的,浏览器开发者在实现js引擎的时候,也在不同程度上支持和优化了事件循环机制。

一、基本概念

1.执行栈(Call Stack)

用于存放正在执行的代码,同步代码在这个栈中直接运行。每次执行函数的时候,该函数会被推入栈顶,执行完后从栈中弹出。比如下面代码在运行过程中,压入执行栈的顺序如下:

eventloop1.png

2.任务队列 (Task Queue)

也叫消息队列(message queue),浏览器中各种Web API为异步代码提供了单独的运行空间,当异步代码运行完毕后,代码中的回调会被推到任务队列中。事件循环会定期检查任务队列,并将其中的任务逐个推入执行栈中执行。

异步任务分为宏任务(Macro Task)微任务(Micro Task) ,任务队列对应的分为宏任务队列微任务队列

2.1 宏任务:

事件循环的外部事件,指的是在js环境之外由浏览器或Node.js环境触发的任务。常见的包括:

  • setTimeoutsetInterval 定时器事件。如:setTimeout(() => { console.log('swan') }, 1000)
  • 用户输入事件: 比如click点击事件、输入事件、mousemove鼠标移动事件等。这些事件通常由浏览器的事件系统触发。
  • I/O操作: 包括网络请求、文件读取等。
  • UI渲染事件: 浏览器渲染界面时会触发的事件,如回流、重绘、RequestAnimationFrame(用于在浏览器下一次重绘之前执行动画代码)等。
  • Web API: 比如fetch请求,虽然fetch本身返回的通常是一个Promise微任务,但是发起网络请求的这个动作时由浏览器Web API处理的。

2.2 微任务

通常由js的内部机制触发,例如PromiseMutaionObserverprocess.nextTick等。微任务具有比宏任务更高的优先级,事件循环会先执行微任务,执行完所有微任务再执行一个宏任务,执行完一个宏任务后又会检查是否有微任务,接着继续循环进行。常见的微任务有如下:

  • Promise 注意Promise的执行器里的代码是同步代码,Promise.thenPromise.catch里的任务才是微任务。
  • MutationObserver 用于监听和响应 DOM 的变化,提供高效的变更检测机制。
  • process.nextTick 在Node.js环境中,process.nextTick可以将一个函数添加到微任务队列中。它会在当前执行栈清空后、微任务队列处理之前执行。(Node.js特有的功能,不适用在浏览器环境)

3.事件循环执行机制

  1. 执行同步代码

    首先执行主线程上的所有同步代码(也就是立即执行的代码)。

  2. 执行微任务

    同步代码执行完后,检查微任务队列(Microtask Queue)是否有微任务(如 Promise 的 .then 回调和 MutationObserver 回调)。如果有,依次执行所有的微任务。

  3. 执行宏任务

    微任务队列中的任务执行完后,检查宏任务队列(Task Queue 或 Callback Queue)中是否有宏任务(如 setTimeoutsetIntervalI/O 操作等)。如果有,从宏任务队列中取出一个任务并执行。

  4. 更新渲染

    执行宏任务之后,浏览器会更新渲染(重新绘制页面)。这包括布局计算、绘制等操作。

  5. 重复

    完成上述步骤后,重新回到第2步,继续处理微任务。直到微任务队列为空,再处理宏任务,直到宏任务队列也为空。

二、示例图解

下面用一个示例来说明事件循环的执行过程,看完后你会发现,原来事件循环如此简单!

// 代码示例
console.log('Start');
​
setTimeout(() => {
  console.log('Timeout');
}, 0);
​
Promise.resolve().then(() => {
  console.log('Promise');
});
​
console.log('End');

图解简化了js引擎的执行栈、宏任务队列、微任务队列、打印输出,以上代码执行的过程如下:

eventloop2.png

eventloop3.png

eventloop4.png

eventloop5.png

eventloop6.png

eventloop7.png

因此,最后代码的输出顺序即为:

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

输出解释

  1. 执行同步代码 console.log('Start'),输出 Start
  2. 执行 setTimeout(() => {...}, 0),将回调函数() => {...}放入宏任务队列。
  3. 执行 Promise.resolve().then(() => { console.log('Promise 1'); }),将回调函数放入微任务队列。
  4. 执行同步代码 console.log('End'),输出 End
  5. 微任务队列中的回调 () => { console.log('Promise 1'); } 执行,输出 Promise 1
  6. 执行宏任务队列中的回调 () => { console.log('Timeout 1'); Promise.resolve().then(() => { console.log('Promise 2'); }); }
  7. 在宏任务回调中,Promise.resolve().then(() => { console.log('Promise 2'); }) 会将回调函数 () => { console.log('Promise 2'); } 放入微任务队列。
  8. 微任务队列中的回调 () => { console.log('Promise 2'); } 执行,输出 Promise 2