面试官最爱问的JS事件循环Event Loop,这次让你彻底搞懂!

116 阅读12分钟

一、引言

如果你是一名 JavaScript 开发者,无论你是前端还是 Node.js 开发者,理解 Event Loop(事件循环)都是至关重要的。Event Loop 是 JavaScript 实现异步编程的核心机制,它决定了代码的执行顺序,也解释了为什么 setTimeout 并不总是按时执行,为什么 PromisesetTimeout 优先级高等现象。

本文将深入探讨 Event Loop 的工作原理,解释相关术语,并通过实际案例帮助你彻底掌握这一重要概念。

二、进程与线程

2.1 什么是进程?

进程是操作系统分配和管理资源的基本单位,每个进程都有独立的内存空间(如代码、数据和系统资源),不同进程之间互不干扰。

例如,当你同时运行浏览器和音乐播放器时,它们属于不同的进程,即使浏览器崩溃,音乐播放器仍能正常运行。进程的优点是稳定性高(一个进程崩溃不会影响其他进程),但缺点是创建和切换开销较大,因为操作系统需要为每个进程分配独立的资源。现代浏览器(如Chrome)采用多进程架构,每个标签页通常运行在单独的渲染进程中,以提高安全性和稳定性。

2.2 什么是线程

线程是进程内部的执行单元,属于轻量级的任务调度单位。同一进程的多个线程共享内存和资源,使得线程间通信更高效,但也可能导致数据竞争问题。

例如,浏览器的一个标签页(渲染进程)通常包含多个线程:主线程负责解析HTML/CSS和执行JavaScript,网络线程处理HTTP请求,而合成线程优化页面渲染。由于JS是单线程运行的,长时间运行的脚本会阻塞页面渲染,因此浏览器引入Web Worker来运行后台任务(如大数据计算),避免卡顿。线程的优点是切换速度快,适合高并发任务,但需要谨慎处理同步问题。

2.3 为什么JavaScript是单线程语言

众所周知,JavaScript是单线程语言,那么为什么它是单线线程的语言呢??

JavaScript 之所以设计为单线程语言,主要是因为它最初是为了在浏览器中操作 DOM 和处理用户交互而创建的。如果允许多线程同时操作 DOM,可能会导致不可预测的冲突(比如一个线程删除节点,另一个线程修改它),使页面行为混乱。单线程模型简化了编程,开发者无需担心复杂的线程同步问题(如死锁、竞态条件),代码执行顺序清晰可预测。

虽然 JavaScript 本身是单线程的,但它通过事件循环(Event Loop)异步回调机制(如 setTimeoutPromisefetch)实现了非阻塞执行。耗时任务(如网络请求)会被交给浏览器后台线程处理,完成后回调函数再回到主线程执行,这样就不会阻塞页面渲染和用户交互。

说到异步回调就不得不讲讲同步和异步代码了

三、同步与异步

3.1 同步

同步是指操作按照严格的顺序依次执行,每个操作必须等待前一个操作完成后才能开始。这是一种线性、阻塞式的执行方式。如果遇到了耗时性很长的代码,那么后面的代码都会被阻塞。

console.log("Start")    
alert("弹窗同步操作,会阻塞页面")     // 阻塞
console.log("End")  

在上面的代码中一开始会打印"Start",然后遇到弹窗的同步代码,如果不点击"确定"的话,弹窗代码会一直阻塞页面,这期间不能有别的操作,只有点击了之后才会继续运行,打印出"End"

3.2 异步

异步是指操作不需要等待前一个操作完成就可以开始执行。这是一种非阻塞式的执行方式,即使遇到耗时的操作,也不会阻塞后续代码的执行。JavaScript 通过回调函数、Promise、async/await 等机制来实现异步编程。

console.log("Start");

setTimeout(() => {
    console.log("异步操作,不会阻塞页面");
}, 2000);

console.log("End");

