Skip to content

事件循环

事件循环(Event Loop)是JavaScript中处理异步操作的核心机制,它决定了代码的执行顺序,使得JavaScript能够在单线程的情况下处理并发操作。理解事件循环对于掌握JavaScript的异步编程至关重要,也是面试中经常被问到的话题。

JavaScript的单线程特性

什么是单线程?

JavaScript是一种单线程语言,这意味着它一次只能执行一个任务,不能并行执行多个任务。这是因为JavaScript最初是为浏览器设计的,主要用于处理用户交互和DOM操作,如果允许多线程并行执行,可能会导致DOM操作的冲突(例如,一个线程正在修改DOM,另一个线程正在删除DOM)。

单线程的优缺点

  • 优点

    • 避免了多线程的复杂性,如线程同步、死锁等问题
    • 简化了代码的编写和调试
    • 保证了DOM操作的安全性
  • 缺点

    • 如果有一个任务执行时间过长,会阻塞后续任务的执行,导致页面卡顿
    • 无法充分利用多核CPU的性能

事件循环的基本概念

什么是事件循环?

事件循环是JavaScript中处理异步操作的机制,它允许JavaScript在单线程的情况下处理并发操作。事件循环的核心是一个循环,它不断地从任务队列中取出任务并执行,直到任务队列为空。

任务队列

任务队列是事件循环中的一个重要概念,它用于存储待执行的任务。JavaScript中的任务分为两种类型:

  1. 宏任务(Macro Task):包括脚本的执行、setTimeout、setInterval、setImmediate(Node.js)、I/O操作、UI渲染等
  2. 微任务(Micro Task):包括Promise的回调、process.nextTick(Node.js)、MutationObserver等

事件循环的执行过程

事件循环的执行过程如下:

  1. 执行当前宏任务(例如,执行脚本的同步代码)
  2. 执行完当前宏任务后,检查微任务队列,如果有微任务,依次执行所有微任务
  3. 执行完所有微任务后,检查是否需要进行UI渲染
  4. 开始下一个事件循环,从宏任务队列中取出下一个宏任务执行
javascript
console.log('Start'); // 同步代码

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

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

console.log('End'); // 同步代码

// 输出顺序:
// Start
// End
// Promise
// Timeout

浏览器中的事件循环

浏览器中的任务队列

在浏览器中,事件循环的执行过程如下:

  1. 执行当前宏任务(例如,执行脚本的同步代码)
  2. 执行完当前宏任务后,检查微任务队列,如果有微任务,依次执行所有微任务
  3. 执行完所有微任务后,检查是否需要进行UI渲染
  4. 开始下一个事件循环,从宏任务队列中取出下一个宏任务执行

示例

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

setTimeout(() => {
  console.log('2'); // 宏任务
  Promise.resolve().then(() => {
    console.log('3'); // 微任务
  });
}, 0);

Promise.resolve().then(() => {
  console.log('4'); // 微任务
  setTimeout(() => {
    console.log('5'); // 宏任务
  }, 0);
});

console.log('6'); // 同步代码

// 输出顺序:
// 1
// 6
// 4
// 2
// 3
// 5

Node.js中的事件循环

Node.js中的事件循环阶段

Node.js的事件循环与浏览器的事件循环有所不同,它分为六个阶段,每个阶段都有自己的任务队列:

  1. timers:执行setTimeout和setInterval的回调
  2. pending callbacks:执行I/O操作的回调
  3. idle, prepare:内部使用
  4. poll:执行I/O操作的回调,获取新的I/O事件
  5. check:执行setImmediate的回调
  6. close callbacks:执行close事件的回调

Node.js中的任务队列

在Node.js中,事件循环的执行过程如下:

  1. 进入timers阶段,执行timers队列中的回调
  2. 进入pending callbacks阶段,执行pending callbacks队列中的回调
  3. 进入idle, prepare阶段,内部使用
  4. 进入poll阶段:
    • 执行poll队列中的回调
    • 如果poll队列为空,检查是否有setImmediate的回调:
      • 如果有,进入check阶段
      • 如果没有,等待新的I/O事件,然后执行相应的回调
  5. 进入check阶段,执行check队列中的回调
  6. 进入close callbacks阶段,执行close callbacks队列中的回调
  7. 开始下一个事件循环

