深入理解JavaScript的事件循环机制

导语:

JavaScript 作为一门单线程语言,却能高效处理各种异步操作,这背后离不开其独特的事件循环(Event Loop)机制。理解事件循环,不仅能让你更好地驾驭 JavaScript,还能帮你避免常见的异步陷阱。如果你对 JavaScript 的异步行为感到困惑,那么这篇文章将为你揭开它的神秘面纱。

正文:

1. JavaScript 是单线程的,为什么还能处理异步?

首先,我们要明确一个概念:JavaScript 引擎本身是单线程的,这意味着它一次只能执行一个任务。那为什么我们还能使用 setTimeout、fetch 等异步操作,而不阻塞主线程呢?

这是因为 JavaScript 的运行环境(比如浏览器或 Node.js)并非单线程的,它们提供了额外的能力,比如:

  • Web API (浏览器): 提供了诸如 setTimeout、DOM Events、fetch 等接口。
  • Node.js API (Node.js): 提供了文件 I/O、网络请求等功能。

这些 API 在遇到异步任务时,会将任务委托给其他线程(例如,浏览器内核中的线程),当异步任务完成后,再通知 JavaScript 主线程。这个过程就是事件循环的核心。

2. 什么是调用栈(Call Stack)?

调用栈就像一个堆叠的盘子,每次调用一个函数,就会把函数压入栈中,函数执行完毕后,就会从栈中弹出。JavaScript 主线程每次执行的任务都是从调用栈中拿取的。

function foo() {
  console.log("foo called");
  bar();
}

function bar() {
  console.log("bar called");
}

foo();

在这个例子中,foo() 和 bar() 会依次被压入调用栈,bar() 执行完后弹出,然后 foo() 执行完弹出。

3. 任务队列(Task Queue)/ 回调队列(Callback Queue)

当异步任务(如 setTimeout、fetch)完成后,它们的回调函数不会立即进入调用栈,而是会被放入任务队列(也称为回调队列)。 任务队列就像一个等待执行的函数列表。

任务队列分两种:

  • 宏任务队列(Macrotask Queue): 例如 setTimeout、setInterval、setImmediate (Node.js)、requestAnimationFrame (浏览器)、I/O 操作、UI rendering。
  • 微任务队列(Microtask Queue): 例如 Promise.then、async/await、queueMicrotask、MutationObserver。

4. 事件循环(Event Loop)的运作方式

事件循环就是不断地监控调用栈和任务队列,它的工作方式可以用以下伪代码来描述:

while (true) {
  // 1. 检查调用栈是否为空
  if (callStack.isEmpty()) {
    // 2. 如果调用栈为空,先检查微任务队列
    if (!microtaskQueue.isEmpty()) {
      //  从微任务队列中取出第一个任务并执行
      const microtask = microtaskQueue.shift();
      callStack.push(microtask);
      // 循环执行 微任务,直到微任务队列为空
      while(!microtaskQueue.isEmpty()){
          const microtask = microtaskQueue.shift();
          callStack.push(microtask);
      }
    }else{
      // 3. 如果微任务队列为空,再检查宏任务队列
       if (!macrotaskQueue.isEmpty()) {
        // 从宏任务队列中取出第一个任务并执行
        const macrotask = macrotaskQueue.shift();
        callStack.push(macrotask);
      }
    }

  }
}

简单来说,事件循环会:

  1. 检查调用栈: 看看调用栈是否为空。
  2. 优先检查微任务队列: 如果调用栈为空,则会先检查微任务队列,将队列中的所有微任务依次执行完毕,直到微任务队列为空。
  3. 执行宏任务队列: 然后检查宏任务队列,取出最老的宏任务到调用栈中执行。
  4. 重复执行: 不断循环以上步骤。

5. 代码示例:理解执行顺序

console.log('script start');

setTimeout(() => {
  console.log('setTimeout 1');
  Promise.resolve().then(() => {
      console.log('promise inside setTimeout')
  })
}, 0);

Promise.resolve().then(() => {
  console.log('promise 1');
});

Promise.resolve().then(() => {
  console.log('promise 2');
});


console.log('script end');

这段代码的执行顺序是:

  1. script start
  2. script end
  3. promise 1
  4. promise 2
  5. setTimeout 1
  6. promise inside setTimeout

解释:

  • 首先,同步代码按顺序执行,输出 script start 和 script end。
  • Promise 的 then 回调放入微任务队列。
  • setTimeout 的回调放入宏任务队列。
  • 当前同步代码执行完毕后,先清空微任务队列,输出 promise 1 和 promise 2。
  • 然后,事件循环取出一个宏任务执行,即 setTimeout 的回调函数,输出 setTimeout 1, 之后遇到了微任务,放到微任务队列,循环检测,输出 promise inside setTimeout
  • 最终,事件循环继续执行下一轮。

总结:

JavaScript 的事件循环机制是理解异步编程的关键。它通过调用栈、任务队列和事件循环之间的协作,实现了 JavaScript 的非阻塞执行。