2026年5月18日166约 3573 字12 分钟阅读

从 setTimeout 到 VSync:一篇讲透 Event Loop 的前世今生

setTimeout(fn, 0) 为什么不是立即执行?async 函数的执行顺序为什么和你想的不一样?React 为什么用 MessageChannel 而不是 setTimeout?从调用栈到 VSync,从浏览器到 Node.js,10 个可交互 Live Demo 帮你建立完整的 Event Loop 心智模型。

你写了一个 setTimeout(fn, 0),期望它"立即执行",结果它排在了 Promise.then 后面。你用 requestAnimationFrame 做动画,在 120Hz 屏上却比 60Hz 还卡。你觉得 async function 整个函数体都是异步的,直到有一天线上出了 bug。

这些问题的根源都是同一个东西——Event Loop

它不是一个 API,不是一个库,而是 JavaScript 运行时的调度心脏。你写的每一行代码何时执行、DOM 改动何时上屏、动画流不流畅,全部由它决定。

这篇文章从底层设计动机讲到硬件 VSync,从浏览器覆盖到 Node.js,从原理延伸到 React/Vue 框架源码。10 个可交互 Live Demo 让你亲手验证每一个结论。

一、为什么需要 Event Loop?

从一个问题开始

javascript
console.log('start');
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
console.log('end');
// start → end → promise → timeout

setTimeout(fn, 0) 明明写的是 0ms 延迟,为什么 Promise.then 比它先执行?这不是 bug,这是 Event Loop 的设计结果。

程序处理并发的三种方式

模型代表优势代价
多进程Apache prefork隔离性强,一个崩不影响其他内存开销大,进程间通信复杂
多线程Java Servlet共享内存,切换成本低于进程竞态条件、死锁、需要锁机制
单线程 + 事件驱动JavaScript、Nginx无锁,无竞态,逻辑简单任何代码都可能阻塞整个程序

JavaScript 为什么选择单线程?

不是因为"简单"或"落后"。DOM 是一棵共享可变状态树——如果两个线程同时操作同一个 DOM 节点,你需要加锁。一旦加锁,代码复杂度指数上升,而且锁竞争会抵消多线程的性能优势。1

单线程的代价很明确:任何一行代码都可以阻塞页面。Event Loop 就是在"单线程安全"和"不阻塞用户响应"之间找到的平衡解。

二、基础概念建立

在理解 Event Loop 之前,你需要知道 JavaScript 运行时的三个核心组件。

调用栈(Call Stack)

函数调用形成的 LIFO 栈。每次函数调用压入一帧,返回时弹出。调用栈清空意味着当前同步代码执行完毕。 了解更多

javascript
function a() { b(); }
function b() { c(); }
function c() { console.log('done'); }
a();
// 栈变化: [] → [a] → [a, b] → [a, b, c] → [a, b] → [a] → []

堆(Heap)

对象分配的内存区域。和 Event Loop 调度无关,但它是运行时的一部分。 了解更多

任务队列(Task Queue)

异步操作完成后,回调被放入队列等待执行。关键:队列里的回调不是立即执行的——它们必须等调用栈清空后,才会被 Event Loop 取出放到栈上执行。

Mermaid

Full view

同步 vs 异步

同步:代码按顺序执行,前一行没完后一行不会开始。异步:把回调注册到队列,当前代码继续往下走,回调等调用栈空了才执行。

阻塞 vs 非阻塞

阻塞:调用栈被一个长时间运行的任务占据,页面冻结。非阻塞:通过异步 API(setTimeout、fetch、事件监听)把耗时操作交给浏览器/OS,主线程继续响应用户交互。

三、浏览器中的 Event Loop(核心篇)

3.1 整体运行机制

Event Loop 是一个持续运行的循环。它的工作极其简单——不断地检查:调用栈空了吗?如果空了,从队列取任务放到栈上执行。

但"从哪个队列取"以及"取多少个"——这两个问题的答案,决定了 JavaScript 所有异步行为的执行顺序。

3.2 宏任务(Macrotask)

成员setTimeoutsetIntervalMessageChannel、I/O 回调、用户交互事件(click、keydown)、<script> 整体代码

调度策略:每轮只取一个。 执行完这个宏任务后,Event Loop 会先去清空微任务队列,然后可能渲染,再取下一个宏任务。这给了浏览器"喘气"的机会——如果宏任务也全部一次性清空,页面就会冻结。

3.3 微任务(Microtask)

成员Promise.then/catch/finallyasync/await 的后半段、queueMicrotask()MutationObserver

调度策略:一次性全部清空。 一个宏任务执行完后,Event Loop 会清空微任务队列里的所有任务。如果执行微任务的过程中又产生了新的微任务,也会在这轮全部清完。2

