Skip to content

深入理解 Event Loop(事件循环)

从浏览器架构到微任务调度,彻底搞懂 JavaScript 异步执行机制

什么是事件循环?

定义:事件循环(Event Loop)是 JavaScript 运行时的核心调度机制,它负责协调调用栈(Call Stack)、任务队列(Task Queue)和微任务队列(Microtask Queue)之间的工作,使单线程的 JS 能够非阻塞地处理异步操作。

涉及场景

  • 定时器调度setTimeoutsetIntervalrequestAnimationFrame 的回调何时执行
  • 网络请求回调fetchXMLHttpRequest 完成后的处理
  • DOM 事件:用户点击、滚动、输入等事件的响应
  • Promise 微任务.then()async/awaitMutationObserver 的执行时机
  • Node.js I/O:文件读写、数据库查询等异步操作的调度

作用

  1. 非阻塞执行:让耗时操作(网络请求、文件I/O)不会冻结页面
  2. 确定执行顺序:定义了同步代码、微任务、宏任务的优先级规则
  3. 渲染协调:保证每一帧中 JS 执行和浏览器渲染的有序配合
  4. 面试核心考点:几乎所有前端面试都会考察 Event Loop 执行顺序题

为什么需要事件循环?

JavaScript 是单线程语言——同一时刻只能执行一段代码。这个设计源于它最初的用途:操作DOM。如果多个线程同时修改同一个DOM节点,浏览器将无法判断以谁为准。

但单线程带来一个严重问题:如果一个操作很耗时(如网络请求),整个页面都会卡死

事件循环就是解决这个问题的机制——它让 JavaScript 能够在单线程中非阻塞地处理异步操作。

浏览器的多进程架构

在理解事件循环之前,需要先了解浏览器的架构:

┌─────────────────────────────────────────────────────┐
│                    浏览器主进程                        │
│   (Browser Process - UI、地址栏、书签、前进后退)        │
├─────────────────────────────────────────────────────┤
│              渲染进程 (Renderer Process)               │
│   每个Tab一个(站点隔离后,每个站点一个)                 │
│  ┌──────────────┐  ┌──────────────┐  ┌───────────┐  │
│  │  JS引擎线程   │  │   渲染线程     │  │ 合成线程   │  │
│  │  (V8)        │  │  (Layout,     │  │           │  │
│  │  ⚠️ 与渲染线 │  │   Paint)      │  │           │  │
│  │  程互斥!     │  │              │  │           │  │
│  └──────────────┘  └──────────────┘  └───────────┘  │
│  ┌──────────────┐  ┌──────────────┐  ┌───────────┐  │
│  │ 事件触发线程  │  │ 定时器线程    │  │ 网络线程   │  │
│  │ (Event Loop  │  │ (setTimeout  │  │ (HTTP请求) │  │
│  │  管理者)     │  │  计时)       │  │           │  │
│  └──────────────┘  └──────────────┘  └───────────┘  │
├─────────────────────────────────────────────────────┤
│              GPU进程 / 网络进程 / 插件进程              │
└─────────────────────────────────────────────────────┘

关键点:

  • JS引擎线程与渲染线程互斥 —— JS执行时页面不会重绘,反之亦然
  • 定时器线程 负责 setTimeout/setInterval 的计时(不在JS线程中计时)
  • 事件触发线程 管理事件循环,将回调放入任务队列

事件循环的完整流程

                    ┌───────────────────────┐
                    │      调用栈 (Call Stack)│
                    │  ┌─────────────────┐  │
                    │  │ 当前执行的函数     │  │
                    │  ├─────────────────┤  │
                    │  │ 上一个函数        │  │
                    │  ├─────────────────┤  │
                    │  │ 全局执行上下文     │  │
                    │  └─────────────────┘  │
                    └───────────┬───────────┘

                    ┌───────────▼───────────┐
                    │    调用栈是否为空?      │
                    └───────────┬───────────┘
                           是   │
                    ┌───────────▼───────────┐
                    │  微任务队列是否为空?    │──否──→ 取出所有微任务执行
                    └───────────┬───────────┘         │
                           是   │          ←──────────┘
                    ┌───────────▼───────────┐
                    │  是否需要渲染?          │
                    │  (通常16.6ms一次)       │──是──→ 执行渲染
                    └───────────┬───────────┘         │
                           否   │          ←──────────┘
                    ┌───────────▼───────────┐
                    │  宏任务队列是否为空?    │──否──→ 取出一个宏任务执行
                    └───────────┬───────────┘         │
                           是   │          ←──────────┘
                    (等待新任务...)