执行过程解析

  1. 同步代码执行

    • 首先打印 "Start"
    • 遇到 setTimeout,这是一个异步函数,JavaScript 会将其回调函数放入任务队列(稍后执行),而不会等待它完成。
    • 继续执行后面的代码,打印 "End"
  2. 异步回调执行

    • 大约 2 秒后,setTimeout 的回调函数从任务队列进入调用栈,执行并打印 "异步操作,不会阻塞页面"

输出顺序

Start
End
异步操作,不会阻塞页面

关键点

  • 非阻塞setTimeout 不会阻塞后续代码(console.log("End")),即使它的延迟时间是 0 毫秒。
  • 事件循环:JavaScript 通过事件循环机制处理异步任务,先执行同步代码,再处理异步回调。
  • 适用场景:网络请求(如 fetch)、文件读写(Node.js)、定时任务等耗时操作通常用异步避免阻塞主线程。

对比同步的例子,异步的优势在于:即使有耗时操作(如网络请求),页面仍能保持响应,不会“卡死”。

3.3 同步与异步的区别

特性同步异步
执行顺序严格顺序执行无需等待,并行执行
阻塞性阻塞后续代码执行非阻塞,立即继续执行
性能影响可能导致资源闲置提高资源利用率
错误处理可直接使用try-catch需要回调或Promise链捕获错误
代码复杂度简单直观相对复杂(需处理回调/Promise)
适用场景简单任务/快速操作I/O密集型/高延迟操作
调试难度较容易较困难(执行流不直观)
资源消耗线程可能被长时间阻塞更高效的线程利用

深入说明:

  1. 执行模型差异

    • 同步像单车道:车辆(操作)必须依次通过
    • 异步像多车道:车辆可以并行,但有复杂的交通规则
  2. 底层机制

    • 同步通常对应线程的阻塞式I/O

    • 异步可能使用:

      • 回调队列(JavaScript事件循环)
      • 多线程(后台线程处理)
      • 系统级异步I/O(如Linux的epoll)

四、深入理解 Event Loop?

Event Loop(事件循环) 是 JavaScript 运行时处理事件、回调和非阻塞 I/O 操作的机制。它允许 JavaScript 在单线程环境中实现异步行为,避免了多线程带来的复杂性。

4.1 为什么需要 Event Loop?

JavaScript 是单线程语言,这意味着它一次只能执行一个任务。如果没有异步处理机制,所有操作(如网络请求、文件读写、定时器等)都会阻塞主线程,导致页面卡顿或无响应。Event Loop 通过巧妙地安排任务执行顺序,使得 JavaScript 能够高效处理大量异步操作。

4.2 宏任务与微任务

在JavaScript的异步世界里,任务被分成了两种类型:宏任务(Macro-tasks)微任务(Micro-tasks) 。这两种任务在Event Loop中有着不同的优先级和执行时机。

宏任务(Macro-tasks)包括:

  • setTimeout
  • setInterval
  • I/O操作(例如网络请求 ajax、文件读写)
  • UI渲染
  • setImmediate (Node.js环境特有)

微任务(Micro-tasks)包括:

  • Promise.then()Promise.catch()Promise.finally()
  • process.nextTick (Node.js环境特有)
  • MutationObserver (用于监听DOM变化)

Promise本身是一个同步任务,只有Promise.then()才是一个微任务

4.3 Event Loop的执行顺序详解

  1. 执行全局同步代码
    • 所有同步代码按顺序执行,形成调用栈
  2. 处理微任务队列
    • 当调用栈清空后,Event Loop 会检查微任务队列
    • 执行所有微任务直到队列为空
  3. 渲染更新 (如果需要)
    • 浏览器可能会在此处进行页面渲染
  4. 处理宏任务队列
    • 从宏任务队列中取出一个任务执行
  5. 重复循环
    • 回到步骤2,继续处理微任务队列,然后渲染,再处理下一个宏任务