为什么微任务优先于宏任务? 设计动机是保证一批关联的异步操作能在渲染前完成。比如你在一个 Promise 链里连续修改 DOM 三次,你不希望中间被渲染打断导致用户看到中间状态。

WARNING

微任务的"全清空"策略意味着,如果你在微任务里无限创建新的微任务,浏览器永远不会渲染,页面直接卡死。while(true) 阻塞调用栈,无限微任务阻塞 Event Loop 循环——效果一样致命。

微任务 vs 宏任务的渲染差异

3.4 执行顺序完整规则

一轮 Event Loop 的完整流程:

text
同步代码(调用栈)→ 清空微任务队列 → [可能渲染] → 取下一个宏任务 → 清空微任务队列 → ...

注意"可能渲染"——浏览器并不是每轮都渲染。它会根据刷新率(通常 60Hz = 16.67ms 一帧)和页面可见性决定是否需要渲染。

3.5 渲染时机与 requestAnimationFrame

TIP

渲染发生在微任务清空之后、下一个宏任务之前。这意味着:同一个宏任务里对 DOM 的多次赋值,浏览器只会取最终值来渲染。

Mermaid

Full view

requestAnimationFrame 的身份

IMPORTANT

rAF 不是宏任务,也不是微任务。它是渲染流程的一部分,在 Style/Layout/Paint 之前触发。HTML 规范明确将它放在"更新渲染"步骤内。

rAF 的三个关键特性:

  1. 和刷新率同步:60Hz 屏幕上约 16.67ms 调用一次,120Hz 屏幕上约 8.33ms
  2. 页面不可见时暂停:切到其他 tab,rAF 停止调用,节省资源
  3. 同一帧内批量执行:本帧注册的所有 rAF 回调在渲染前全部执行

rAF vs setTimeout 执行顺序

VSync——帧率的物理基础

VSync(垂直同步信号)是显示器在每次刷新屏幕时发出的硬件信号。它是一切帧率的节拍器——你的代码不能推迟它,只能错过它3

双缓冲机制:显示器从"前缓冲"读取像素显示,GPU 把新帧画到"后缓冲"。VSync 信号到来时,前后缓冲交换。如果后缓冲还没画完新帧——就没有新帧可交换,用户看到的还是上一帧的内容,这就是掉帧4

刷新率帧预算实际可用(扣除浏览器开销)
60Hz16.67ms~10-12ms
120Hz8.33ms~5-6ms
144Hz6.94ms~4-5ms

WARNING

120Hz 屏幕的帧预算只有 60Hz 的一半。同样的代码在 60Hz 上流畅,到 120Hz 可能掉帧。高刷屏不是"白送的流畅"——它对你的代码要求更高。

Mermaid

Full view

rAF vs setTimeout 动画对比

帧率不稳定 vs 稳定的视觉差异

3.6 async/await 的"切刀"模型

每个 await 是一把刀,把 async 函数切成两半——前半段同步执行,后半段作为微任务入队。

javascript
async function demo() {
  console.log('A');  // 同步,立即执行
  await somePromise;
  console.log('B');  // 微任务,等同步代码跑完才执行
}

这不是比喻,这就是 V8 实际做的事:遇到 await,保存当前函数的执行上下文,把后半段包装成 Promise 的 .then 回调,让出控制权给调用者。

async/await 切刀模型

四、经典题目精讲

理论学完了,来验证你是否真正理解。以下 5 道题由浅入深,每道题都有"逐步执行"按钮,让你看到调用栈和队列的变化。

建议:先自己在脑中推演输出顺序,再点"显示答案"对比。

题目 1:setTimeout + Promise 基础顺序

题目 2:Promise 链的微任务累积

题目 3:async/await 执行顺序陷阱

题目 4:宏任务中嵌套微任务

题目 5:综合压轴

五、Node.js 中的 Event Loop

5.1 与浏览器的核心差异

浏览器的 Event Loop 由 HTML 规范定义,关注渲染和用户交互。Node.js 的 Event Loop 由 libuv 实现,关注 I/O 和系统调用。

最大的区别:Node.js 把一轮循环分成了 6 个明确的阶段,而浏览器只区分宏任务和微任务。

5.2 六大阶段详解

text
   ┌───────────────────────────┐
┌─>│        timers              │  ← setTimeout / setInterval 回调
│  └───────────┬───────────────┘
│  ┌───────────┴───────────────┐
│  │     pending callbacks      │  ← 系统级回调(TCP 错误等)
│  └───────────┬───────────────┘
│  ┌───────────┴───────────────┐
│  │       idle, prepare        │  ← 内部使用
│  └───────────┬───────────────┘
│  ┌───────────┴───────────────┐
│  │          poll              │  ← I/O 回调、等待新 I/O
│  └───────────┬───────────────┘
│  ┌───────────┴───────────────┐
│  │          check             │  ← setImmediate 回调
│  └───────────┬───────────────┘
│  ┌───────────┴───────────────┐
│  │      close callbacks       │  ← socket.on('close') 等
│  └───────────┬───────────────┘
└──────────────┘

