基于本文回答

播面 播面

文图音视,全方位拆解八股文
0
评论

JavaScript事件循环机制

知识点图片

JavaScript 事件循环是其单线程异步核心。它通过调用栈和任务队列(宏任务与微任务)调度异步操作,避免阻塞。每次循环执行一个宏任务后,会立即清空所有微任务。

我们来深入浅出地讲解一下 JavaScript 的核心机制之一——事件循环(Event Loop)

理解事件循环对于掌握 JavaScript 异步编程至关重要,它解释了为什么 JavaScript 作为一个单线程语言,却能高效地处理 I/O 操作、定时器等耗时任务而不会阻塞主流程。


1. 为什么需要事件循环?—— 从单线程说起

首先要明确一个核心前提:JavaScript 是单线程的

这意味着在任何一个时间点,JavaScript 引擎只能执行一件事情。如果有一个任务耗时很长(比如一个复杂的计算或者一个网络请求),那么后续的所有任务都必须排队等待,这会导致页面卡顿、无响应,用户体验极差。

为了解决这个问题,JavaScript 引入了异步(Asynchronous)的概念。将那些耗时的任务交给其他线程(通常是浏览器或 Node.js 环境提供的 API)去处理,主线程则继续执行后续代码。当耗时任务完成后,再通过一个回调函数(Callback)通知主线程“我完成了,你可以处理结果了”。

事件循环就是管理和调度这些任务(同步任务和异步任务回调)的机制。


2. 事件循环的核心组成部分

想象一下 JavaScript 的运行环境是一个分工明确的工厂,它主要由以下几个部分组成:

  1. 调用栈(Call Stack)

    • 作用:这是一个后进先出(LIFO)的数据结构,用来追踪所有正在执行的函数。
    • 工作流程:当一个函数被调用时,它会被推入(push)栈顶;当函数执行完毕返回时,它会被弹出(pop)栈顶。主线程的所有同步代码都在这里执行。
  2. 堆(Heap)

    • 作用:一块非结构化的内存区域,用于存储对象、数组等引用类型的数据。
  3. 任务队列(Task Queue / Callback Queue)

    • 作用:这是一个先进先出(FIFO)的数据结构,用来存放所有准备好被执行的异步任务的回调函数
    • 工作流程:当一个异步操作(如 setTimeout、AJAX 请求)完成时,它的回调函数不会立即执行,而是被放入这个队列中排队。
  4. 事件循环(Event Loop)

    • 作用:这是整个机制的“调度中心”。它是一个持续运行的进程,负责监控调用栈和任务队列。
    • 工作流程:它的工作非常简单但至关重要:
      • “调用栈是空的吗?”
      • “如果是,任务队列里有等待处理的回调吗?”
      • “如果有,就把队列里的第一个回调函数推入调用栈,让它执行。”
      • 这个过程会无限重复,因此得名“循环”。

3. 一个简单的例子:setTimeout 的执行过程

让我们通过一个经典例子来理解这个流程:

javascript
console.log('Start'); // 1. 同步任务

setTimeout(function() {
    console.log('Timeout callback'); // 3. 异步任务的回调
}, 2000);

console.log('End'); // 2. 同步任务

执行步骤分解:

  1. console.log('Start') 被推入调用栈,执行,打印 "Start",然后从调用栈弹出。
  2. setTimeout(...) 被推入调用栈。这是一个异步 API,JavaScript 引擎会把它交给浏览器 Web API(或 Node.js 的 C++ API)去处理。浏览器会启动一个计时器。setTimeout 函数本身执行完毕,从调用栈弹出。注意:主线程没有等待2秒,而是继续向下执行。
  3. console.log('End') 被推入调用栈,执行,打印 "End",然后从调用栈弹出。
  4. 此时,调用栈变空了。主线程的所有同步代码都已执行完毕。
  5. 2秒后... 浏览器的计时器完成,它会将 setTimeout 的回调函数 function() { console.log('Timeout callback'); } 放入任务队列中排队。
  6. 事件循环(Event Loop) 发现调用栈是空的,并且任务队列里有东西。
  7. 它将任务队列中的第一个任务(也就是那个回调函数)取出,推入调用栈。
  8. 调用栈执行这个回调函数,console.log('Timeout callback') 被执行,打印 "Timeout callback",然后函数执行完毕,从调用栈弹出。
  9. 现在调用栈和任务队列都空了,程序等待新的事件发生。