五、 async/await关键字的影响

简单来说,async/await 是让异步代码看起来像同步的语法糖,但实际执行时依然遵循 Event Loop 机制。当遇到 await 时,函数会暂停执行,让出线程去处理其他任务(比如点击事件、网络请求),而 await 后面的代码会被打包成一个微任务(Microtask),等到异步操作完成后,Event Loop 会在当前调用栈清空且没有宏任务(如 setTimeout)要执行时,优先执行这些微任务,从而继续之前的异步流程。这种机制既保持了代码的直观性,又不会阻塞主线程。

让我们看个栗子吧!!

console.log("Script start");

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

async function fetchData() {
    console.log("Start fetching...");
    await new Promise(resolve => {
        console.log("Inside Promise executor");
        setTimeout(resolve, 0); // 模拟异步操作
    });
    console.log("Fetch completed");
}

fetchData();

new Promise(resolve => {
    console.log("Promise 1 executor");
    resolve();
}).then(() => {
    console.log("Promise 1 then");
});

console.log("Script end");

执行流程分析(Event Loop 视角)

  1. 同步代码执行(调用栈)

    • console.log("Script start") → 输出 "Script start"

    • setTimeout 被调用,回调 () => { console.log("setTimeout") } 进入 宏任务队列(Task Queue)

    • fetchData() 被调用:

      • console.log("Start fetching...") → 输出 "Start fetching..."

      • await new Promise(...) 执行:

        • console.log("Inside Promise executor") → 输出 "Inside Promise executor"
        • setTimeout(resolve, 0) 让 resolve 进入 宏任务队列
      • await 暂停 fetchData,后面的 console.log("Fetch completed") 被包装成 微任务(Microtask) ,等待 resolve 触发

    • new Promise(...) 执行:

      • console.log("Promise 1 executor") → 输出 "Promise 1 executor"
      • resolve() 立即执行,.then(() => { console.log("Promise 1 then") }) 进入 微任务队列
    • console.log("Script end") → 输出 "Script end"

  2. 微任务执行(当前调用栈清空后)

    • 微任务队列

      • Promise 1 then → 输出 "Promise 1 then"
    • fetchData 的 console.log("Fetch completed") 还不能执行,因为 resolve 还没触发)

  3. 宏任务执行(Event Loop 检查微任务队列为空后)

    • 宏任务队列(按顺序执行):

      1. setTimeout(resolve, 0)(来自 fetchData 的 await)→ 触发 resolve

        • 这会使得 console.log("Fetch completed") 进入 微任务队列
      2. setTimeout(() => { console.log("setTimeout") }) → 输出 "setTimeout"

  4. 再次执行微任务(resolve 触发后)

    • 微任务队列

      • console.log("Fetch completed") → 输出 "Fetch completed"

最终输出顺序

Script start  
Start fetching...  
Inside Promise executor  
Promise 1 executor  
Script end  
Promise 1 then  
Fetch completed  
setTimeout  

六、案例分析

让我们通过几个案例来深入理解 Event Loop。

案例 1:基本执行顺序

console.log('1. 同步代码开始');

setTimeout(() => {
  console.log('4. setTimeout 回调');
}, 0);

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

console.log('2. 同步代码结束');

输出顺序:

1. 同步代码开始
2. 同步代码结束
3. Promise 回调
4. setTimeout 回调

解析:

  1. 同步代码按顺序执行,输出 1 和 2
  2. 同步代码执行完毕后,检查微任务队列,执行 Promise 回调,输出 3
  3. 微任务执行完毕后,从任务队列中取出 setTimeout 回调执行,输出 4

案例 2:嵌套 Promise 和 setTimeout

console.log('脚本开始');

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

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

console.log('脚本结束');

输出顺序:

脚本开始
脚本结束
Promise 1
Promise 2
setTimeout