poll 阶段是核心——它负责等待和处理 I/O。如果 poll 队列为空:

  • setImmediate 回调?进入 check 阶段
  • 有到期的 timer?回到 timers 阶段
  • 都没有?在 poll 阶段阻塞等待新的 I/O 事件

setImmediate vs setTimeout(fn, 0)

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

在主模块中执行,顺序不确定——取决于进程启动耗时是否超过 1ms(setTimeout 的最小延迟)。但在 I/O 回调中,setImmediate 一定先执行,因为 I/O 回调在 poll 阶段,下一个就是 check 阶段。

javascript
const fs = require('fs');
fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});
// 始终输出: immediate → timeout

5.3 Node.js 中的微任务

Node.js 有两种"微任务级"队列,优先级不同:

  1. process.nextTick — 最高优先级,在当前阶段结束后、任何其他微任务之前执行
  2. Promise.then — 在 nextTick 队列清空后执行
javascript
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
// 始终输出: nextTick → promise

Node.js 11 前后的行为变化:Node 11 之前,每个阶段的所有回调执行完才处理微任务;Node 11 之后,每个回调执行完就清空微任务——和浏览器行为对齐。

5.4 实际应用场景

为什么 Node.js 适合 I/O 密集型? 因为 Event Loop + 非阻塞 I/O 让单线程能同时处理上千个连接。每个 I/O 操作不阻塞主线程,完成后通过回调通知。这就是 Nginx 的架构思想。

为什么不适合 CPU 密集型? 因为 CPU 密集计算会阻塞 Event Loop,所有等待中的 I/O 回调都无法处理。解决方案是 worker_threads

六、Event Loop 与现代框架

6.1 React 调度系统(Scheduler)

React 15 的问题:setState 触发同步递归渲染,如果组件树很深,主线程被长时间占用,页面卡死。

React 16+ 的解法:Fiber 架构 + 时间切片。把渲染工作拆成小单元,每个单元执行完检查是否还有剩余帧预算。如果没了,把剩余工作交还给 Event Loop,让浏览器先处理用户交互和渲染。

为什么用 MessageChannel 而不是 setTimeout 因为 setTimeout(fn, 0) 有最小延迟 4ms(嵌套超过 5 层后)。在 16.67ms 的帧预算里,4ms 是巨大的浪费。MessageChannel 的回调在宏任务中触发,没有最小延迟限制。

javascript
const channel = new MessageChannel();
channel.port1.onmessage = () => {
  // React 在这里执行下一批渲染工作
  performWorkUntilDeadline();
};
// 调度下一批工作
channel.port2.postMessage(null);

6.2 Vue 的 nextTick

Vue.nextTick 让你在下一次 DOM 更新之后执行回调。它的实现本质就是把回调放入微任务队列。

javascript
this.message = 'updated';
this.$nextTick(() => {
  // DOM 已更新,可以读取新的 DOM 状态
  console.log(this.$el.textContent); // 'updated'
});

为什么能拿到更新后的 DOM? 因为 Vue 的 DOM 更新也是微任务(通过 Promise.then 触发)。nextTick 的回调排在 DOM 更新微任务之后,但在渲染之前——所以 DOM 已经改了,但还没绘制到屏幕上。

降级策略(Vue 2):Promise.thenMutationObserversetImmediatesetTimeout。优先微任务,不行就降级到宏任务。

6.3 其他场景

虚拟列表 / 大数据渲染分片:把 10000 条数据的渲染拆成多批,每批用 requestAnimationFramerequestIdleCallback 调度,避免长时间阻塞 Event Loop。

防抖节流debouncesetTimeout 延迟执行(宏任务);throttlerequestAnimationFrame 限制频率(和渲染同步)。选哪个取决于你要和渲染同步还是只要延迟。

七、常见误区

误区正确理解
setTimeout(fn, 0) 是立即执行最快也要等当前宏任务和全部微任务执行完。嵌套超过 5 层还有 4ms 最小延迟
Promise 是异步的new Promise(executor) 的 executor 是同步执行的,只有 .then 回调才是异步的
async 函数整体是异步的await 之前的代码和被 await 的函数调用都是同步执行
用了 rAF 动画就流畅了rAF 只保证回调和刷新率同步,帧预算是你自己的责任。回调里跑 20ms 的逻辑照样掉帧
rAF 是宏任务rAF 既不是宏任务也不是微任务,它是渲染流程的一部分
浏览器和 Node.js 的 Event Loop 一样Node.js 有 6 个明确阶段、process.nextTick 优先于 Promise、setImmediate 是独有的
掉帧是因为 VSync 信号被推迟了VSync 是硬件时钟,不会推迟。掉帧是因为你的代码没有在 VSync 到来前准备好新帧

