事件循环:JS 的“外卖调度系统”大揭秘

0 阅读6分钟

为什么setTimeout明明是0毫秒,却不是立即执行?为什么Promise.then比setTimeout先执行?今天我们来揭开JS异步执行的底层秘密——事件循环。看完这篇,你就能像背口诀一样记住各种异步任务的执行顺序,面试再也不怕被问“输出顺序”了。

前言

还记得昨天我们说的异步吗?JS把耗时任务丢给浏览器去干,自己继续往下跑。但问题是:异步任务完成后,回调函数是怎么被调用的?谁来决定先执行哪个回调?

这就要说到今天的主角——事件循环(Event Loop)。它就像一个外卖调度中心,管理着所有的“订单”(异步任务)和“配送员”(回调函数)。弄懂了它,你就弄懂了JS的异步执行机制。

一、为什么需要事件循环?

JS是单线程的,这意味着只有一个“执行员”在处理代码。如果这个执行员被卡住了,整个页面就挂了。

但浏览器给了JS一套“外挂”——Web APIs(比如setTimeout、DOM事件、网络请求)。JS遇到这些任务时,会交给浏览器去后台处理,自己继续执行同步代码。

问题来了:后台任务完成后,回调函数怎么回来执行?这就需要事件循环来调度——它负责盯着“任务队列”,一旦主线程空闲了,就把队列里的任务拉出来执行。

二、事件循环的三大角色

事件循环就像一家外卖店,有三个核心角色:

1. 主线程:厨师

厨师(主线程)只做一件事:按顺序做菜(执行同步代码)。他一次只能做一个菜,做完一个才能做下一个。

2. 任务队列:待处理订单

顾客下的订单(异步回调)被送到这里排队。厨师忙完手里的活,就会来这里取下一个订单。

3. 事件循环:调度员

调度员(事件循环)的任务很简单:不断盯着厨师,看他忙完没。忙完了,就从任务队列里取一个订单交给厨师。这个过程无限循环,所以叫“事件循环”。

三、宏任务 vs 微任务:VIP通道和普通通道

任务队列并不是只有一个,而是分两条通道:

  • 宏任务队列:普通通道,包括setTimeout、setInterval、I/O操作、UI渲染、事件回调等。
  • 微任务队列:VIP通道,包括Promise.then、MutationObserver、queueMicrotask等。

调度员有个规则:每次从宏任务队列里取一个任务执行完后,要把当前所有微任务都执行完,然后再去取下一个宏任务。

console.log('1');

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

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

console.log('4');

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

为什么是这个顺序?

  1. 同步代码先执行(1,4)
  2. 执行完后,微任务队列里有Promise.then(3),全部执行
  3. 然后取下一个宏任务setTimeout(2)

四、事件循环的完整流程

用代码来模拟一下事件循环的执行过程:

// 循环流程示意
while (true) {
  // 1. 执行一个宏任务(从宏任务队列取)
  const macroTask = macroQueue.shift();
  execute(macroTask);
  
  // 2. 执行所有微任务
  while (microQueue.length > 0) {
    const microTask = microQueue.shift();
    execute(microTask);
  }
  
  // 3. 可能进行一次UI渲染(浏览器)
  // 4. 回到步骤1
}

这个循环就是事件循环的核心逻辑。记住这个顺序,就能推断出任何异步代码的执行顺序。

五、经典面试题:输出顺序大挑战

来几个经典题目,看看你掌握了没有。

题目一:基础版

setTimeout(() => console.log('A'), 0);
Promise.resolve().then(() => console.log('B'));
console.log('C');
// 输出:C B A

题目二:嵌套版

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

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

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

console.log('E');
// 输出:E C A B D

分析:

  1. 同步代码:E
  2. 微任务:C
  3. 第一个宏任务(第一个setTimeout):A,然后它的微任务B
  4. 第二个宏任务(第二个setTimeout):D

题目三:async/await版

async function test() {
  console.log('1');
  await console.log('2');
  console.log('3');
}
console.log('4');
test();
console.log('5');
// 输出:4 1 2 5 3