每一轮事件循环的精确步骤

  1. 执行一个宏任务(如果宏任务队列不为空)
  2. 检查微任务队列,执行所有微任务直到队列清空
    • 微任务执行过程中产生的新微任务也会在本轮执行
  3. 判断是否需要渲染更新(浏览器决定,通常60fps = 16.6ms一次)
    • 如果需要渲染:
      • 触发 requestAnimationFrame 回调
      • 执行布局(Layout)
      • 执行绘制(Paint)
  4. 判断是否有空闲时间
    • 如果有:触发 requestIdleCallback 回调
  5. 回到第1步

宏任务 vs 微任务

宏任务(Macro Task / Task)

宏任务来源说明
<script> 整体代码第一个宏任务
setTimeout / setInterval定时器线程计时完毕后入队
setImmediateNode.js 独有
requestAnimationFrame严格来说是渲染步骤的一部分,但行为类似宏任务
I/O操作文件读写、网络请求完成
UI渲染/交互事件click、scroll、resize 等
MessageChannel创建独立的消息通道
postMessage跨窗口/iframe通信

微任务(Micro Task)

微任务来源说明
Promise.then/catch/finallyPromise 状态变化时
async/awaitawait 之后的代码相当于 .then()
queueMicrotask()手动添加微任务
MutationObserverDOM变化观察
process.nextTickNode.js 独有,优先级高于其他微任务

关键区别

javascript
// 微任务在当前宏任务结束后、下一个宏任务开始前、渲染前执行
// 宏任务之间可能穿插渲染

console.log('1. 同步代码(宏任务的一部分)');

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

Promise.resolve().then(() => {
  console.log('3. Promise.then(微任务)');
}).then(() => {
  console.log('4. Promise.then链(微任务中产生的微任务,同一轮执行)');
});

queueMicrotask(() => {
  console.log('3.5 queueMicrotask(微任务,按入队顺序执行)');
});

console.log('2. 同步代码(宏任务的一部分)');

// 输出顺序:1 → 2 → 3 → 3.5 → 4 → 5

经典面试题逐行分析

题目1:基础综合

javascript
console.log('script start');

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

Promise.resolve()
  .then(function () {
    console.log('promise1');
  })
  .then(function () {
    console.log('promise2');
  });

console.log('script end');

分析过程:

第一轮事件循环:
├── 宏任务:script整体代码
│   ├── console.log('script start')       → 输出 "script start"
│   ├── setTimeout callback → 放入宏任务队列
│   ├── Promise.resolve().then(cb1) → cb1放入微任务队列
│   └── console.log('script end')         → 输出 "script end"

├── 微任务队列清空:
│   ├── 执行cb1: console.log('promise1')  → 输出 "promise1"
│   │   └── .then(cb2) → cb2放入微任务队列
│   └── 执行cb2: console.log('promise2')  → 输出 "promise2"

└── (可能渲染)

第二轮事件循环:
├── 宏任务:setTimeout callback
│   └── console.log('setTimeout')         → 输出 "setTimeout"
└── 微任务队列:空

最终输出: script startscript endpromise1promise2setTimeout

题目2:async/await

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(function () {
  console.log('setTimeout');
}, 0);

async1();

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

console.log('script end');

关键知识点: await 可以理解为:

javascript
// await async2() 等价于:
async2().then(() => {
  console.log('async1 end');
});

逐步分析:

