核心:JavaScript 是单线程语言,通过事件循环(Event Loop)机制实现异步操作,避免代码阻塞,让页面保持响应。

一、什么是事件循环?

事件循环是 JavaScript 运行时的核心机制,负责协调代码执行、事件处理、异步任务的调度。简单来说:

  • 单线程:JavaScript 一次只能做一件事
  • 非阻塞:通过异步机制,不会因为等待而卡住
  • 循环调度:持续检查任务队列,按优先级执行

想象一个餐厅服务员(JavaScript 引擎):他一次只能服务一桌客人(单线程),但可以先接单、再上菜、中间去收盘子(异步),通过合理安排顺序(事件循环),让所有客人都不用等太久。

二、核心组成部分

事件循环整体架构
graph TB subgraph 执行环境 A[调用栈 Call Stack] end subgraph 任务队列 B[微任务队列 Microtask Queue] C[宏任务队列 Macrotask Queue] end subgraph Web APIs D[setTimeout/setInterval] E[Promise] F[DOM Events] G[AJAX/Fetch] end A -->|执行完毕| H{调用栈是否为空?} H -->|是| I[执行所有微任务] I --> J{微任务队列是否为空?} J -->|是| K[取出一个宏任务] J -->|否| I K --> A H -->|否| A E -.->|添加任务| B D -.->|添加任务| C F -.->|添加任务| C G -.->|添加任务| C style A fill:#409EFF,stroke:#409EFF,color:#FFFFFF style B fill:#67C23A,stroke:#67C23A,color:#FFFFFF style C fill:#E6A23C,stroke:#E6A23C,color:#FFFFFF style I fill:#67C23A,stroke:#67C23A,color:#FFFFFF style K fill:#E6A23C,stroke:#E6A23C,color:#FFFFFF

2.1 调用栈(Call Stack)

存放正在执行的代码。JavaScript 引擎逐行执行代码,函数调用会入栈,执行完毕就出栈。栈空了才会去检查任务队列。

2.2 宏任务(Macrotask)

浏览器级别的异步任务,每次事件循环只执行一个宏任务。

宏任务类型 说明 示例
setTimeout 延迟执行 setTimeout(() => {}, 1000)
setInterval 定时执行 setInterval(() => {}, 1000)
DOM 事件 用户交互 button.onclick
AJAX 请求 网络请求 XMLHttpRequest
script 标签 整段脚本 <script>...</script>

2.3 微任务(Microtask)

JavaScript 引擎级别的异步任务,优先级高于宏任务。每次宏任务执行完,会清空所有微任务。

微任务类型 说明 示例
Promise.then/catch/finally Promise 回调 promise.then(() => {})
MutationObserver DOM 变化监听 observer.observe()
queueMicrotask 手动添加微任务 queueMicrotask(() => {})
async/await 语法糖(底层是 Promise) await fetch()

三、事件循环执行流程

事件循环完整流程
flowchart TD Start([开始执行脚本]) --> A[执行同步代码
遇到函数调用就入栈] A --> B{调用栈是否为空?} B -->|否| A B -->|是| C[检查微任务队列] C --> D{有微任务?} D -->|是| E[执行一个微任务] E --> F{执行过程中产生新微任务?} F -->|是| E F -->|否| D D -->|否| G[检查宏任务队列] G --> H{有宏任务?} H -->|是| I[取出一个宏任务执行] I --> A H -->|否| J([等待新任务]) J --> C style A fill:#409EFF,stroke:#409EFF,color:#FFFFFF style E fill:#67C23A,stroke:#67C23A,color:#FFFFFF style I fill:#E6A23C,stroke:#E6A23C,color:#FFFFFF
1
执行同步代码
调用栈
从上到下执行脚本,函数调用入栈,执行完出栈。遇到异步任务(setTimeout、Promise)注册到对应队列。
2
清空所有微任务
微任务队列
调用栈为空后,立即执行微任务队列中的所有任务。注意:微任务执行过程中产生的新微任务也会在本轮清空。
3
执行一个宏任务
宏任务队列
从宏任务队列取出一个任务执行,执行完后回到步骤2。每次只执行一个宏任务,确保页面不会长时间无响应。
4
循环往复
Event Loop
重复步骤1-3,持续监听任务队列,直到页面关闭。

四、宏任务 vs 微任务执行顺序

