JavaScript 中的事件循环(Event Loop):从基础到深入

93 阅读5分钟

JavaScript 中的事件循环(Event Loop):从基础到深入

JavaScript 是一种单线程语言,这意味着它一次只能执行一个任务。这种设计简化了开发过程,避免了多线程编程中的复杂性,但也带来了问题——如何在不阻塞主线程的情况下处理耗时操作(如网络请求、定时器等)。事件循环(Event Loop) 正是 JavaScript 用来解决这个问题的核心机制。

一、JavaScript 的单线程特性

JavaScript 最初被设计为单线程语言,主要是为了防止浏览器中多个脚本同时操作 DOM 所带来的同步问题。例如:

document.getElementById('btn').addEventListener('click', () => {
    console.log('Button clicked!');
});

如果多个线程可以同时修改 DOM,就会导致状态不一致的问题。因此,JavaScript 引擎只允许在一个线程上执行代码。

二、调用栈(Call Stack)

JavaScript 使用 调用栈(Call Stack) 来管理函数的执行顺序。每当遇到一个函数调用,引擎就会将其推入调用栈中执行;当函数返回后,它会被弹出调用栈。

function foo() {
    console.log('foo');
}

function bar() {
    foo();
    console.log('bar');
}

bar();
console.log('end');

调用栈的变化如下:

  1. bar() 被调用 → 推入栈。
  2. bar 中调用 foo()foo() 推入栈。
  3. foo() 执行完毕 → 弹出栈。
  4. bar() 继续执行 → 输出 'bar',然后弹出栈。
  5. 最后输出 'end'

这就是 JavaScript 同步执行的基本流程。

三、异步回调与 Web APIs

JavaScript 虽然是单线程的,但浏览器并不是。浏览器提供了许多 Web APIs(如 setTimeout、fetch、DOM 事件等),它们可以在 JavaScript 主线程之外运行。例如:

console.log('Start');

setTimeout(() => {
    console.log('Timeout');
}, 0);

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

console.log('End');

输出顺序为:Start、end、Promise、Timeout

为什么异步代码不是按代码顺序执行?这就引出了我们接下来要讲的 事件循环机制

四、事件循环(Event Loop)的工作原理

Event Loop 事件循环是 JavaScript 运行时中用于协调代码执行、处理异步操作(如定时器、网络请求、用户交互等)的一种机制。

事件循环的主要职责是协调 调用栈消息队列(Message Queue),确保 JavaScript 在单线程下也能高效地处理异步任务。

  • 事件循环工作原理:

    1. 事件循环从宏任务队列中取出一个宏任务来执行,(例如,由setTimeoutsetInterval、I/O事件或用户交互等触发的任务)js脚本也是一个宏任务。
    2. 执行宏任务中的同步代码。当遇到异步操作,若该任务是宏任务则将其放入宏任务队列,若是微任务则将其放入微任务队列。
    3. 一旦当前宏任务执行完毕,事件循环不会立即去取下一个宏任务来执行。而是连续不断地执行微任务队列中的每一个微任务(如Promise.then().catch()回调、MutationObserver回调等),直到微任务队列为空。
    4. 微任务都执行完后,则会进行页面渲染
    5. 最后,事件循环才会回到宏任务队列,选择下一个宏任务来继续这个循环。

    以如下为例:

    console.log("第一轮宏任务开始");
    
    console.log("第一轮同步代码执行");
    
    
    // Promise 本身是同步的,它后面的then方法是异步的
    const promise1 = Promise.resolve("Promise1");
    const promise2 = Promise.resolve("Promise2");
    
    const promise3 = new Promise((resolve) => {
      console.log("promise3");
      resolve("Promise3");
    });
    
    // 这是第一轮的微任务
    promise1.then((res) => {
      console.log(res);
    });
    promise2.then((res) => {
      console.log(res);
    });
    promise3.then((res) => {
      console.log(res);
    });
    
    setTimeout(() => {
      console.log("第二轮宏任务开始");
      console.log("第二轮同步代码执行");
      const promise4 = Promise.resolve("Promise4");
      promise4.then((res) => {
        console.log(res);
      });
      console.log("第二轮宏任务结束");
    }, 0);
    setTimeout(() => {
      console.log("第三轮宏任务开始");
    }, 0);
    
    
    console.log("第一轮宏任务结束");
    
    

    Promise.then 是微任务,会在当前宏任务结束后立即执行;setTimeout 是宏任务,虽然设置为 0ms,但依然排在下一个宏任务阶段;

    执行结果如下:

    eventloop.png

  • JavaScript中任务被分为宏任务(macrotask)和微任务(microtask)

    宏任务队列(Macro Task Queue)

    每次事件循环迭代会从宏任务队列中取出一个任务放入调用栈执行。

    常见的宏任务包括:

    • setTimeout
    • setInterval
    • setImmediate(Node.js)
    • I/O 操作
    • UI 渲染(浏览器)

    微任务队列(Micro Task Queue)

    微任务具有更高的优先级,在每次宏任务执行完之后,会清空微任务队列。

    常见的微任务包括:

    • Promise.then()/catch/finally

      这里promise本身是同步的,但Promise.then()是异步的

      例如:

      console.log('同步代码开始执行')
      
      const promise = new Promise((resolve)=>{
          console.log('promise执行')
          resolve('微任务执行')
      })
      
      promise.then((res)=>{
          console.log(res)
      })
      
      console.log('同步代码执行结束')
      

      执行结果如下,可以看出Promise本身是同步的

      同步代码开始执行
      promise执行
      同步代码执行结束
      微任务执行
      
    • queueMicrotask

      这个方法是用于将一个函数作为微任务(microtask)加入到微任务队列中

      queueMicrotask(function);
      
    • MutationObserver

      MutationObserver 是 JavaScript 中用于监视 DOM 元素变化的 API。它允许你观察特定 DOM 元素的变化(例如属性、子节点、文本内容等),并在发生变化时执行回调函数。

Node.js 中可以通过 process.nextTick() 插入比微任务还优先的任务

六、实际应用与优化建议

  1. 避免长时间阻塞主线程

    不要在主线程中执行大量计算或同步阻塞操作,否则会导致页面卡顿甚至崩溃。

    // 不推荐
    for (let i = 0; i < 1e9; i++) {}
    
    // 推荐使用异步方式或 Web Worker
    setTimeout(() => {
        // 执行耗时操作
    }, 0);
    
  2. 合理使用微任务和宏任务

    • 如果希望尽快执行某个任务,使用 Promise.resolve().then(...)
    • 如果需要延迟执行,使用 setTimeout(fn, 0)
    • 如果是动画相关,使用 requestAnimationFrame
    • 如果是 Node.js,考虑使用 setImmediateprocess.nextTick
  3. 注意微任务爆炸问题

    过多的微任务可能导致主线程无法处理其他任务,造成性能瓶颈。例如递归调用 Promise.then 可能导致堆栈溢出或性能下降。