JavaScript 核心面试高频

详解 JavaScript 事件循环

事件循环(Event Loop)是 JavaScript 实现异步的核心机制。由于 JS 是单线程语言, 它通过事件循环在「调用栈」「Web API」「任务队列」之间协调工作, 让非阻塞的异步编程成为可能。

为什么需要事件循环?

JavaScript 引擎(如 V8)是单线程的, 同一时刻只能执行一段代码。如果所有操作都是同步的,一个网络请求就可能阻塞整个页面。

事件循环的出现,就是为了解决这个矛盾:让耗时操作(网络请求、定时器、用户交互)委托给浏览器的其他线程处理,主线程继续执行后续代码,等异步操作完成后再回调处理结果。

架构事件循环全景图

调用栈 Call Stack

记录函数执行上下文的栈结构,遵循 LIFO(后进先出) 原则。 每调用一个函数就压栈,执行完毕就弹栈。栈空时,事件循环开始检查任务队列。

main()
fetchData()
parseJSON() ← 栈顶

Web APIs(宿主环境)

浏览器(或 Node.js)提供的多线程异步接口,包括:

setTimeoutfetchDOM 事件XMLHttpRequestaddEventListenerIntersectionObserver

宏任务队列 Macrotask

也叫 Task Queue,每轮事件循环只取 一个 宏任务执行。

setTimeout / setInterval
setImmediate (Node.js)
I/O 操作
UI 渲染 (requestAnimationFrame)
MessageChannel
requestAnimationFrame

微任务队列 Microtask

优先级高于宏任务,每轮事件循环会 清空所有 微任务。

Promise.then / catch / finally
async/await (await 之后的代码)
MutationObserver
queueMicrotask()
process.nextTick (Node.js)

⚡ 一轮事件循环的完整流程

执行调用栈
处理 Web API
清空微任务队列
取出一个宏任务

详解事件循环的执行步骤

1

执行调用栈

同步代码直接推入 Call Stack,按后进先出(LIFO)顺序执行。

2

处理 Web API

遇到 setTimeout / fetch / DOM 事件等,交由浏览器 Web API 线程异步处理。

3

清空微任务队列

调用栈每清空一次,立即清空所有 Microtask(Promise.then / MutationObserver)。

4

取出一个宏任务

从 Macrotask 队列取出一个任务推入调用栈,然后回到步骤 1。

对比宏任务 vs 微任务

Macrotask(宏任务)

每次事件循环取一个

1setTimeout / setInterval
2setImmediate (Node.js)
3I/O 操作
4UI 渲染 (requestAnimationFrame)
5MessageChannel
6requestAnimationFrame

Microtask(微任务)

每轮清空全部微任务

1Promise.then / catch / finally
2async/await (await 之后的代码)
3MutationObserver
4queueMicrotask()
5process.nextTick (Node.js)

关键优先级规则

微任务的执行优先级 远高于 宏任务。 事件循环在每次执行完一个宏任务(或同步代码)后,会优先清空整个微任务队列, 只有微任务队列为空时才会取出下一个宏任务。这意味着如果微任务不断产生新的微任务, 宏任务将被无限期推迟

练习经典输出题详解

经典面试题 #1:基础宏微任务

基础

代码

console.log('1 - 同步');

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

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

console.log('4 - 同步');

输出结果

11 - 同步
24 - 同步
33 - 微任务
42 - 宏任务

解析

同步代码 1、4 先执行 → 调用栈清空 → 清空微任务队列(3)→ 取出宏任务(2)。

经典面试题 #2:微任务优先级

进阶

代码

Promise.resolve()
  .then(() => console.log('微任务1'))
  .then(() => console.log('微任务2'));

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

console.log('同步');

输出结果

1同步
2微任务1
3微任务3
4微任务2

解析

两个 Promise 各自的 .then 链是独立注册的。微任务队列中:微任务1 → 微任务3 → 微任务2(因为微任务2 依赖微任务1 完成后才入队)。

经典面试题 #3:async/await 的本质

高级

代码

async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}

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

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

输出结果

1script start
2async1 start
3async2
4promise1
5script end
6async1 end
7promise2

解析

await async2() 后面的代码相当于放入微任务队列。执行完同步代码后,先执行 async1 end(微任务1),再执行 promise2(微任务2)。

经典面试题 #4:宏任务嵌套微任务

进阶

代码

console.log('1');

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

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

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

console.log('6');

输出结果

11
26
35
42
53
64

解析

同步 1,6 → 微任务 5 → 第一个宏任务(2) → 产生的微任务(3) → 第二个宏任务(4)。注意:每执行完一个宏任务都要清空全部微任务。

深入async/await 与事件循环

await 的本质

async/await 只是 Promise 的语法糖。理解它的关键在于:

1. async 函数执行到 await 时

会暂停执行,将 await 后面的代码包装成微任务

2. await 等待的 Promise 解决后

将后续代码推入微任务队列,而非立即执行

3. await 让出控制权

类似于 yield,让事件循环继续处理其他任务

等价转换

async/await → Promise 链

await 写法

async function fn() {
  const a = await fetch('/api');
  const b = await a.json();
  return b;
}

Promise 写法

function fn() {
  return fetch('/api')
    .then(a => a.json())
    .then(b => b);
}

性能提示:如果两个 await 操作之间没有依赖关系, 应使用 Promise.all() 并行执行,避免不必要的串行等待。

拓展浏览器 vs Node.js 差异

浏览器环境

宏任务:setTimeout、setInterval、I/O、UI渲染
微任务:Promise.then、MutationObserver
requestAnimationFrame 在渲染前执行
每轮宏任务后可能触发 UI 渲染
有明确的渲染时机(16.6ms 帧)

Node.js 环境

基于 libuv 实现事件循环
process.nextTick 优先级高于其他微任务
有 6 个阶段(timers → poll → check 等)
setImmediate 在 check 阶段执行
无 UI 渲染,无 requestAnimationFrame

Node.js 事件循环六阶段

timers

setTimeout/Interval 回调

pending

系统级回调

idle/prepare

内部使用

poll

I/O 回调

check

setImmediate

close

关闭回调

避坑常见陷阱与最佳实践

陷阱

setTimeout(fn, 0) ≠ 立即执行

setTimeout 的最小延迟约为 4ms(HTML5 规范)。即使设置 0ms,回调也会被放入宏任务队列,等同步代码和微任务全部执行完毕后才执行。

陷阱

微任务可能造成页面卡顿

如果微任务中不断产生新的微任务(如递归 Promise.resolve().then()),宏任务和 UI 渲染将永远得不到执行机会,导致页面冻结。

最佳实践

合理使用 requestAnimationFrame

DOM 动画操作应放在 rAF 回调中,它在浏览器下一次渲染前执行,保证动画流畅。注意 rAF 是宏任务还是渲染步骤因浏览器实现而异。

总结:核心记忆要点

1

JS 是单线程的,事件循环是实现异步的核心机制

2

执行顺序:同步代码 → 微任务 → 宏任务(每次一个)

3

微任务优先级高于宏任务,每轮事件循环会清空全部微任务

4

await 后面的代码等价于 Promise.then 的回调(微任务)

5

每执行完一个宏任务,都要检查并清空微任务队列

6

Node.js 的事件循环比浏览器更复杂,有 6 个阶段