基于本文回答

播面 播面

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

JavaScript 浏览器的事件循环(Event Loop)机制

知识点图片

JavaScript 的事件循环(Event Loop)是理解 JS 异步编程的核心。它是浏览器(或 Node.js)协调同步代码和异步代码执行的一种机制。

由于 JavaScript 是一门单线程语言(意味着它同一时间只能做一件事),如果没有事件循环机制,执行耗时任务(如网络请求、定时器)就会导致页面卡死。事件循环正是 JS 实现非阻塞 I/O 的秘密武器。

下面我们从核心概念、运行机制到经典代码示例,把浏览器的事件循环彻底讲透。


一、 核心组件

要理解事件循环,首先需要认识浏览器环境中的几个核心组件:

  1. Call Stack(调用栈)
    • JS 代码真正执行的地方。
    • 遵循后进先出(LIFO)原则。
    • 所有的同步代码都会先被推入调用栈执行。
  2. Web APIs(浏览器 API)
    • 浏览器提供的工作线程(C++ 实现),独立于 JS 主线程。
    • 例如:DOM 操作setTimeout/setIntervalAJAX/Fetch 网络请求
    • 当调用这些异步方法时,调用栈会把任务交给 Web APIs 处理,JS 主线程继续往下走。
  3. Task Queue(任务队列)
    • 当 Web APIs 处理完异步任务后(比如定时器时间到了,或者网络请求拿到数据了),会把对应的回调函数推入任务队列。
    • 遵循先进先出(FIFO)原则。
    • 任务队列分为两种:宏任务队列微任务队列

二、 宏任务与微任务(核心考点)

异步任务被分为了两类,它们的优先级不同:

1. 宏任务(Macrotask / Task)

可以理解为每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)。

  • <script> 整体代码(整体的主体同步代码)
  • setTimeout / setInterval
  • UI 渲染(UI Rendering)
  • I/O 操作、事件回调(如 click 事件)

2. 微任务(Microtask)

可以理解为在当前宏任务执行结束后,立即要执行的任务。微任务的优先级高于下一个宏任务。

  • Promise.then() / .catch() / .finally()
  • MutationObserver(监听 DOM 变化)
  • queueMicrotask()
  • 注意:async/await 本质上是 Promise 的语法糖,await 后面的代码会被包装成微任务。

三、 事件循环的运转流程(The Loop)

事件循环是一个不断检查和执行的死循环,具体步骤如下:

  1. 执行同步代码:从宏任务队列中取出第一个宏任务(通常是 <script> 整体代码),放入调用栈中执行。
  2. 遇到异步任务
    • 如果是宏任务,交给 Web APIs 处理,完成后把回调放入宏任务队列
    • 如果是微任务,把回调放入微任务队列
  3. 清空微任务队列:当前宏任务执行完毕(调用栈为空)时,事件循环会立即检查微任务队列。并把里面的微任务全部依次执行完。(如果在执行微任务的过程中又产生了新的微任务,也会在这一步一起执行完)
  4. UI 渲染:如果需要,浏览器会在这时进行视图的更新渲染。
  5. 开启下一轮:去宏任务队列中取出一个宏任务,放入调用栈执行。
  6. 重复步骤 2 ~ 5。

💡 核心口诀:执行一个宏任务 -> 清空所有微任务 -> 渲染 -> 执行下一个宏任务。


四、 经典代码实战

基础示例

我们来看一道经典的面试题:

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

setTimeout(() => {
    console.log('2. 宏任务: setTimeout');
}, 0);

Promise.resolve().then(() => {
    console.log('3. 微任务: Promise.then');
});

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

执行过程分析:

  1. 打印 1. 同步代码开始
  2. 遇到 setTimeout,交给 Web APIs,0ms 后将其回调放入宏任务队列
  3. 遇到 Promise.resolve().then,将其回调放入微任务队列
  4. 打印 4. 同步代码结束
  5. 当前宏任务(整个 script)执行完毕,调用栈空了。
  6. 检查微任务队列,发现有 Promise 回调,推入调用栈执行,打印 3. 微任务: Promise.then。微任务队列清空。
  7. 检查宏任务队列,发现有 setTimeout 回调,推入调用栈执行,打印 2. 宏任务: setTimeout

输出结果: 1 -> 4 -> 3 -> 2

进阶示例(包含 async/await 和嵌套)

javascript
console.log('script start');

async function async1() {
  await async2();
  // await 后面的代码等价于 Promise.then,是微任务
  console.log('async1 end');
}

async function async2() {
  // 这里的代码是同步执行的
  console.log('async2 end');
}

async1();

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

new Promise(resolve => {
  console.log('Promise'); // Promise 构造函数里的代码是同步的
  resolve();
}).then(function() {
  console.log('promise1'); // 微任务
}).then(function() {
  console.log('promise2'); // 微任务
});

console.log('script end');

执行过程深度剖析:

  1. 执行同步代码:打印 script start
  2. 调用 async1()async1 内部调用 async2(),打印 async2 endawait 后面的 console.log('async1 end') 被推入微任务队列(微任务A)
  3. 遇到 setTimeout,回调推入宏任务队列
  4. new Promise 构造函数立即执行,打印 Promiseresolve() 被调用,第一个 .then 被推入微任务队列(微任务B)
  5. 打印 script end(至此,第一轮宏任务结束)
  6. 清空微任务队列
    • 执行微任务A,打印 async1 end
    • 执行微任务B,打印 promise1。此时它返回了一个新的 Promise,导致第二个 .then 被推入微任务队列(微任务C)
    • 继续清空,执行微任务C,打印 promise2
  7. 执行下一个宏任务
    • 取出 setTimeout 回调,打印 setTimeout

输出结果:

plaintext
script start
async2 end
Promise
script end
async1 end
promise1
promise2
setTimeout

五、 补充与注意事项

  1. Promise 构造函数是同步的new Promise(executor) 中的 executor 函数是同步执行的,只有 .then() / .catch() / .finally() 里的回调才是微任务。
  2. setTimeout(fn, 0) 并非真正的 0 毫秒:根据 HTML5 标准,嵌套超过 5 层的定时器,最小延迟时间为 4ms。
  3. 微任务的“霸权”:如果在执行微任务的过程中不断生成新的微任务,事件循环会一直停留在清空微任务队列的阶段,导致宏任务无法执行,UI 无法渲染,页面会卡死
  4. Node.js 的事件循环:虽然概念相似,但 Node.js 的事件循环机制(基于 libuv)比浏览器复杂,分为了 Timers、Poll、Check 等多个阶段,并且 Node 独有 process.nextTick(优先级高于普通微任务)。(注意:Node 11 之后,两者在执行顺序的表现上已经基本拉平,都是执行完一个宏任务就清空微任务)
00:00
00:00