最终的输出顺序是:

plaintext
Start
End
Timeout callback

4. 进阶:宏任务(Macro-task)与微任务(Micro-task)

上面的“任务队列”其实是一个简化的说法。实际上,任务队列被分为两种类型:

  1. 宏任务(Macro-task / Task)

    • 可以理解为一次独立的、较大的工作单元。
    • 常见的宏任务
      • 整体的 <script> 代码
      • setTimeout, setInterval
      • setImmediate (Node.js 环境)
      • I/O 操作(如文件读写、网络请求)
      • UI 渲染
  2. 微任务(Micro-task / Job)

    • 可以理解为当前宏任务执行结束后,需要立即处理的小任务。
    • 常见的微任务
      • Promise.then(), Promise.catch(), Promise.finally()
      • async/await (其本质是 Promise 的语法糖)
      • queueMicrotask()
      • MutationObserver

事件循环的详细执行规则:

事件循环的每一次迭代(称为一个 "tick")都遵循以下精确的顺序:

  1. 宏任务队列中选择一个最老的任务(比如页面加载时的 script 脚本)并执行它。
  2. 执行完毕后,检查微任务队列
  3. 循环执行微任务队列中的所有任务,直到微任务队列变空。如果在执行微任务的过程中,又产生了新的微任务,那么新的微任务也会被添加到队列的末尾,并在当前 tick 中继续执行。
  4. (可选)执行 UI 渲染更新。
  5. 回到第 1 步,开始下一个宏任务。

核心要点:执行完一个宏任务后,会立即清空所有微任务,然后再去执行下一个宏任务。

5. 宏任务与微任务的混合示例

让我们看一个经典的面试题:

javascript
console.log('script start'); // 宏任务1

setTimeout(function() {
  console.log('setTimeout'); // 宏任务2
}, 0);

Promise.resolve().then(function() {
  console.log('promise1'); // 微任务1
}).then(function() {
  console.log('promise2'); // 微任务2
});

console.log('script end'); // 宏任务1

执行分析:

  1. 开始第一个宏任务(script 整体代码):

    • console.log('script start') 执行,打印 "script start"。
    • 遇到 setTimeout,将其回调函数注册为一个新的宏任务,放入宏任务队列。
    • 遇到 Promise.resolve().then(...),第一个 .then 的回调函数被注册为一个微任务,放入微任务队列。
    • console.log('script end') 执行,打印 "script end"。
  2. 第一个宏任务执行完毕。 现在,事件循环会检查微任务队列。

  3. 执行所有微任务:

    • 微任务队列中有 promise1 的回调。执行它,打印 "promise1"。
    • 这个 .then 执行后返回一个新的 Promise,并立即将第二个 .then 的回调(promise2)也推入微任务队列
    • 微任务队列还没空!继续执行。
    • 取出 promise2 的回调,执行它,打印 "promise2"。
  4. 微任务队列现在空了。 第一个事件循环 tick 结束。

  5. 开始下一个宏任务:

    • 事件循环从宏任务队列中取出 setTimeout 的回调函数。
    • 执行它,打印 "setTimeout"。

最终输出顺序:

plaintext
script start
script end
promise1
promise2
setTimeout

总结

  • JS 是单线程的,通过事件循环实现异步,避免阻塞。
  • 核心组件:调用栈(执行同步代码)、任务队列(存放异步回调)、事件循环(调度者)。
  • 基本流程:主线程(调用栈)代码执行完 -> 事件循环从任务队列取任务到调用栈执行 -> 重复。
  • 任务分为宏任务微任务
  • 精确流程:执行一个宏任务 -> 清空所有微任务 -> (可能进行UI渲染) -> 执行下一个宏任务。这个循环不断进行。

掌握了事件循环,你就能更好地理解和调试 JavaScript 中的异步行为,写出更健壮、性能更好的代码。

00:00
00:00