深入理解 JavaScript 事件循环机制:event loop

112 阅读9分钟

前言

让我们在了解微任务、宏任务与Event loop 之前,我们先要知道JavaScript是一个单线程的脚本语言。

为什么JavaScript是单线程?

首先,肯定是可以提高效率

其次,JavaScript是单线程,和他的用途有很大关系。JavaScript是浏览器脚本语言,他的主要作用是处理交互问题,以及操作DOM。如果 JavaScript 是多线程的,多个线程同时操作 DOM 会导致复杂的同步问题(例如一个线程删除节点,另一个线程修改节点)。单线程避免了锁、死锁等并发问题,简化了设计。 既然JavaScript是单线程,也就是说它在任意时刻只能执行一个任务。试想一下,如果我们在浏览网页的时候,但是网页包含的超清图片加载很慢,难道我们的网页要一直卡着直到图片完全显示出来?答案肯定是不行的。因此就有了同步任务 (Synchronous)异步任务 (Asynchronous)

什么是同步任务和异步任务?

同步任务:即主线程上的任务,按照顺序由上⾄下依次执⾏,当前⼀个任务执⾏完毕后,才能执⾏下⼀个任务。

异步任务:不进⼊主线程,⽽是进⼊任务队列的任务,执行完毕之后会产生一个回调函数,并且通知主线程。当主线程上的任务执行完后,就会调取最早通知自己的回调函数,使其进入主线程中执行。异步任务分为宏任务微任务

执行栈和任务队列

执行栈(调用栈)

在 JavaScript 的运行机制中,执行栈(Execution Stack) ,也被称为调用栈(Call Stack) 。它用于管理代码执行过程中函数的调用顺序。

当一个 JavaScript 程序开始运行时,首先会创建一个全局执行上下文,这个上下文会被推入执行栈中,表示程序的最外层作用域开始执行。每当一个函数被调用时,JavaScript 引擎就会为这个函数创建一个函数执行上下文 ,然后将这个上下文压入执行栈的顶部。

每个执行上下文都包含两个核心部分:

  1. 词法环境 :用于存储函数内部定义的变量、函数声明以及参数的映射关系。词法环境主要负责变量的查找和作用域链的构建。
  2. 变量环境 :用于存储通过 var 声明的变量。在早期的 JavaScript 版本中,变量环境和词法环境是同一个结构,但在 ES6 引入 let 和 const 之后,为了支持块级作用域,变量环境和词法环境被分开处理。

当某个执行上下文位于执行栈的顶部时,意味着它当前正在被执行。执行完成后,该上下文会从栈顶弹出,并被销毁(除非存在闭包引用),然后继续执行下一个位于栈顶的上下文。

8045d387105ad15652d8e2f82d9488d5.png

任务队列

事件队列是一个存储着任务完成后触发的回调函数 的队列,只有当这个异步任务真正执行完成后,它的结果才会被放入任务队列,供后续处理或回调使用。其中的任务严格按照时间先后顺序执行,排在队头的任务将会率先执行,而排在队尾的任务会最后执行。事件队列每次仅执行一个任务,在该任务执行完毕之后,再执行下一个任务。执行栈则是一个类似于函数调用栈的运行容器,当执行栈为空时,JS 引擎便检查事件队列,如果事件队列不为空的话,事件队列便将第一个任务压入执行栈中运行。

让我们来看一个例子

setTimeout(()=>{
    console.log('11111')
},1000)
setTimeout(()=>{
    console.log('22222')
},10)

当执行第一个定时器的时候,他并不会立马进入任务队列。而是先交给浏览器专属的定时器线程管理,他会在1000ms后触发回调。同时第二个定时器也是如此,它会在10ms后触发回调。

当主线程空闲后(同步代码执行完毕后),就会执行任务队列里面回调函数。

image.png

Event Loop

我们注意到,在异步代码完成后仍有可能要在一旁等待,因为此时程序可能在做其他的事情,等到程序空闲下来才有时间去看哪些异步已经完成了。所以 JavaScript 有一套机制去处理同步和异步操作,那就是事件循环 (Event Loop)。

请看下面这张图

7bcc4580c7d8ce2dcadf5c45b575ec31.png