执行栈:
1. console.log('script start')           → 输出 "script start"
2. setTimeout callback → 放入宏任务队列
3. 调用 async1()
   3.1 console.log('async1 start')       → 输出 "async1 start"
   3.2 调用 async2()
       console.log('async2')             → 输出 "async2"
   3.3 await 后面的代码 → 放入微任务队列(相当于.then)
4. new Promise(executor)
   4.1 console.log('promise1')           → 输出 "promise1"
   4.2 resolve() → .then(cb) 放入微任务队列
5. console.log('script end')             → 输出 "script end"

微任务队列清空:
6. async1 await后面的代码
   console.log('async1 end')             → 输出 "async1 end"
7. Promise.then callback
   console.log('promise2')               → 输出 "promise2"

下一轮宏任务:
8. setTimeout callback
   console.log('setTimeout')             → 输出 "setTimeout"

最终输出: script startasync1 startasync2promise1script endasync1 endpromise2setTimeout

题目3:复杂嵌套

javascript
setTimeout(() => {
  console.log('timeout1');
  Promise.resolve().then(() => {
    console.log('promise1');
  });
}, 0);

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

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

console.log('start');

分析:

第一轮(script整体):
├── setTimeout1 callback → 宏任务队列
├── setTimeout2 callback → 宏任务队列
├── Promise.then(cb3) → 微任务队列
└── console.log('start')                  → 输出 "start"

微任务:
├── cb3: console.log('promise3')          → 输出 "promise3"
│   └── setTimeout3 callback → 宏任务队列

第二轮(宏任务:timeout1):
├── console.log('timeout1')               → 输出 "timeout1"
└── Promise.then(cb1) → 微任务队列
微任务:
└── cb1: console.log('promise1')          → 输出 "promise1"

第三轮(宏任务:timeout2):
├── console.log('timeout2')               → 输出 "timeout2"
└── Promise.then(cb2) → 微任务队列
微任务:
└── cb2: console.log('promise2')          → 输出 "promise2"

第四轮(宏任务:timeout3):
└── console.log('timeout3')               → 输出 "timeout3"

最终输出: startpromise3timeout1promise1timeout2promise2timeout3

题目4:微任务无限循环

javascript
// ⚠️ 危险代码 - 微任务会阻塞渲染!
function infiniteMicrotask() {
  Promise.resolve().then(() => {
    console.log('microtask');
    infiniteMicrotask(); // 每次微任务中又产生新微任务
  });
}
infiniteMicrotask();

// 微任务队列永远不会清空
// → 永远不会执行下一个宏任务
// → 永远不会渲染
// → 页面卡死!

// 对比:setTimeout 不会阻塞
function infiniteTimeout() {
  setTimeout(() => {
    console.log('timeout');
    infiniteTimeout();
  }, 0);
}
infiniteTimeout();
// 每次都是新的宏任务,宏任务之间可以渲染
// → 页面不会卡死

requestAnimationFrame 的执行时机

requestAnimationFrame(rAF)在事件循环中的位置比较特殊:

宏任务 → 微任务全部清空 → [如果需要渲染] → rAF → 布局 → 绘制 → 宏任务...
javascript
console.log('1');

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

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

requestAnimationFrame(() => console.log('rAF'));

console.log('2');

// 确定的顺序:1 → 2 → promise → ...
// rAF 和 timeout 的顺序不确定!
// rAF 在渲染前执行,可能在 timeout 之前或之后
// 取决于浏览器是否决定在这一帧渲染
javascript
// rAF 的典型用途:读写分离优化
// 错误做法:在循环中反复读写DOM,触发强制同步布局
elements.forEach(el => {
  const width = el.offsetWidth; // 读 → 触发Layout
  el.style.width = width + 10 + 'px'; // 写 → 使Layout失效
  // 下一次循环读取时又触发Layout = 布局抖动!
});

// 正确做法:批量读取,然后在 rAF 中批量写入
const widths = elements.map(el => el.offsetWidth); // 批量读
requestAnimationFrame(() => {
  elements.forEach((el, i) => {
    el.style.width = widths[i] + 10 + 'px'; // 批量写
  });
});

requestIdleCallback 的执行时机

