鲁斯前端布鲁斯前端

文章中英模式

布鲁斯前端JS面试题目 - 事件循环与异步操作

完整解析 JavaScript 与 Node.js 的事件循环,包含 microtask/macrotask 差异、浏览器与 Node.js 执行顺序、nextTick 与 Promise 优先权、常见面试陷阱与解法。

影片縮圖

懒得看文章?那就来看视频吧

什么是异步操作?

JavaScript 是单线程语言,无法同时处理多个任务。为了避免遇到等待操作(如 API 请求、计时器)时卡住整个程序,JavaScript 引入了异步机制。这些异步任务会先交由浏览器或 Node.js 的底层处理,完成后再通过事件循环排队回来执行。

  • setTimeout:macrotask,延迟后执行
  • Promise.then:microtask,同步后立即执行
  • async/await:Promise 语法糖
  • queueMicrotask:手动加入 microtask queue

浏览器的事件循环

在浏览器中,事件循环每一轮的执行顺序为:

  1. 1.
    执行同步代码(Call Stack)
  2. 2.
    清空所有 Microtask queue
  3. 3.
    执行一个 Macrotask
  4. 4.
    重复循环

Microtask 会在每轮事件循环结束前全部执行完毕,优先于任何 macrotask。

console.log('1');

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

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

console.log('4');

輸出順序:1 → 4 → 3 → 2

requestAnimationFrame 在事件循环中的位置

requestAnimationFrame (rAF) 是浏览器提供的用于优化动画性能的 API,它在事件循环中有特殊的位置:

浏览器每一帧的处理顺序:
1. 执行同步代码
2. 执行所有 microtasks (Promise.then, queueMicrotask 等)
3. 执行 requestAnimationFrame 回调
4. 浏览器渲染 (layout, paint, composite)
5. 执行一个 macrotask (setTimeout, setInterval 等)
6. 返回步骤 1 开始下一轮循环

Node.js 的事件循环

Node.js 的事件循环是基于 libuv,总共有六个阶段,每个阶段都有自己对应的任务。每一阶段之间会先执行所有 microtask(含 process.nextTick 和 Promise)。

  1. 1.
    timers:处理 setTimeout / setInterval
  2. 2.
    pending callbacks:某些异步操作的回调
  3. 3.
    idle, prepare:内部使用
  4. 4.
    poll:等待新的 I/O 事件
  5. 5.
    check:执行 setImmediate
  6. 6.
    close callbacks:像是 socket.on('close')
process.nextTick(() => console.log('tick'));
Promise.resolve().then(() => console.log('promise'));
setTimeout(() => console.log('timeout'), 0);
console.log('sync');

輸出順序:sync → tick → promise → timeout

Microtask vs Macrotask 整理表

  • 🟢
    Microtask(同步后立即执行):Promise.then, queueMicrotask, MutationObserver, async/await
  • 🟡
    Macrotask(下一轮事件循环):setTimeout, setInterval, setImmediate
  • 🔴
    特别:Node.js 中 process.nextTick 优先于所有 microtask

setTimeout vs setImmediate

在 Node.js 中,如果 setTimeout setImmediate 同时出现在 I/O callback 中, setImmediate 会优先执行。

但如果直接在主程序中执行,顺序会不稳定:
// 在 I/O callback 中,順序固定
const fs = require('fs');
fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'));
  setImmediate(() => console.log('immediate'));
});
// 輸出:immediate → timeout

// 直接執行,順序不固定
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// 輸出:順序不固定!
原因:主程序中的 setTimeout(fn, 0) 实际上会被设为 1ms,而事件循环启动时机的微小差异会影响执行顺序。

🔥 常见面试题目

(一)什么是事件循环(Event Loop)?

解答:

  1. 微任务与宏任务:

    JavaScript 是单线程语言,一次只能做一件事。为了处理异步,JavaScript 将任务分为两类:

    • Microtask(微任务): 比如 Promise.thenqueueMicrotaskMutationObserver
    • Macrotask(宏任务): 比如 setTimeoutsetIntervalMessageChannel、Node.js 中的 setImmediate

    执行顺序是「同步代码 → 所有 microtask → 一个 macrotask → 重复」

  2. Node.js 与浏览器的差异:

    在浏览器中,每轮事件循环会先执行所有同步代码与 microtask,然后再从 macrotask queue 中取出一个执行。

    在 Node.js 中,事件循环分为六大阶段:timers pending callbacks poll check close callbacks,每个阶段结束都会先跑 process.nextTick Promise 的 microtask。

    • process.nextTick:Node.js 专有,优先级最高
    • Promise.then:属于 microtask,优先于 macrotask
    • setImmediate:只存在于 Node.js,在 check 阶段执行