等等,为什么是这个顺序?await后面的代码相当于被包在Promise.then里,是微任务。所以:

  1. 同步代码:4
  2. 进入test函数:1
  3. await console.log('2'):先执行console.log('2'),然后await后面的代码(3)变成微任务
  4. 继续同步:5
  5. 同步结束,执行微任务:3

题目四:复杂混合

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

Promise.resolve()
  .then(() => {
    console.log('B');
    setTimeout(() => console.log('C'), 0);
  })
  .then(() => console.log('D'));

console.log('E');
// 输出:E B D A C

分析:

  1. 同步:E
  2. 微任务:Promise.then链。第一个then输出B,并把setTimeout(C)加入宏任务;然后第二个then输出D(因为第一个then返回的Promise会立即触发第二个then)
  3. 宏任务:A
  4. 下一个宏任务:C

六、UI渲染在哪儿?

浏览器会在适当的时机进行UI渲染,通常是在一个宏任务执行完、所有微任务执行完后,下一个宏任务开始前。

setTimeout(() => {
  document.body.style.background = 'red';
});
Promise.resolve().then(() => {
  document.body.style.background = 'blue';
});

上面的代码,背景会先变成蓝色,再变成红色。因为微任务先执行,然后UI渲染,然后下一个宏任务执行。

七、Node.js的事件循环不一样

Node.js的事件循环和浏览器不同,它有六个阶段:timers、pending、idle/prepare、poll、check、close。简单来说,Node里的setImmediateprocess.nextTick有自己的顺序:

  • process.nextTick:不属于事件循环的任何阶段,会在当前阶段结束后立即执行(比微任务还早)。
  • setImmediate:在check阶段执行。
setImmediate(() => console.log('setImmediate'));
setTimeout(() => console.log('setTimeout'), 0);
process.nextTick(() => console.log('nextTick'));
// 输出:nextTick, setTimeout/setImmediate(顺序不一定)

在Node里,setTimeoutsetImmediate的执行顺序取决于事件循环的当前阶段,不一定谁先。

八、实际开发中的应用

了解事件循环有什么用?不只是为了面试,实际开发中也很有帮助:

1. 用setTimeout分割长任务

如果你有一个很耗时的循环,会阻塞页面,可以用setTimeout分片执行。

function processLargeArray(items, chunkSize = 100) {
  let index = 0;
  function process() {
    const chunk = items.slice(index, index + chunkSize);
    chunk.forEach(item => {
      // 处理每个item
    });
    index += chunkSize;
    if (index < items.length) {
      setTimeout(process, 0); // 让出主线程,让页面有机会渲染
    }
  }
  process();
}

2. 用微任务做“刚刚好”的异步

有时候你想让代码异步执行,但又想让它尽快执行,可以用微任务。

function doSomethingAsync(callback) {
  // 确保回调是异步执行的
  queueMicrotask(callback);
  // 或者 Promise.resolve().then(callback)
}

3. 用Promise优化“同步变异步”

如果某个操作可能是同步的也可能是异步的,用Promise包装可以保证执行顺序一致。

function fetchData() {
  if (cachedData) {
    // 用微任务确保异步执行
    return Promise.resolve(cachedData);
  }
  return fetch('/api/data');
}

// 调用方不需要担心是同步还是异步
fetchData().then(data => console.log(data));

九、总结:外卖调度员的日常

事件循环就是JS的“外卖调度员”,它的工作流程是:

  1. 厨师(主线程)做完一道菜(同步代码)
  2. 调度员看看VIP通道(微任务队列)有没有菜要上
  3. 有就全上完
  4. 再从普通通道(宏任务队列)取一道菜给厨师
  5. 重复

记住这个口诀:先同步,再微任务,最后宏任务。宏任务里嵌套的微任务,在下一次微任务循环中执行。

掌握了事件循环,你就掌握了JS异步的执行规律。那些看似复杂的异步面试题,不过是这个规则的排列组合。

明天我们将进入JavaScript的另一个重要话题——Promise源码实现,手写一个符合Promise/A+规范的Promise,让你彻底理解它的内部机制。

如果你觉得今天的“外卖调度”讲得清楚明白,点个赞让更多人看到。有疑问评论区见,我们明天见!