一帧的时间线(约16.6ms):
┌────────┬──────────┬──────────┬──────────┬──────────┐
│ 宏任务  │ 微任务    │   rAF    │ 布局+绘制 │ 空闲时间  │
│        │          │          │          │ rIC在此  │
└────────┴──────────┴──────────┴──────────┴──────────┘
javascript
// requestIdleCallback - 利用空闲时间执行低优先级任务
requestIdleCallback((deadline) => {
  // deadline.timeRemaining() 返回当前帧剩余的毫秒数
  while (deadline.timeRemaining() > 0 && tasks.length > 0) {
    processTask(tasks.shift());
  }

  // 如果还有任务没处理完,下一帧继续
  if (tasks.length > 0) {
    requestIdleCallback(processRemainingTasks);
  }
}, { timeout: 2000 }); // 最多等2秒,即使没有空闲也会执行

Node.js 的事件循环

Node.js 的事件循环与浏览器有显著差异,基于 libuv 库实现:

   ┌───────────────────────────┐
┌─>│         timers            │  setTimeout / setInterval
│  └─────────────┬─────────────┘
│  ┌─────────────▼─────────────┐
│  │     pending callbacks     │  I/O回调(上一轮延迟的)
│  └─────────────┬─────────────┘
│  ┌─────────────▼─────────────┐
│  │       idle, prepare       │  仅内部使用
│  └─────────────┬─────────────┘
│  ┌─────────────▼─────────────┐
│  │          poll             │  检索新的I/O事件
│  │   (执行I/O相关回调)        │  计算应该阻塞多久
│  └─────────────┬─────────────┘
│  ┌─────────────▼─────────────┐
│  │         check             │  setImmediate
│  └─────────────┬─────────────┘
│  ┌─────────────▼─────────────┐
│  │     close callbacks       │  socket.on('close', ...)
│  └─────────────┬─────────────┘
└─────────────────┘

⚠️ 每个阶段之间都会执行所有微任务
   (process.nextTick 优先于 Promise.then)

Node.js 关键差异

javascript
// 1. process.nextTick vs Promise.then
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
// Node.js 中:nextTick → promise
// nextTick 优先级高于所有其他微任务

// 2. setTimeout vs setImmediate(不确定顺序)
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// 顺序不确定!取决于进程性能、系统时钟精度

// 3. 在 I/O 回调中,setImmediate 一定先于 setTimeout
const fs = require('fs');
fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});
// 确定顺序:immediate → timeout
// 因为 I/O 回调在 poll 阶段,下一个阶段是 check(setImmediate)

// 4. Node.js 11+ 行为与浏览器对齐
// 每执行一个宏任务后,立即清空微任务队列
// (之前的版本是每个阶段结束后才清空微任务)

完整对比

javascript
// Node.js vs 浏览器的事件循环差异
setTimeout(() => {
  console.log('timeout1');
  Promise.resolve().then(() => console.log('promise1'));
}, 0);

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

// 浏览器 & Node.js 11+:
// timeout1 → promise1 → timeout2 → promise2
// (每个宏任务后立即执行微任务)

// Node.js 10 及更早:
// timeout1 → timeout2 → promise1 → promise2
// (同一阶段的所有宏任务执行完后,再清空微任务)

queueMicrotask API

queueMicrotask()专门用于入队微任务的标准API:

javascript
// 使用 queueMicrotask(推荐)
queueMicrotask(() => {
  console.log('microtask');
});

// 等价于(但语义更清晰):
Promise.resolve().then(() => {
  console.log('microtask via promise');
});

// 实际用例:批量DOM更新
let pending = false;
const updates = [];

function scheduleUpdate(update) {
  updates.push(update);
  if (!pending) {
    pending = true;
    queueMicrotask(() => {
      const batch = [...updates];
      updates.length = 0;
      pending = false;
      // 批量执行所有更新
      batch.forEach(fn => fn());
    });
  }
}