解析:

  1. 同步代码按顺序执行,输出"脚本开始"和"脚本结束"
  2. 执行微任务队列中的 Promise 回调,第一个 then 输出"Promise 1"
  3. 第一个 then 返回的 Promise 又添加了一个微任务,所以接着输出"Promise 2"
  4. 最后执行任务队列中的 setTimeout 回调

案例 3:复杂场景下的执行顺序

console.log('1');

setTimeout(() => {
  console.log('2');
  Promise.resolve().then(() => {
    console.log('3');
  });
}, 0);

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

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

console.log('6');

输出顺序:

1
6
5
2
3
4

解析:

  1. 同步代码执行,输出 1 和 6
  2. 执行微任务队列中的 Promise 回调,输出 5
  3. 从任务队列中取出第一个 setTimeout 回调执行,输出 2
  4. 该回调中的 Promise 又添加了一个微任务,立即执行,输出 3
  5. 从任务队列中取出第二个 setTimeout 回调执行,输出 4

七、浏览器与 Node.js 中的 Event Loop 差异

虽然基本概念相同,但浏览器和 Node.js 中的 Event Loop 实现有一些重要区别:

7.1 浏览器中的 Event Loop

浏览器中的 Event Loop 遵循 HTML5 规范,主要包括以下阶段:

  1. 执行同步代码(调用栈)
  2. 执行所有微任务(Promise、MutationObserver)
  3. 如果需要,进行页面渲染
  4. 执行一个宏任务(setTimeout、setInterval、I/O、UI渲染等)
  5. 重复上述过程

7.2 Node.js 中的 Event Loop

Node.js 使用 libuv 实现的 Event Loop 更为复杂,分为以下几个阶段:

  1. timers:执行 setTimeout 和 setInterval 的回调
  2. pending callbacks:执行某些系统操作(如 TCP 错误)的回调
  3. idle, prepare:仅内部使用
  4. poll:检索新的 I/O 事件,执行相关回调
  5. check:执行 setImmediate 回调
  6. close callbacks:执行关闭事件的回调(如 socket.on('close'))

在 Node.js 中,微任务会在每个阶段之间执行,而不仅仅是整个循环结束时。

八、性能考虑和最佳实践

理解 Event Loop 有助于编写更高效的 JavaScript 代码:

  1. 避免阻塞主线程:长时间运行的同步代码会阻塞 Event Loop,导致页面无响应
  2. 合理使用微任务和宏任务:微任务适合高优先级任务,宏任务适合低优先级任务
  3. 避免微任务饥饿:无限递归添加微任务会导致程序无法继续执行宏任务
  4. 分解大型任务:使用 setTimeoutsetImmediate 分解长时间任务
// 不好的做法 - 阻塞主线程
function processLargeArraySync(array) {
  for (let i = 0; i < array.length; i++) {
    // 耗时处理
  }
}

// 好的做法 - 分批次异步处理
function processLargeArrayAsync(array, callback) {
  let index = 0;
  
  function processChunk() {
    const chunkSize = 100;
    const end = Math.min(index + chunkSize, array.length);
    
    for (; index < end; index++) {
      // 处理每个元素
    }
    
    if (index < array.length) {
      // 下一批次
      setTimeout(processChunk, 0);
    } else {
      callback();
    }
  }
  
  processChunk();
}

九、总结

Event Loop 是 JavaScript 异步编程的核心机制,理解它对于编写高效、可靠的代码至关重要。关键要点包括:

  1. JavaScript 是单线程的,通过 Event Loop 实现异步
  2. 调用栈用于跟踪同步代码执行
  3. 任务队列(宏任务)和微任务队列处理异步回调
  4. 微任务优先于宏任务执行
  5. 浏览器和 Node.js 的 Event Loop 实现有所不同
  6. 避免阻塞主线程,合理使用异步编程技术

掌握 Event Loop 不仅能帮助你在面试中脱颖而出,更能让你成为更好的 JavaScript 开发者。