(二)事件循环顺序

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

解答:输出A → D → C → B

  • 1. 先执行同步代码,印出 A 和 D
  • 2. setTimeout 是 macrotask,会被放到下一轮事件循环
  • 3. Promise.then 是 microtask,会在同步代码后立即执行,印出 C
  • 4. 最后才执行 setTimeout 的 callback,印出 B

(三)setTimeout vs setImmediate 的异步行为

setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

解答:输出顺序不固定(可能是 timeout → immediate,也可能是 immediate → timeout)

  • 解释:这段代码没有包在 I/O callback 里,所以执行的先后顺序无法保证,取决于执行环境与事件循环调度时机。
  • setTimeout 在 timers 阶段
  • setImmediate 在 check 阶段
  • 因为这段代码没有包在 I/O callback 里,所以执行的先后顺序无法保证,取决于执行环境与事件循环调度时机。

(四)nextTick vs Promise

process.nextTick(() => console.log('tick'));
Promise.resolve().then(() => console.log('promise'));
console.log('sync');

解答:输出sync → tick → promise

  • 1. 同步代码先执行,印出 sync
  • 2. process.nextTick 优先于所有 microtask,印出 tick
  • 3. Promise.then 是 microtask,最后执行并印出 promise

(五)Promise in setTimeout

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

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

解答:输出4 → 3 → 1 → 2

  • 1. 先执行同步代码,印出 4
  • 2. Promise.then 是 microtask,立即执行并印出 3
  • 3. setTimeout 是 macrotask,在下一轮事件循环执行,印出 1
  • 4. setTimeout 内的 Promise.then 会在 setTimeout 执行完后立即执行,印出 2

(六)async/await 与事件循环

async function foo() {
  console.log('A');
  await Promise.resolve();
  console.log('B');
}

console.log('C');
foo();
console.log('D');

解答:输出C → A → D → B

解析:async/await 会在 await 处中断,将后续逻辑作为 microtask 进入 queue。

(七)setTimeout + Promise + async/await 混合执行顺序

console.log('start');

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

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

(async () => {
  console.log('async1 start');
  await Promise.resolve();
  console.log('async1 end');
})();

console.log('end');

解答:输出start → async1 start → end → promise → async1 end → timeout

解析:

  • 同步代码先执行:startasync1 startend
  • 接着进入 microtask queue,先执行 promise,再执行 async1 end
  • 最后才是 macrotask 的 setTimeout

(八) requestAnimationFrame 与 Event Loop 的关系是什么?

A: requestAnimationFrame 在事件循环中有特殊的执行时机。它会在每一帧的微任务(microtasks)执行完毕后、渲染前被调用,具体顺序为:

事件循环中的一帧执行顺序:
1. 执行同步任务
2. 执行微任务队列(Promise.then, queueMicrotask等)
3. 执行 requestAnimationFrame 回调 ← 特殊时机点
4. 浏览器渲染(layout, paint, composite)
5. 执行宏任务(setTimeout, setInterval等)
6. 进入下一帧循环

这使得 rAF 能在渲染前的最后时刻更新画面,
确保动画计算结果能立即被渲染,不会延迟到下一帧。
console.log('script start');

setTimeout(() => {
  console.log('setTimeout 1');

  Promise.resolve().then(() => {
    console.log('microtask inside setTimeout 1');
  });
}, 0);

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

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

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

requestAnimationFrame(() => {
  console.log('rAF callback');
});

console.log('script end');

输出顺序解析:

  1. 先执行同步任务:script start script end
  2. 接着执行当前帧的所有微任务:microtask 1 microtask 2
  3. 然后执行 requestAnimationFrame 回调:rAF callback(在下一次重绘前)
  4. 浏览器进行渲染
  5. 最后执行宏任务:setTimeout 1 microtask inside setTimeout 1(注意这个微任务在 setTimeout 回调内,所以会立即执行)→ setTimeout 2

完整输出顺序:script start script end microtask 1 microtask 2 rAF callback setTimeout 1 microtask inside setTimeout 1 setTimeout 2