// 同步代码中多次调用,只会执行一次批量更新
scheduleUpdate(() => el1.style.color = 'red');
scheduleUpdate(() => el2.style.color = 'blue');
scheduleUpdate(() => el3.style.color = 'green');
// → 三个更新合并为一次微任务执行

终极面试题

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

async function async2() {
  console.log('async2');
  return new Promise((resolve) => {
    console.log('async2 promise');
    resolve();
  }).then(() => {
    console.log('async2 then');
  });
}

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

console.log('script start');

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

async1();

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

console.log('script end');

详细分析:

同步代码执行:
1. "script start"
2. 调用 async1()
   → "async1 start"
   → 调用 async2()
     → "async2"
     → "async2 promise"(Promise executor 是同步的)
     → .then(cb: "async2 then") → 微任务队列 [async2Then]
     → await 等待 async2() 返回的 Promise 完成
3. "promise1"(Promise executor 同步执行)
   → .then(cb: "promise2") → 微任务队列 [async2Then, promise2Cb]
4. "script end"

第一轮微任务:
5. 执行 async2Then → "async2 then"
   → async2 的 Promise 完成 → await 后面的代码入队
   → 微任务队列 [promise2Cb, async1EndCb]
6. 执行 promise2Cb → "promise2"
   → .then(cb: "promise3") → 微任务队列 [async1EndCb, promise3Cb]
7. 执行 async1EndCb → "async1 end"
   → 调用 async3() → "async3"
   → await → 微任务队列 [promise3Cb, async1FinalCb]
8. 执行 promise3Cb → "promise3"
9. 执行 async1FinalCb → "async1 final"

第二轮宏任务:
10. "setTimeout"

最终输出:

script start
async1 start
async2
async2 promise
promise1
script end
async2 then
promise2
async1 end
async3
promise3
async1 final
setTimeout

常见误区

1. setTimeout(fn, 0) 不是真正的 0ms

javascript
// 最小延迟约 4ms(嵌套超过5层时)
// HTML规范规定:嵌套层级 > 5 时,最小延迟 4ms
function nestedTimeout(depth = 0) {
  const start = performance.now();
  setTimeout(() => {
    console.log(`depth ${depth}: ${(performance.now() - start).toFixed(2)}ms`);
    if (depth < 10) nestedTimeout(depth + 1);
  }, 0);
}
nestedTimeout();
// depth 0: 0.xx ms
// depth 1: 0.xx ms
// ...
// depth 5+: 4.xx ms(最小延迟生效)

2. 微任务不是"下一轮"事件循环

javascript
// 微任务在当前宏任务的末尾执行,不是新的事件循环
// 它是当前轮次的"收尾工作"
console.log('start');
queueMicrotask(() => console.log('microtask'));
console.log('end');
// start → end → microtask(微任务在同步代码之后、渲染之前)

3. async/await 不会创建新的微任务(只是转换)

javascript
// await 后面的代码就是 .then() 的语法糖
async function foo() {
  console.log(1);
  const result = await bar();
  console.log(result); // 这行代码相当于 .then(result => ...)
}

总结

┌──────────────────────────────────────────────────────────┐
│                    事件循环核心规则                         │
├──────────────────────────────────────────────────────────┤
│ 1. 执行一个宏任务(最初是 <script> 整体代码)               │
│ 2. 执行所有微任务(包括执行过程中产生的新微任务)              │
│ 3. 如果需要渲染 → rAF → Layout → Paint                   │
│ 4. 如果有空闲时间 → requestIdleCallback                   │
│ 5. 回到第1步                                             │
├──────────────────────────────────────────────────────────┤
│ 优先级:同步代码 > 微任务 > rAF > 宏任务 > rIC             │
│ Node.js: process.nextTick > Promise.then > 其他微任务      │
├──────────────────────────────────────────────────────────┤
│ 面试解题步骤:                                             │
│ ① 找出所有同步代码,确定初始输出                             │
│ ② 将异步回调分类(宏/微),放入对应队列                       │
│ ③ 同步代码执行完后,先清空微任务队列                         │
│ ④ 取出一个宏任务执行,重复②③                               │
└──────────────────────────────────────────────────────────┘