【JavaScript面试题-执行机制深度应用】结合事件循环分析复杂代码输出(如 `Promise`、`async/await`、`setTimeout`)

8 阅读7分钟

事件循环是一个持续运行的过程,它监视调用栈任务队列。当调用栈为空时,事件循环会从队列中取出任务执行。关键在于它维护了两种队列:

  • 宏任务队列 (Macrotask Queue) :包括整个 script 代码块、setTimeoutsetInterval、I/O 操作、UI 渲染等 。
  • 微任务队列 (Microtask Queue) :包括 Promise.then/catch/finally 的回调、MutationObserver 的回调、queueMicrotask() 等。

核心执行规则是:在一个宏任务执行完毕后,事件循环会立即清空整个微任务队列,然后再从宏任务队列中取出下一个宏任务执行。  这意味着微任务的优先级高于宏任务。

一个形象比喻

可以把事件循环想象成一个餐厅厨房的操作流程:

  • 宏任务:就像厨师接到的大菜单,比如做一份牛排、煮一锅汤。这些菜需要花时间准备。
  • 微任务:就像厨师做菜过程中收到的加急小单,比如“这份牛排多加一份酱汁”、“客人要一杯水”。

规则是这样的:厨师每次只做一道大菜(执行一个宏任务)。这道菜做完后,他不会马上做下一道大菜,而是先把所有积压的加急小单全部处理完(清空微任务队列),然后再从菜单上取下道大菜继续做。

为什么这么设计?
因为加急小单(微任务)通常需要快速响应,比如 Promise 的回调、DOM 变化观察,如果拖到后面,客人可能就不耐烦了。所以每做完一道大菜,必须立刻把所有紧急小事搞定,才能开始下一道大菜。

注意:如果处理小单的过程中又来了新小单(比如微任务里又产生微任务),厨师也得把它们都干完,才能回到大菜上。这就是“清空整个微任务队列”的意思。

这样一来,既保证了主要菜品(宏任务)能持续推进,又确保了紧急需求(微任务)不会被耽误。

复杂场景代码执行顺序分析

结合上述规则,分析以下包含 setTimeout (宏任务)、async/await (基于Promise) 和 Promise (微任务) 的代码:

javascript

async function async1() {
  console.log('async1 start'); // 同步代码
  await async2(); // 执行 async2,然后等待其 Promise 解析
  console.log('async1 end'); // 被阻塞的部分,作为微任务
}

async function async2() {
  console.log('async2'); // 同步代码
}

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

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

async1();

new Promise(function (resolve) {
  console.log('promise1'); // 同步代码
  resolve();
}).then(function () {
  console.log('promise2'); // 微任务
});

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

根据事件循环的执行顺序,我们可以一步步推导输出结果:

步骤执行的代码说明
1console.log('script start')执行第一个同步代码。
2setTimeout(...)将其回调函数放入宏任务队列,标记为 H1
3async1() 被调用函数开始执行。
3.1console.log('async1 start')async1 内部的第一个同步代码,直接执行。
3.2await async2()执行 async2async2 内部的 console.log('async2') 是同步代码,直接执行。await 关键字使后续代码暂停,并将 console.log('async1 end') 作为微任务放入队列,标记为 W1 。
4new Promise(executor)Promise 的构造函数是同步执行的,所以立即输出 promise1。随后 resolve() 被调用,将其 .then() 中的回调作为微任务放入队列,标记为 W2
5console.log('script end')执行最后一个同步代码。
6清空微任务队列同步代码执行完毕,调用栈为空。事件循环检查微任务队列,并开始依次执行。先执行 W1 (async1 end),后执行 W2 (promise2)。
7执行下一个宏任务微任务队列清空后,从宏任务队列取出 H1 (setTimeout 回调) 并执行,输出 setTimeout

因此,最终输出结果为:

text

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

注意:在 Chrome 72 版本之前,由于规范差异,async1 end 可能会在 promise2 之后输出。但新版本已优化,使其更符合上述顺序 。

利用事件循环优化长任务,避免UI阻塞

JavaScript 运行在单线程上,如果执行一个耗时的同步任务(如大量数据计算、复杂循环),它会长时间占用主线程,导致无法响应用户点击、无法更新UI,造成页面卡顿甚至“假死”。利用事件循环机制,可以将长任务拆解,让出主线程。

1. 将任务拆分为微任务(适用于可分解的轻量级操作)

可以使用 queueMicrotask 或 Promise 将一个大任务拆分成多个步骤,在每个微任务中处理一小部分。但需谨慎使用,因为微任务队列会在当前宏任务结束后被持续清空。  如果递归地创建微任务而不结束,会一直阻塞后续宏任务(如UI渲染),导致页面无法更新,这种现象称为“微任务饥饿”。

javascript

// 不推荐:递归微任务会阻塞 UI
function recursiveMicrotask() {
  Promise.resolve().then(recursiveMicrotask); // 无限循环,UI 永远不会更新 [citation:9]
}

2. 将任务拆分为宏任务(推荐方案)

更安全的方式是使用 setTimeout 将长任务切分为多个宏任务。这样,在每个宏任务执行完毕后,浏览器都有机会在清空微任务后执行UI渲染,从而保持页面响应 。

假设我们有一个耗时的数据处理函数 processData,可以将其拆分为多个块执行:

javascript

function processLargeArray(largeArray) {
  const chunkSize = 100; // 每批处理 100 条数据
  let index = 0;

  function processChunk() {
    // 处理当前批次的数据
    const chunk = largeArray.slice(index, index + chunkSize);
    for (const item of chunk) {
      // 执行一些复杂计算或 DOM 操作
      console.log('Processing:', item);
    }

    index += chunkSize;
    if (index < largeArray.length) {
      // 如果还有数据,安排下一个宏任务继续处理
      setTimeout(processChunk, 0); // 使用 setTimeout 让出主线程
    } else {
      console.log('All data processed');
    }
  }

  // 启动第一个宏任务
  setTimeout(processChunk, 0);
}

在这个例子中,setTimeout(..., 0) 将 processChunk 放入宏任务队列。处理完一批数据后,如果还有数据,再次通过 setTimeout 安排下一批。在每个宏任务之间,浏览器可以处理用户交互、渲染UI更新(如显示加载状态)。

3. 使用 requestIdleCallback 或 Web Workers

  • requestIdleCallback:允许在浏览器空闲时段执行低优先级任务,避免影响关键操作。
  • Web Workers:对于纯计算的复杂任务,最好的方案是将其完全移出主线程,在后台线程中运行,完成后通过消息传递结果回主线程。这从根本上解决了主线程阻塞问题。
优化策略核心思想适用场景优点缺点
微任务拆分将任务分解,利用 queueMicrotask 或 Promise.then 在清空微任务阶段执行。需要尽快执行、但量级不大的异步操作。优先级高,在当前宏任务结束后立即执行。容易造成微任务队列无限循环,导致UI无法更新。
宏任务拆分将任务分解,利用 setTimeout 或 setInterval 将任务分块放入宏任务队列。耗时较长、需要让出主线程以响应用户操作或渲染的任务。有效避免主线程长时间阻塞,UI能够保持响应。执行优先级较低,可能被高优先级任务插队。
requestIdleCallback在浏览器空闲时段执行任务。非紧急的后台任务,如数据上报、预加载等。不会影响关键路径上的操作。浏览器兼容性问题,执行时间不确定。
Web Workers在独立的后台线程执行JavaScript。纯计算密集型任务,如图像/视频处理、大量数据计算。彻底解放主线程,不影响UI交互和渲染 。无法直接操作DOM,需要通过消息传递数据。