示例

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

setTimeout(() => {
  console.log('2'); // timers阶段
}, 0);

setImmediate(() => {
  console.log('3'); // check阶段
});

process.nextTick(() => {
  console.log('4'); // 微任务,在每个阶段结束后执行
});

console.log('5'); // 同步代码

// 输出顺序:
// 1
// 5
// 4
// 2
// 3

宏任务和微任务的区别

执行时机

  • 宏任务:在事件循环的每个阶段执行
  • 微任务:在每个宏任务执行完成后,事件循环进入下一个阶段之前执行

包含的任务

  • 宏任务

    • 脚本的执行
    • setTimeout
    • setInterval
    • setImmediate(Node.js)
    • I/O操作
    • UI渲染(浏览器)
    • requestAnimationFrame(浏览器)
  • 微任务

    • Promise的回调(then, catch, finally)
    • process.nextTick(Node.js)
    • MutationObserver(浏览器)
    • queueMicrotask

执行顺序

  1. 执行同步代码
  2. 执行所有微任务
  3. 执行下一个宏任务
  4. 执行所有微任务
  5. 重复步骤3和4

事件循环的应用场景

1. 异步操作的执行顺序

理解事件循环可以帮助我们预测异步操作的执行顺序,避免出现意外的行为。

javascript
// 示例1:setTimeout和Promise
console.log('1');

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

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

console.log('4');

// 输出顺序: 1, 4, 3, 2

// 示例2:嵌套的异步操作
console.log('1');

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

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

console.log('6');

// 输出顺序: 1, 6, 4, 2, 3, 5

2. 避免阻塞主线程

事件循环使得JavaScript能够在单线程的情况下处理并发操作,避免了主线程的阻塞。

javascript
// 示例:使用setTimeout避免阻塞主线程
function heavyTask() {
  // 模拟耗时操作
  let sum = 0;
  for (let i = 0; i < 1000000000; i++) {
    sum += i;
  }
  console.log('Heavy task completed:', sum);
}

console.log('Start');

// 使用setTimeout将耗时操作放入宏任务队列,避免阻塞主线程
setTimeout(heavyTask, 0);

console.log('End');

// 输出顺序:
// Start
// End
// Heavy task completed: 499999999500000000

3. 微任务的应用

微任务通常用于处理需要在当前宏任务执行完成后立即执行的操作,例如Promise的回调。

javascript
// 示例:使用微任务更新DOM
console.log('Start');

// 宏任务
setTimeout(() => {
  console.log('Timeout');
  document.body.style.backgroundColor = 'red';
}, 0);

// 微任务
Promise.resolve().then(() => {
  console.log('Promise');
  document.body.style.backgroundColor = 'blue';
});

console.log('End');

// 输出顺序:
// Start
// End
// Promise
// Timeout

// 页面背景色先变为蓝色,然后变为红色

事件循环的常见问题

1. 为什么setTimeout的回调不是立即执行的?

setTimeout的回调会被放入宏任务队列,只有当当前宏任务执行完成,并且所有微任务执行完成后,才会执行宏任务队列中的回调。因此,即使setTimeout的延迟时间为0,它的回调也不会立即执行。

javascript
console.log('Start');

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

console.log('End');

// 输出顺序: Start, End, Timeout

2. 为什么Promise的回调比setTimeout的回调先执行?

Promise的回调会被放入微任务队列,而setTimeout的回调会被放入宏任务队列。根据事件循环的执行顺序,微任务会在当前宏任务执行完成后立即执行,而宏任务会在下一个事件循环中执行。因此,Promise的回调比setTimeout的回调先执行。

javascript
console.log('Start');

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

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

console.log('End');

// 输出顺序: Start, End, Promise, Timeout

3. 什么是任务队列的优先级?

在事件循环中,任务队列的优先级如下:

  1. 同步代码
  2. 微任务
  3. 宏任务

