JavaScript 事件循环与任务队列:从 setTimeout 到 Promise

1. 引言

JavaScript 是单线程语言,却能在浏览器中流畅地处理动画、网络请求和用户交互,这背后离不开**事件循环(Event Loop)**机制。理解事件循环,是掌握异步编程、避免页面卡顿、写出高性能代码的关键。

很多初学者会困惑:为什么 setTimeout(0) 里的回调不是立即执行?为什么 Promise.then 有时比 setTimeout 先执行?这些看似「反直觉」的现象,根源都在于事件循环对宏任务 与微任务的调度规则。

本文将带你从 setTimeout 到 Promise,系统梳理宏任务与微任务的区别、Event Loop 的执行阶段、渲染时机,以及面试中高频出现的题目拆解。读完你会明白:事件循环不是玄学,而是一套可预测的调度规则。

2. 为什么需要事件循环

JavaScript 运行在单线程上,意味着同一时间只能执行一段代码。如果遇到耗时操作(如网络请求、文件读取),线程会被阻塞,页面就会卡死。

想象一下:如果 fetch 请求是同步的,那么当浏览器等待服务器响应时,用户点击按钮、滚动页面、输入文字都会毫无反应,整个页面像「冻结」了一样。这种体验显然是无法接受的。

为了解决这个问题,JavaScript 引入了异步回调机制:将耗时任务交给浏览器或 Node.js 的底层线程处理,完成后把回调放入任务队列,等待主线程空闲时执行。

事件循环就是连接「调用栈」与「任务队列」的调度器,它决定了何时执行哪个任务。它的核心职责可以概括为三点:

  • 调度:决定下一个要执行的任务;
  • 优先级:区分宏任务与微任务,微任务优先;
  • 协调渲染:在合适的时机让浏览器绘制页面。

理解了这个「为什么」,后面所有看似复杂的规则都会变得顺理成章。

下面用一张图直观对比「同步阻塞」与「异步回调」两种模型:

flowchart LR subgraph A[&#34;同步模型(阻塞)&#34;] A1[&#34;任务 1 执行&#34;] --> A2[&#34;等待网络响应<br/>主线程被阻塞&#34;] A2 --> A3[&#34;任务 2 执行&#34;] end subgraph B[&#34;异步模型(事件循环)&#34;] B1[&#34;任务 1 执行&#34;] --> B2[&#34;发起网络请求<br/>交给底层线程&#34;] B2 --> B3[&#34;主线程继续执行任务 2&#34;] B3 --> B4[&#34;响应完成,回调入队&#34;] B4 --> B5[&#34;事件循环调度回调执行&#34;] end A -. &#34;页面卡死&#34; .-> B

3. 宏任务与微任务

事件循环的核心,是把任务分成**宏任务(MacroTask)与微任务(MicroTask)**两类,并规定它们的执行顺序。理解这两类任务的来源、特点和调度规则,是掌握事件循环的第一步。

3.1 宏任务(MacroTask)

宏任务是由宿主环境(浏览器/Node.js)提供的任务,常见来源包括:

  • setTimeout / setInterval
  • setImmediate(Node.js)
  • I/O 操作
  • UI 渲染(浏览器)
  • requestAnimationFrame(浏览器)

每个宏任务执行完毕后,事件循环会检查是否需要渲染页面。

宏任务的特点是一次只取一个:每轮事件循环从宏任务队列头部取出一个任务执行,执行完后再处理微任务队列,然后才进入下一轮。这意味着即使宏任务队列中有 100 个任务,它们也不会被一口气全部执行,而是被微任务和渲染「打断」。

javascript 复制代码
// 宏任务队列中有多个任务,但每轮只取一个
setTimeout(() => console.log('宏任务 1'), 0);
setTimeout(() => console.log('宏任务 2'), 0);
setTimeout(() => console.log('宏任务 3'), 0);

// 输出顺序:宏任务 1 → 宏任务 2 → 宏任务 3
// 但每个宏任务之间,都会先清空微任务队列、检查渲染

为什么宏任务要「一次只取一个」? 因为宏任务之间可能插入渲染。如果一口气执行完所有宏任务,浏览器就没有机会绘制页面,用户会看到明显的卡顿。把宏任务拆成「一次一个」,浏览器就能在任务间隙完成渲染,保持界面流畅。

3.2 微任务(MicroTask)

微任务是由 JavaScript 引擎自身提供的任务,常见来源包括:

  • Promise.then / catch / finally
  • queueMicrotask
  • MutationObserver
  • process.nextTick(Node.js,优先级高于普通微任务)

微任务的特点是在当前宏任务结束后、下一个宏任务开始前,一次性清空队列。

与宏任务「一次取一个」不同,微任务队列是一次性全部清空的。也就是说,如果微任务执行过程中又产生了新的微任务,这些新微任务也会被继续执行,直到微任务队列为空。这也是为什么微任务可以用于「异步但尽快执行」的场景。

javascript 复制代码
// 微任务队列一次性清空,包括执行过程中新产生的微任务
Promise.resolve()
  .then(() => {
    console.log('微任务 1');
    // 在微任务中又产生新的微任务
    queueMicrotask(() => console.log('微任务 2'));
  })
  .then(() => console.log('微任务 3'));

// 输出顺序:微任务 1 → 微任务 3 → 微任务 2
// 注意:微任务 2 虽然是在微任务 1 中产生的,但会在当前宏任务结束前全部执行完

为什么微任务要「一次性清空」? 微任务的设计初衷是「尽快执行、不打断渲染」。它被安排在宏任务结束后、渲染之前执行,因此可以安全地修改 DOM 而不会造成多次渲染。如果微任务也像宏任务那样「一次一个」,那么 DOM 修改就会被拆散到多个渲染周期,反而降低性能。

3.3 两者的核心区别

对比项 宏任务 微任务
来源 宿主环境 JS 引擎
执行时机 每轮事件循环取一个 当前宏任务结束后全部清空
优先级 低 高
典型代表 setTimeout Promise.then
队列清空方式 逐个执行 一次性清空
是否阻塞渲染 可能阻塞 在渲染前清空,不阻塞渲染
产生新任务 新宏任务进入下一轮 新微任务在当前轮继续执行

一句话总结:宏任务是「排队叫号」,微任务是「插队优先」。微任务永远在下一个宏任务之前执行完毕。

一个直观的类比:想象你在餐厅点餐。宏任务就像「叫号取餐」------每叫一个号,取一份餐,然后服务员可能去收拾桌子(渲染)。微任务就像「加急订单」------只要当前顾客的餐取完,所有加急订单会一次性全部做完,然后才轮到下一位顾客(下一个宏任务)。

下面用一张图梳理宏任务与微任务的调度关系:

flowchart TD A[&#34;同步代码执行&#34;] --> B[&#34;宏任务队列<br/>setTimeout / I/O / UI 渲染&#34;] B --> C[&#34;取出一个宏任务执行&#34;] C --> D[&#34;执行过程中产生微任务&#34;] D --> E[&#34;宏任务结束,清空微任务队列<br/>Promise.then / queueMicrotask&#34;] E --> F{&#34;微任务中又产生新微任务?&#34;} F -- &#34;是&#34; --> E F -- &#34;否&#34; --> G[&#34;检查是否需要渲染&#34;] G --> B

4. Event Loop 执行阶段

4.1 浏览器中的事件循环

浏览器的事件循环可以简化为以下步骤:

  1. 从宏任务队列中取出一个宏任务执行;
  2. 执行过程中产生的微任务,全部推入微任务队列;
  3. 宏任务执行完毕后,清空微任务队列;
  4. 如果需要渲染,执行渲染;
  5. 回到第 1 步,继续下一轮循环。
flowchart TD A[&#34;宏任务队列&#34;] --> B[&#34;取出一个宏任务执行&#34;] B --> C[&#34;执行过程中产生微任务&#34;] C --> D[&#34;宏任务结束,清空微任务队列&#34;] D --> E{&#34;需要渲染?&#34;} E -- &#34;是&#34; --> F[&#34;执行渲染&#34;] E -- &#34;否&#34; --> A F --> A

这里有一个容易忽略的细节:微任务队列的清空是「递归」的 。如果在清空微任务的过程中又产生了新的微任务,这些新微任务会继续被加入当前队列并执行,直到队列完全为空。这意味着微任务可以无限嵌套,理论上可能阻塞事件循环------这也是为什么在微任务中递归调用 queueMicrotask 需要格外小心。

4.2 Node.js 中的事件循环

Node.js 的事件循环分为多个阶段(phase),依次循环:

  • timers :执行 setTimeout / setInterval 回调;
  • pending callbacks:执行延迟到下一轮循环的 I/O 回调;
  • idle / prepare:内部使用;
  • poll:获取新的 I/O 事件,执行 I/O 回调;
  • check :执行 setImmediate 回调;
  • close callbacks :执行 close 事件回调。

每个阶段之间,Node.js 也会清空微任务队列(process.nextTick 优先于 Promise)。

Node.js 与浏览器最大的差异在于:Node.js 的微任务清空发生在每个阶段之间,而不是每个宏任务之后 。这意味着在 Node.js 中,setTimeout 回调执行完后,会先清空微任务,再进入下一个阶段(如 poll),而不是像浏览器那样在同一个宏任务结束后立即清空。

另外,process.nextTick 的优先级高于 Promise,它会在当前阶段结束后、其他微任务之前执行。这也是面试中常见的考点。

下面用一张图展示 Node.js 事件循环的完整阶段流转:

flowchart LR A[&#34;timers<br/>setTimeout / setInterval&#34;] --> B[&#34;pending callbacks<br/>延迟 I/O 回调&#34;] B --> C[&#34;idle / prepare<br/>内部使用&#34;] C --> D[&#34;poll<br/>获取新 I/O 事件&#34;] D --> E[&#34;check<br/>setImmediate&#34;] E --> F[&#34;close callbacks<br/>close 事件&#34;] F --> A G[&#34;微任务队列<br/>process.nextTick 优先于 Promise&#34;] -. &#34;每个阶段之间清空&#34; .-> A

5. 渲染时机

浏览器并不是每执行完一个任务就立刻渲染,而是遵循以下原则:

  • 微任务执行完毕后,浏览器会检查是否需要渲染;
  • 多个宏任务之间,浏览器可能合并渲染,避免频繁重绘;
  • requestAnimationFrame 的回调会在下一次渲染之前执行,适合做动画。

因此,如果我们在微任务中修改 DOM,浏览器会在当前宏任务结束后统一渲染一次,而不是多次渲染。

这里有一个实用的性能优化技巧:把 DOM 批量修改放在微任务中。例如,在一个循环中多次修改 DOM,如果每次都触发渲染,会造成严重的性能问题。但如果把修改合并到微任务中,浏览器只会渲染一次。

javascript 复制代码
// 不推荐:循环中多次修改 DOM,可能触发多次渲染
for (let i = 0; i < 100; i++) {
  document.body.style.marginLeft = i + 'px';
}

// 推荐:合并到微任务中,只渲染一次
let margin = 0;
for (let i = 0; i < 100; i++) {
  margin = i;
}
queueMicrotask(() => {
  document.body.style.marginLeft = margin + 'px';
});

另外,requestAnimationFrame 与 setTimeout 的区别也值得注意:requestAnimationFrame 的回调会在渲染前执行,且浏览器会自动优化帧率(通常 60fps),而 setTimeout 则可能因为事件循环的调度而错过渲染时机,导致动画卡顿。

5.1 渲染的完整时序

要理解渲染时机,需要先明确浏览器一帧(frame)的完整流程。以 60fps 为例,一帧大约 16.7ms,其典型时序如下:

  1. 执行宏任务:从宏任务队列取出一个任务执行;
  2. 清空微任务:执行完宏任务后,一次性清空微任务队列;
  3. 执行 requestAnimationFrame 回调:在渲染之前执行,适合更新动画状态;
  4. 样式计算与布局(Layout):浏览器计算元素的最终样式和几何位置;
  5. 绘制(Paint):将元素绘制到屏幕上;
  6. 合成(Composite):将各图层合成,最终呈现在屏幕上。
flowchart LR A[&#34;宏任务&#34;] --> B[&#34;清空微任务&#34;] B --> C[&#34;requestAnimationFrame&#34;] C --> D[&#34;样式计算与布局&#34;] D --> E[&#34;绘制&#34;] E --> F[&#34;合成&#34;] F --> A

关键点 :requestAnimationFrame 的回调发生在渲染之前 ,而不是渲染之后。这意味着在 requestAnimationFrame 中修改 DOM,浏览器会在同一帧内完成渲染,不会产生额外的帧延迟。

5.2 为什么微任务中修改 DOM 只渲染一次

很多开发者会疑惑:为什么在微任务中连续修改 DOM,浏览器只渲染一次?

原因在于:渲染不是每执行一个任务就触发,而是按「帧」为单位批量进行的。微任务队列在当前宏任务结束后被一次性清空,期间所有 DOM 修改都会被记录,但浏览器不会立即渲染,而是等到这一帧的渲染阶段统一处理。

javascript 复制代码
// 在微任务中连续修改 DOM
Promise.resolve().then(() => {
  document.body.style.background = 'red';
  document.body.style.color = 'white';
  document.body.style.fontSize = '20px';
});
// 浏览器只会渲染一次,而不是三次

这带来一个重要的性能启示:把多次 DOM 修改合并到同一个微任务(或同一个宏任务)中,可以显著减少渲染次数,从而提升页面性能。

5.3 requestAnimationFrame 与 setTimeout 的实战对比

requestAnimationFrame 和 setTimeout 都能实现「延迟执行」,但它们的调度机制完全不同:

对比项 requestAnimationFrame setTimeout
执行时机 下一次渲染之前 宏任务队列中,按延迟时间调度
帧率 跟随浏览器刷新率(通常 60fps) 不保证与刷新率同步
页面隐藏时 自动暂停 继续执行(可能浪费资源)
适合场景 动画、视觉更新 通用延迟任务
javascript 复制代码
// 用 requestAnimationFrame 实现平滑动画
function animate() {
  element.style.transform = `translateX(${x}px)`;
  x += 2;
  if (x < 300) {
    requestAnimationFrame(animate);
  }
}
requestAnimationFrame(animate);

// 用 setTimeout 实现动画(可能卡顿)
function animateWithTimeout() {
  element.style.transform = `translateX(${x}px)`;
  x += 2;
  if (x < 300) {
    setTimeout(animateWithTimeout, 16);
  }
}
setTimeout(animateWithTimeout, 16);

为什么 setTimeout 动画可能卡顿? 因为 setTimeout 的回调进入宏任务队列,执行时机受事件循环调度影响,可能与浏览器的渲染帧不同步。如果回调在两次渲染之间执行,动画就会出现跳帧;而 requestAnimationFrame 的回调被安排在渲染之前,能保证每一帧都更新一次,动画自然流畅。

5.4 实战:用渲染时机优化列表渲染

假设我们要渲染一个包含 10000 条数据的列表,直接同步渲染会导致页面长时间无响应。利用渲染时机,我们可以分批渲染:

javascript 复制代码
const list = document.getElementById('list');
const data = Array.from({ length: 10000 }, (_, i) => i);

let index = 0;
const batchSize = 100;

function renderBatch() {
  // 每次渲染 100 条,避免一次性阻塞主线程
  for (let i = 0; i < batchSize && index < data.length; i++, index++) {
    const item = document.createElement('li');
    item.textContent = `Item ${data[index]}`;
    list.appendChild(item);
  }
  if (index < data.length) {
    // 用 requestAnimationFrame 在下一帧继续渲染
    requestAnimationFrame(renderBatch);
  }
}

requestAnimationFrame(renderBatch);

这样,浏览器可以在每批渲染之间绘制页面,用户不会感到页面「卡死」,同时动画和交互也能保持流畅。

6. setTimeout 0 的真相

setTimeout(fn, 0) 并不是「立即执行」,而是至少等待 0 毫秒后,把回调放入宏任务队列。

这意味着:

  • 它会在当前同步代码执行完毕后,再进入事件循环;
  • 如果微任务队列中有任务,微任务会先于 setTimeout(0) 执行;
  • 实际延迟时间受浏览器最小定时器间隔(通常 4ms)影响。
javascript 复制代码
console.log('同步代码');

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

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

// 输出顺序:
// 同步代码
// 微任务
// setTimeout 0

6.1 为什么不是「立即执行」

很多初学者会误以为 setTimeout(fn, 0) 会立刻执行回调,但实际上它只是把回调放入宏任务队列,真正的执行要等到事件循环的下一次迭代。

javascript 复制代码
console.log('A');

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

console.log('C');

// 输出顺序:A C B
// 而不是 A B C

原因 :setTimeout 的回调不会打断当前正在执行的同步代码。只有当前调用栈清空后,事件循环才会从宏任务队列中取出回调执行。所以 setTimeout(0) 的本质是「把任务推迟到当前同步代码执行完之后」。

6.2 最小延迟时间(4ms 钳制)

浏览器对 setTimeout 的延迟有一个**最小钳制(clamping)**规则:当嵌套层级超过 5 层时,最小延迟会被强制提升到 4ms。

javascript 复制代码
// 嵌套 setTimeout,触发 4ms 钳制
let start = Date.now();
let count = 0;

function nested() {
  count++;
  if (count < 10) {
    setTimeout(nested, 0);
  } else {
    console.log(`10 次 setTimeout(0) 共耗时:${Date.now() - start}ms`);
  }
}

setTimeout(nested, 0);
// 实际输出:10 次 setTimeout(0) 共耗时:约 36ms 以上
// 因为第 6 次开始,每次至少等待 4ms

关键点:

  • 前 5 层嵌套,setTimeout(0) 可以接近 0ms 执行;
  • 第 6 层开始,浏览器强制最小延迟为 4ms;
  • 这是为了防止开发者用 setTimeout 无限循环阻塞主线程。

6.3 setTimeout 0 的常见用途

用途一:把任务推迟到渲染之后

javascript 复制代码
// 先让浏览器渲染当前状态,再执行耗时操作
button.addEventListener('click', () => {
  // 立即更新 UI 状态
  button.disabled = true;
  button.textContent = '处理中...';

  // 用 setTimeout(0) 让浏览器先完成渲染
  setTimeout(() => {
    // 执行耗时操作
    heavyTask();
  }, 0);
});

用途二:避免长任务阻塞渲染

javascript 复制代码
// 不推荐:一次性执行大量计算,阻塞主线程
function processAll(items) {
  for (const item of items) {
    process(item); // 可能耗时很长
  }
}

// 推荐:分批处理,每批之间让出主线程
function processInBatches(items, batchSize = 100) {
  let index = 0;
  function nextBatch() {
    const end = Math.min(index + batchSize, items.length);
    for (; index < end; index++) {
      process(items[index]);
    }
    if (index < items.length) {
      setTimeout(nextBatch, 0); // 让出主线程,允许渲染
    }
  }
  nextBatch();
}

用途三:模拟「异步」行为,统一代码风格

javascript 复制代码
// 某些场景下,希望回调始终异步执行,避免「同步回调」带来的时序问题
function loadData(callback) {
  const data = fetchDataSync(); // 同步获取数据
  setTimeout(() => callback(data), 0); // 强制异步回调
}

// 调用方可以放心地假设 callback 一定是异步的
loadData((data) => {
  console.log('数据加载完成', data);
});
console.log('loadData 调用结束');
// 输出顺序:
// loadData 调用结束
// 数据加载完成

6.4 setTimeout 0 与 Promise 的执行顺序

这是面试中最高频的考点之一:setTimeout(0) 和 Promise.then 谁先执行?

javascript 复制代码
setTimeout(() => {
  console.log('setTimeout');
}, 0);

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

// 输出顺序:
// promise
// setTimeout

原因 :Promise.then 进入微任务队列 ,setTimeout 进入宏任务队列 。微任务在当前宏任务结束后立即清空,而宏任务要等到下一轮事件循环才执行。所以微任务永远先于宏任务。

flowchart TD A[&#34;同步代码执行完毕&#34;] --> B[&#34;清空微任务队列<br/>Promise.then 在这里执行&#34;] B --> C[&#34;进入下一轮事件循环&#34;] C --> D[&#34;取出宏任务执行<br/>setTimeout 在这里执行&#34;]

6.5 一个容易踩的坑:setTimeout 0 并不保证 0ms

javascript 复制代码
// 在浏览器中实测
const start = Date.now();

setTimeout(() => {
  console.log(`实际延迟:${Date.now() - start}ms`);
}, 0);

// 输出:实际延迟:4ms(或更大)
// 而不是 0ms

原因:

  • 浏览器的最小定时器间隔(通常 4ms)会钳制延迟;
  • 事件循环的调度本身有开销;
  • 如果主线程繁忙,回调会被进一步推迟。

结论 :setTimeout(0) 的语义是「尽快 」,而不是「立即」。它保证的是「不早于 0ms」,而不是「恰好 0ms」。

7. 面试题拆解

面试中事件循环相关的题目,核心都是考察你对「同步 → 微任务 → 宏任务」执行顺序的理解。下面从易到难,拆解几道高频题目。

7.1 经典输出顺序题

javascript 复制代码
console.log('1');

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

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

console.log('4');

执行过程分析:

  1. 同步代码依次执行,输出 1、4;
  2. setTimeout 回调进入宏任务队列;
  3. Promise.then 进入微任务队列;
  4. 同步代码结束,清空微任务队列,输出 3;
  5. 下一轮事件循环取出宏任务,输出 2。

最终输出:1 4 3 2

解题口诀:先同步、再微任务、最后宏任务。遇到这类题,先在纸上画出「调用栈 → 微任务队列 → 宏任务队列」三个容器,逐个往里放,再按顺序取出。

7.2 宏任务嵌套微任务

javascript 复制代码
setTimeout(() => {
  console.log('宏任务1');
  Promise.resolve().then(() => {
    console.log('微任务1');
  });
}, 0);

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

执行过程分析:

  1. 两个 setTimeout 依次进入宏任务队列;
  2. 取出宏任务1,输出 宏任务1,产生微任务1;
  3. 宏任务1结束,清空微任务队列,输出 微任务1;
  4. 取出宏任务2,输出 宏任务2。

最终输出:宏任务1 微任务1 宏任务2

关键点 :微任务是在当前宏任务结束后清空,而不是在所有宏任务结束后。所以宏任务1 产生的微任务1,会在宏任务2 之前执行。这是「宏任务嵌套微任务」类题目的核心考点。

7.3 async / await 与微任务

javascript 复制代码
async function test() {
  console.log('async 开始');
  await Promise.resolve();
  console.log('async 结束');
}

console.log('同步开始');
test();
console.log('同步结束');

执行过程分析:

  1. 输出 同步开始;
  2. 调用 test(),同步执行到 await 之前,输出 async 开始;
  3. await 后面的代码被放入微任务队列;
  4. 输出 同步结束;
  5. 清空微任务队列,输出 async 结束。

最终输出:同步开始 async 开始 同步结束 async 结束

易错点 :很多初学者以为 await 会「阻塞」后面的代码,实际上 await 只是把后续代码推迟到微任务队列 ,并不会阻塞同步代码的执行。所以 同步结束 会先于 async 结束 输出。

7.4 微任务中嵌套宏任务

javascript 复制代码
Promise.resolve().then(() => {
  console.log('微任务1');
  setTimeout(() => {
    console.log('宏任务2');
  }, 0);
});

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

执行过程分析:

  1. Promise.then 进入微任务队列,setTimeout 进入宏任务队列;
  2. 同步代码结束,清空微任务队列,输出 微任务1,同时产生宏任务2;
  3. 宏任务队列中现在有宏任务1、宏任务2,按顺序取出;
  4. 输出 宏任务1,再输出 宏任务2。

最终输出:微任务1 宏任务1 宏任务2

关键点 :微任务中产生的宏任务,会追加到宏任务队列末尾,而不是插队。所以即使宏任务2 是在微任务1 中产生的,它仍然排在宏任务1 之后。

7.5 综合题:宏任务、微任务、async 混合

javascript 复制代码
async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}

async function async2() {
  console.log('async2');
}

console.log('script start');

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

async1();

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

console.log('script end');

执行过程分析:

  1. 同步代码依次执行:输出 script start;
  2. setTimeout 回调进入宏任务队列;
  3. 调用 async1(),同步执行到 await 之前:输出 async1 start,调用 async2() 输出 async2;
  4. await async2() 后面的代码(async1 end)被放入微任务队列;
  5. new Promise 的构造函数是同步执行 的,输出 promise1,promise2 进入微任务队列;
  6. 输出 script end;
  7. 同步代码结束,清空微任务队列:先输出 async1 end(先入队),再输出 promise2;
  8. 下一轮事件循环取出宏任务,输出 setTimeout。

最终输出:script start async1 start async2 promise1 script end async1 end promise2 setTimeout

易错点:

  • new Promise 的构造函数是同步执行 的,只有 .then 里的回调才是异步的;
  • await 后面的代码进入微任务队列,且先于 后面 Promise.then 的回调(因为先入队);
  • 微任务队列是 FIFO(先进先出),所以 async1 end 先于 promise2 输出。

7.6 面试答题模板

面试中遇到事件循环的输出顺序题,可以按以下步骤作答:

  1. 先找同步代码 :从上到下执行,遇到 console.log 直接输出;
  2. 标记异步任务 :遇到 setTimeout 放入宏任务队列,遇到 Promise.then / await 放入微任务队列;
  3. 注意 new Promise 构造函数 :它是同步执行的,只有 .then 是异步的;
  4. 清空微任务:同步代码结束后,一次性清空微任务队列(注意 FIFO 顺序);
  5. 执行宏任务:从宏任务队列头部取出一个执行,执行完再清空微任务,如此循环。

一句话总结 :同步代码先跑完,微任务队列清空,宏任务逐个执行,每个宏任务结束再清空微任务。

下面用一张图总结面试题的通用推导流程:

flowchart TD A[&#34;从上到下执行同步代码&#34;] --> B{&#34;遇到异步任务?&#34;} B -- &#34;setTimeout / setInterval&#34; --> C[&#34;放入宏任务队列&#34;] B -- &#34;Promise.then / await&#34; --> D[&#34;放入微任务队列&#34;] B -- &#34;new Promise 构造函数&#34; --> E[&#34;同步执行,只有 .then 是异步的&#34;] C --> F[&#34;同步代码执行完毕&#34;] D --> F E --> F F --> G[&#34;一次性清空微任务队列<br/>注意 FIFO 顺序&#34;] G --> H[&#34;从宏任务队列取出一个执行&#34;] H --> I{&#34;执行中产生新微任务?&#34;} I -- &#34;是&#34; --> G I -- &#34;否&#34; --> H

8. 总结

  • 宏任务由宿主环境提供,每轮事件循环取一个执行;
  • 微任务由 JS 引擎提供,在当前宏任务结束后全部清空;
  • 渲染发生在微任务清空之后、下一轮宏任务之前;
  • setTimeout(0) 不是立即执行,而是进入宏任务队列;
  • 面试中遇到输出顺序题,先画调用栈,再按「同步 → 微任务 → 宏任务」的顺序推导。

掌握事件循环,不仅能应对面试,更能帮助你写出流畅、可预测的异步代码。

相关推荐
ZoeLandia3 小时前
TypeScript 05:交叉类型、索引签名、元组与 enum
前端·typescript
anyup_前端梦工厂3 小时前
uni-app 模板 Starter 一键清理 Skill 发布
前端·人工智能·uni-app
行者全栈架构师3 小时前
【鸿蒙心迹】冷启动从 2.8s 优化到 0.9s——耗时拆解与数据对比实战(HarmonyOS 7.x)
前端·算法·架构
临期冰淇淋4 小时前
MTK平台 安卓16 GMS报错修改
android·java·前端
Misnearch4 小时前
React中的回调
前端·javascript·react.js
Profile排查笔记4 小时前
浏览器多开怎么设置独立 IP?Profile、代理与核验流程
前端·人工智能·后端·自动化
anyup_前端梦工厂4 小时前
将uni-app工程迁移uni-app x,AI 能帮我们做什么?
前端·人工智能·uni-app
凌云若寒5 小时前
BarTender官网下载打印机驱动
运维·服务器·前端·网络·产品经理
zd8451015005 小时前
基于 HTML 与 Modbus TCP 构建设备仪器上位机:可行性分析与代码结构设计
前端·tcp/ip·html