第2节:JavaScript 事件循环机制

0 阅读3分钟

题记:React 的每一次渲染决策,都发生在事件循环的缝隙里。


🔍 本节要点

  • 事件循环是 JavaScript 并发模型的唯一实现方式
  • 任务队列与微任务队列的优先级差异
  • 事件循环如何影响 React 的渲染时机

单线程的 JavaScript 怎么"同时"做很多事?

JavaScript 是单线程的——同一时刻只能执行一段代码。但我们的页面却能响应点击、播放动画、发送网络请求……这背后靠的就是事件循环(Event Loop)

事件循环不是什么魔法,它就是一个永不停止的 while 循环,它的工作逻辑极其简单:

while (true) {
  // 1. 执行完当前调用栈的所有同步代码
  // 2. 清空微任务队列(全部执行完,包括过程中新增的微任务)
  // 3. 尝试渲染(如果有待渲染的更新)
  // 4. 从宏任务队列取一个任务,执行它
  // 5. 重复
}

简单来说:事件循环就是不断地问——"现在谁在排队?"

两个队列的博弈

理解事件循环,关键在于理解两个队列:

宏任务队列(MacroTask Queue)

  • setTimeoutsetInterval
  • I/O 操作
  • requestAnimationFrame
  • UI 渲染

微任务队列(MicroTask Queue)

  • Promise.then() / Promise.catch()
  • queueMicrotask()
  • MutationObserver

执行顺序的黄金法则:每次从宏任务队列取任务之前,必须清空整个微任务队列

┌──────────────────────────────────────────────┐
│             执行一个宏任务                      │
│  (比如 setTimeout 的回调)                      │
└─────────────────┬──────────────────────────┘
                   │ 该任务执行完毕
┌─────────────────▼──────────────────────────┐
│            清空微任务队列                      │
│   (Promise.then, queueMicrotask...)         │
│   只要微任务队列非空就一直执行直到空             │
└─────────────────┬──────────────────────────┘
                   │ 微任务队列空了
┌─────────────────▼──────────────────────────┐
│           尝试渲染(可能发生)                  │
└─────────────────┬──────────────────────────┘
                   │
         ┌─────────┴──────────┐
         │                    │
    还有宏任务?              没有宏任务?
         │                    │
         ▼                    ▼
    取下一个宏任务          等待新任务

一个经常被面试题"用烂"的经典例子

console.log('1');

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

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

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

console.log('6');

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

很多人死记硬背这个顺序,但其实理解了就很简单:

  1. 16 是同步代码,直接执行
  2. setTimeout 是宏任务 → 排队
  3. Promise.then 是微任务 → 排队
  4. 同步代码执行完毕,开始清空微任务队列:355 里的 setTimeout 又入宏任务队列)
  5. 微任务队列空了,取下一个宏任务:2
  6. 2 执行过程中,触发了新的 setTimeout → 入队
  7. 没有更多宏任务了,但 4 那个定时器还在路上……

React 18 的自动批量(Batching)

在 React 18 之前,批量更新(batching)是有条件的:只有在 React 的事件处理函数内才会批量更新。如果你写了一个 setTimeout,里面的 setState 每次都会单独触发一次渲染——这叫"不必要的重新渲染"。

// React 17 行为
setTimeout(() => {
  setCount(1);  // 触发一次渲染
  setFlag(true); // 再触发一次渲染 ❌ 浪费
}, 0);

// React 18 行为
setTimeout(() => {
  setCount(1);  // 排队
  setFlag(true); // 合并,最终只触发一次渲染 ✅
}, 0);

React 18 的 automatic batching 改变了这个游戏规则:无论在哪个上下文中(setTimeout、Promise、fetch 回调),多次 setState 都会被合并为一次渲染

事件循环与 React 渲染的交汇点

事件循环的"渲染时机"是一个很多人忽视的细节。事件循环执行完微任务后,可能会触发一次渲染——但不是每次都会。

决定是否渲染的因素包括:

  • 是否有待提交的 DOM 更新
  • 浏览器的渲染时机(通常 60fps,即每 16.67ms 一次)
  • requestAnimationFrame 的调度

React 的 scheduler 模块正是在这个时间缝隙里工作的。它判断主线程是否足够空闲,以便继续执行 React 的工作单元(fiber)。

本节小结

事件循环是 JavaScript 世界的基础设施。理解它的循环模型、两个队列的优先级,对理解 React 的调度机制至关重要。

当你下次在 DevTools 里看到"长任务(Long Task)"警告时,你会知道:这是某个宏任务执行时间太长,堵住了事件循环,让页面无法渲染。

2_1080468923_184_97_3_1098378589_66a27b56910902a8e766d2b25dd3b2db.png