在微任务中,process.nextTick(Node.js)的优先级高于Promise的回调。

在宏任务中,setTimeout和setInterval的优先级低于setImmediate(Node.js)。

javascript
// Node.js中的示例
console.log('1');

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

setImmediate(() => {
  console.log('3');
});

process.nextTick(() => {
  console.log('4');
});

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

console.log('6');

// 输出顺序: 1, 6, 4, 5, 2, 3

4. 如何处理长时间运行的任务?

长时间运行的任务会阻塞主线程,导致页面卡顿。为了避免这种情况,可以将长时间运行的任务分割成多个小任务,使用setTimeout或requestAnimationFrame将它们放入宏任务队列,使得浏览器有机会进行UI渲染。

javascript
// 示例:分割长时间运行的任务
function processData(data) {
  const chunkSize = 1000;
  let index = 0;
  
  function processChunk() {
    const end = Math.min(index + chunkSize, data.length);
    for (let i = index; i < end; i++) {
      // 处理数据
      console.log(data[i]);
    }
    
    index = end;
    if (index < data.length) {
      // 继续处理下一个 chunk
      setTimeout(processChunk, 0);
    } else {
      console.log('Processing completed');
    }
  }
  
  processChunk();
}

const data = Array.from({ length: 10000 }, (_, i) => i);
processData(data);

面试常见问题

1. 什么是事件循环?它的作用是什么?

  • 事件循环的定义:事件循环是JavaScript中处理异步操作的机制,它允许JavaScript在单线程的情况下处理并发操作
  • 事件循环的作用
    • 决定了代码的执行顺序
    • 使得JavaScript能够在单线程的情况下处理并发操作
    • 避免了主线程的阻塞

2. 宏任务和微任务的区别是什么?

  • 执行时机:宏任务在事件循环的每个阶段执行,微任务在每个宏任务执行完成后执行
  • 包含的任务
    • 宏任务:脚本的执行、setTimeout、setInterval、setImmediate、I/O操作、UI渲染等
    • 微任务:Promise的回调、process.nextTick、MutationObserver等
  • 执行顺序:微任务的执行优先级高于宏任务

3. 事件循环的执行过程是什么?

  • 执行当前宏任务(例如,执行脚本的同步代码)
  • 执行完当前宏任务后,检查微任务队列,如果有微任务,依次执行所有微任务
  • 执行完所有微任务后,检查是否需要进行UI渲染
  • 开始下一个事件循环,从宏任务队列中取出下一个宏任务执行

4. 为什么setTimeout的延迟时间为0,回调也不是立即执行的?

setTimeout的回调会被放入宏任务队列,只有当当前宏任务执行完成,并且所有微任务执行完成后,才会执行宏任务队列中的回调。因此,即使setTimeout的延迟时间为0,它的回调也不会立即执行。

5. Promise的回调和setTimeout的回调哪个先执行?为什么?

Promise的回调先执行。因为Promise的回调会被放入微任务队列,而setTimeout的回调会被放入宏任务队列。根据事件循环的执行顺序,微任务会在当前宏任务执行完成后立即执行,而宏任务会在下一个事件循环中执行。

6. Node.js的事件循环与浏览器的事件循环有什么区别?

  • 阶段划分:Node.js的事件循环分为六个阶段,每个阶段都有自己的任务队列;浏览器的事件循环没有明确的阶段划分
  • 微任务执行时机:在Node.js中,微任务在每个阶段结束后执行;在浏览器中,微任务在当前宏任务执行完成后执行
  • 特殊API:Node.js有process.nextTick、setImmediate等特殊API;浏览器有requestAnimationFrame、MutationObserver等特殊API

总结

事件循环是JavaScript中处理异步操作的核心机制,它决定了代码的执行顺序,使得JavaScript能够在单线程的情况下处理并发操作。事件循环的核心是一个循环,它不断地从任务队列中取出任务并执行,直到任务队列为空。JavaScript中的任务分为宏任务和微任务,微任务的执行优先级高于宏任务。理解事件循环对于掌握JavaScript的异步编程至关重要,也是面试中经常被问到的话题。

好好学习,天天向上