八、性能实践指南

不要阻塞 Event Loop

javascript
// ❌ 同步处理大数组
const result = hugeArray.map(item => expensiveComputation(item));

// ✅ 分片处理
function processChunk(array, chunkSize, callback) {
  let index = 0;
  function next() {
    const chunk = array.slice(index, index + chunkSize);
    chunk.forEach(callback);
    index += chunkSize;
    if (index < array.length) {
      requestAnimationFrame(next);
    }
  }
  next();
}

善用微任务保证状态一致性

当你需要在当前同步代码之后、渲染之前做一些清理工作,queueMicrotask 是最精准的工具。

javascript
queueMicrotask(() => {
  // 保证在渲染前执行,但不阻塞当前同步代码
  cleanupState();
});

CPU 密集任务的解决方案

方案环境适用场景
Web Worker浏览器图片处理、加密、大量计算
worker_threadsNode.jsCPU 密集的服务端逻辑
child_processNode.js独立进程,完全隔离

requestIdleCallback——低优先级任务

requestIdleCallback 在浏览器空闲时执行,适合不紧急的工作:

javascript
requestIdleCallback((deadline) => {
  while (deadline.timeRemaining() > 0 && tasks.length > 0) {
    doTask(tasks.pop());
  }
  if (tasks.length > 0) {
    requestIdleCallback(doWork);
  }
});

九、总结

Event Loop 的三个核心理念

  1. 非阻塞:通过异步 API 和回调机制,单线程也能处理并发
  2. 事件驱动:所有异步操作完成后通过事件/回调通知,而不是轮询
  3. 协作式调度:代码必须主动让出控制权(return / await),Event Loop 才能调度下一个任务

浏览器 vs Node.js 对比

维度浏览器Node.js
实现HTML 规范libuv
阶段宏/微任务 + 渲染6 个明确阶段
渲染有(rAF + Paint)
特有 APIrAF, rICsetImmediate, nextTick
微任务优先级Promise 统一优先级nextTick > Promise
关注点用户交互 + 动画I/O + 系统调用

全局知识地图

Mindmap

Full view
Event Loop浏览器宏任务setTimeoutsetIntervalMessageChannel用户事件微任务Promise.thenqueueMicrotaskMutationObserverawait 后半段渲染流程rAF 回调Style/LayoutPaint/CompositeVSync 同步Node.js6 阶段timerspending callbackspoll 核心check setImmediateclose callbacks微任务process.nextTick 最高优先级Promise.then框架应用React SchedulerFiber 时间切片MessageChannelVue nextTick微任务优先降级策略性能帧预算60Hz 16.67ms120Hz 8.33ms优化手段Web Worker分片渲染requestIdleCallbackCSS transform

记住这几点就够了

  1. 微任务全清空,宏任务逐个取——这一条规则解释了 90% 的执行顺序问题
  2. 渲染在微任务之后、下一个宏任务之前——理解了这个就知道 DOM 操作为什么"不生效"
  3. await 是一把切刀——前半段同步,后半段变微任务
  4. rAF 不是任务——它是渲染流程的一部分,和 VSync 同步
  5. 帧预算是你的责任——120Hz 只给你 8ms,VSync 不会等你

Footnotes

  1. 这不仅是 JavaScript 的选择。Java Swing 的 EDT(Event Dispatch Thread)、Android 的主线程、iOS 的 RunLoop、Qt 都要求所有 UI 操作在主线程完成。单线程 UI 模型是跨平台的共识。

  2. queueMicrotask()Promise.resolve().then() 都产生微任务,但语义不同。queueMicrotask 是"我要在本轮微任务中做一些清理",Promise.resolve().then 是"我要在一个 Promise 决议后做一些事"。前者语义更纯粹,后者会创建一个不必要的 Promise 对象。

  3. 在游戏中,画面撕裂是常见问题——GPU 渲染帧率和显示器刷新率不同步,导致屏幕上半部分显示旧帧、下半部分显示新帧。浏览器通过双缓冲 + VSync 避免了这个问题,但代价是掉帧时你看到的是"卡顿"而不是"撕裂"。

  4. 现代 GPU 通常使用三缓冲(triple buffering)而不是双缓冲。三缓冲允许 GPU 在等待 VSync 时继续渲染到第三个缓冲,减少空等时间,但增加了一帧的输入延迟。