这张图片展示了JavaScript事件循环(Event Loop)的工作流程。以下是详细的步骤描述:

  1. 同步执行

    • 所有的同步任务首先被放入“执行栈”中,按照顺序依次执行。
  2. 执行栈

    • “执行栈”是一个后进先出(LIFO)的数据结构,用于管理正在执行的同步任务。
    • 当一个同步任务完成后,它会从执行栈中弹出。
    • 如果执行栈为空,系统会检查“事件队列”中是否有待处理的任务。
  3. 异步任务

    • 在执行同步任务的过程中,如果遇到异步任务(如定时器、网络请求等),这些异步任务会被移出执行栈,并交由“异步线程”处理。
    • 异步线程负责执行这些异步任务,并在任务完成后将相应的回调函数放入“事件队列”中。
  4. 事件队列

    • “事件队列”是一个先进先出(FIFO)的数据结构,用于存放等待执行的异步回调函数和其他新加入的任务。
    • 当异步任务完成时,其对应的回调函数会被添加到事件队列的末尾。
  5. 事件循环

    • 事件循环不断检查执行栈是否为空。如果执行栈为空,则从事件队列中取出最前面的任务并将其放入执行栈中执行。
    • 这个过程会不断重复,直到所有任务都被处理完毕。

宏任务与微任务

异步代码又分为:微任务(microtask)和宏任务(macrotask)。想象一下你是一名餐厅服务员,一天工作中,一位顾客进店点餐(宏任务开始)。你在记录完顾客的点餐信息后,立即在系统中更新订单状态为“已下单”(微任务),然后返回继续处理该顾客的需求,比如告知厨房开始准备食物(继续宏任务)。与此同时,若有即时需求如加水或调料,你会快速响应这些小任务而不打断主要的服务流程(更多微任务)。最后,在顾客用餐完毕后,你完成结账手续,结束对该顾客的服务(宏任务结束)。这样,通过处理一系列的宏任务和其间穿插的微任务,确保了服务的流畅与高效。

宏任务 有哪些:

  • 脚本执行(script):当一个.js文件中的代码被执行时,这个执行过程本身就可以视为一个宏任务。这意味着每次加载并执行一个新的脚本文件,都会将其作为一个新的宏任务加入到事件循环的任务队列中。
  • 定时器:包括setTimeout()setInterval()设定的回调函数。这些方法用于延迟执行一段代码,直到指定的时间过后才将任务推入任务队列等待执行。
  • 事件处理(比如事件监听,窗口滚动等等):用户触发的事件,例如点击按钮(click)、按键(keydown, keyup)等事件的回调函数都属于宏任务。在Node.js中也有类似的事件机制,比如监听文件变化的事件。
  • I/O操作:包括文件读写、网络请求等。在Node.js环境中,这包括使用fs模块进行的文件系统操作以及通过HTTP模块发起的网络请求;在浏览器中,这可以是任何与外部资源交互的操作,如Ajax请求。
  • 在node.js环境中setImmediate 也是一个宏任务

微任务 有哪些:

  • Promise回调:Promise对应的thencatch方法注册的回调函数将作为微任务排队等待执行。
  • MutationObserver回调:用于监听DOM变化的MutationObserver,一旦观察到指定的DOM改变,相应的回调函数将以微任务的形式被执行。
  • process.nextTick(Node.js环境) :虽然从技术角度来说,process.nextTick在Node.js中被认为比普通的微任务具有更高的优先级,但为了便于理解,它常被视作一种特殊的微任务。它允许你在当前操作完成后立即执行回调,甚至优先于其他微任务执行。
  • queueMicrotask:无论是在浏览器环境中还是在Node.js中都可以使用queueMicrotask()方法来安排一个微任务。

宏任务与微任务的容易犯错的点

  1. Promise 构造函数本身是同步的,只有 then/catch/finally 回调才是微任务

new Promise((resolve) => {
  console.log('同步执行'); // 同步执行
  resolve();
}).then(() => {
  console.log('微任务'); // 微任务
});

console.log('宏任务结束');

// 输出顺序:
// 同步执行 → 宏任务结束 → 微任务

  1. setTimeout 和 setInterval 的时间参数不保证精确执行 setTimeout和setInterval是一个宏任务,在它们的回调函数执行之前,它们会执行程序中的同步代码和微任务,所以时间参数不保证精确执行。
  2. async/await 并不是微任务本身,但它会触发微任务链

async/await 是 Promise 的语法糖,其背后依然使用 Promise,因此会产生微任务。


async function test() {
  console.log('async函数内部');
  await Promise.resolve();
  console.log('await之后');
}

test();

console.log('宏任务结束');

// 输出顺序:
// async函数内部 → 宏任务结束 → await之后

总结

JavaScript 的事件循环机制是其处理异步任务的核心,通过宏任务和微任务的配合,实现了单线程下的异步编程。了解事件循环机制对于编写高性能、无阻塞的 JavaScript 代码至关重要。同时,浏览器的多进程架构和渲染进程的工作流程也为我们理解页面的渲染和交互提供了基础。在实际开发中,我们应该合理利用异步编程,避免阻塞主线程,提高页面的性能和用户体验。