宏任务与微任务执行时序对比
sequenceDiagram participant 主线程 participant 微任务队列 participant 宏任务队列 Note over 主线程: 执行同步代码 主线程->>微任务队列: 注册 Promise.then 主线程->>宏任务队列: 注册 setTimeout Note over 主线程: 同步代码执行完毕 主线程->>微任务队列: 有微任务吗? 微任务队列-->>主线程: 有!执行 Promise.then Note over 主线程: 执行微任务1 主线程->>微任务队列: 又产生新微任务? 微任务队列-->>主线程: 有!继续执行 Note over 主线程: 执行微任务2 主线程->>微任务队列: 还有微任务吗? 微任务队列-->>主线程: 没了 Note over 主线程: 微任务队列清空 主线程->>宏任务队列: 取出一个宏任务 宏任务队列-->>主线程: 执行 setTimeout 回调 Note over 主线程: 执行宏任务 主线程->>微任务队列: 检查微任务 Note over 主线程: 循环继续...

关键规则:每执行完一个宏任务,必须清空所有微任务,再执行下一个宏任务。这保证了 Promise 等微任务的响应速度快于定时器等宏任务。

五、实战案例:代码执行过程可视化

经典面试题

console.log('1');

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

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

console.log('6');
代码执行流程详解
graph TD A[开始执行] --> B[输出 '1'
同步代码] B --> C[注册 setTimeout
回调函数到宏任务队列] C --> D[注册 Promise.then
回调函数到微任务队列] D --> E[输出 '6'
同步代码] E --> F[同步代码执行完毕] F --> G[执行微任务:Promise.then] G --> H[输出 '4'] H --> I[注册新的 setTimeout
到宏任务队列] I --> J[微任务队列清空] J --> K[执行宏任务1:setTimeout] K --> L[输出 '2'] L --> M[注册新的 Promise.then
到微任务队列] M --> N[执行微任务:Promise.then] N --> O[输出 '3'] O --> P[微任务队列清空] P --> Q[执行宏任务2:setTimeout] Q --> R[输出 '5'] R --> S[结束] style B fill:#409EFF,stroke:#409EFF,color:#FFFFFF style E fill:#409EFF,stroke:#409EFF,color:#FFFFFF style G fill:#67C23A,stroke:#67C23A,color:#FFFFFF style K fill:#E6A23C,stroke:#E6A23C,color:#FFFFFF style N fill:#67C23A,stroke:#67C23A,color:#FFFFFF style Q fill:#E6A23C,stroke:#E6A23C,color:#FFFFFF

正确输出顺序:1 → 6 → 4 → 2 → 3 → 5

执行步骤拆解

步骤 操作 输出 队列状态
1 执行同步代码 1, 6 微任务:[Promise4] 宏任务:[setTimeout2]
2 清空微任务 4 微任务:[] 宏任务:[setTimeout2, setTimeout5]
3 执行宏任务1 2 微任务:[Promise3] 宏任务:[setTimeout5]
4 清空微任务 3 微任务:[] 宏任务:[setTimeout5]
5 执行宏任务2 5 微任务:[] 宏任务:[]

六、常见问题与注意事项

Q1: setTimeout(fn, 0) 真的会立即执行吗?

不会!即使延迟设为 0,回调函数也会被放入宏任务队列,等待当前同步代码和所有微任务执行完后才会执行。实际延迟通常在 4ms 左右(浏览器最小延迟限制)。

Q2: async/await 是宏任务还是微任务?

微任务!async/await 是 Promise 的语法糖。await 后面的代码相当于 Promise.then() 回调,会被添加到微任务队列。

Q3: 微任务会无限循环吗?

function recursiveMicrotask() {
  Promise.resolve().then(() => {
    console.log('微任务');
    recursiveMicrotask(); // 递归调用
  });
}
recursiveMicrotask();

会!微任务产生新微任务会在本轮清空,导致宏任务永远无法执行,页面卡死。这种情况会触发浏览器的保护机制强制中断。

七、总结

概念 特点 典型场景 执行时机
调用栈 同步执行,LIFO 普通函数调用 立即执行
微任务 高优先级,批量清空 Promise、async/await 每个宏任务后
宏任务 低优先级,单次执行 setTimeout、事件监听 微任务清空后

记忆口诀:同步代码先执行,微任务紧跟其后,宏任务排队等候。每次宏任务后都要清空微任务,如此循环往复,JavaScript 虽是单线程,却能高效处理异步任务。