从 Event Loop 到 React 调度机制
Javascript
#Javascript#React
要真正理解 React(以及任何现代 JavaScript 框架)的运行机制,事件循环(Event Loop) 是绕不开的一层“底座知识”。
JavaScript 是单线程的:同一时间只能执行一段代码。为了避免网络请求、定时器等耗时操作阻塞页面,浏览器引入了异步非阻塞模型,而这个模型的核心正是 —— 事件循环 + 队列。
本文将从底层执行模型出发,用 ASCII 图 + 严格流程 拆解 JavaScript 的完整执行顺序,并最终解释 React(尤其是
useEffect / useEffectEvent)背后的设计逻辑。一、事件循环的整体架构(The Big Picture)
在浏览器环境中,JavaScript 的执行并不只发生在一个地方,而是由以下四个角色协作完成:
- Call Stack(调用栈):主线程,执行所有同步代码(LIFO)。
- Web APIs / C++ Bindings:浏览器提供的能力(定时器、Fetch、DOM 事件等),在后台运行,不阻塞主线程。
- Queues(任务队列):存放“未来要执行”的回调函数。
- Event Loop(事件循环):调度器,决定下一步该执行谁。
架构示意图
┌───────────────────────────┐ ┌───────────────────────────┐ │ JavaScript Engine (V8) │ │ Browser Web APIs │ │ │ │ │ │ ┌─────────────────────┐ │ │ ┌──────────┐ ┌────────┐ │ │ │ Call Stack │ │ │ │DOM Events│ │ Fetch │ │ │ │ (Main Thread/LIFO) │ │ │ └──────────┘ └────────┘ │ │ │ [ Sync Code Exec ] │◀─┼─────┼──│ setTimeout / Interval │ │ │ └──────────┬──────────┘ │ │ └──────────────┬─────────┘ │ └─────────────┼─────────────┘ └─────────────────┼────────────┘ │ │ (Complete) ▼ ▼ ┌──────────────────┐ ┌─────────────────────────┐ │ Event Loop │ ◀──────────── │ Task Queues │ └─────────┬────────┘ └─────────────────────────┘ │ ▼
二、三类关键队列与执行优先级
并不是所有异步任务都“生而平等”。在浏览器中,至少存在三种优先级完全不同的队列。
1️⃣ 微任务队列(Microtask Queue)—— VIP 通道
- 优先级:最高
- 规则:
- 主线程一空,必须清空整个微任务队列
- 执行微任务时新产生的微任务,会立即插队继续执行
- 典型成员:
Promise.then / catch / finallyqueueMicrotaskMutationObserver
- 风险:无限微任务 = 页面直接卡死
- React 关联:状态更新批处理、调度优化的关键基础
2️⃣ 动画帧队列(Animation Frame Queue)—— 渲染前的最后机会
- 优先级:中等(仅发生在浏览器准备渲染时)
- 特性:
- 每一帧(约 16.6ms)最多执行一次
- 执行时机在 Paint 之前
- 典型成员:
requestAnimationFrame (rAF)
3️⃣ 宏任务队列(Macrotask Queue)—— 普通通道
- 优先级:最低
- 规则:
- 每一次事件循环 只执行一个宏任务
- 典型成员:
setTimeout / setInterval- DOM 事件回调(click / scroll)
- I/O、
MessageChannel
三、事件循环的完整执行流程
事件循环并不是简单的“轮询”,而是一个严格的阶段式流程。
START │ ▼ ┌───────────────────┐ │ 1. Run Script │ 同步代码执行 └────────┬──────────┘ │ ▼ ┌───────────────────┐ │ 2. Microtasks │ 清空所有微任务(包括新增的) └────────┬──────────┘ │ ▼ ┌───────────────────┐ │ 3. Need Render? │ 是否达到刷新节奏(≈60Hz) └────────┬──────────┘ Yes │ No ▼ │ ┌───────────────────┐ │ 4. rAF Callbacks │ requestAnimationFrame └────────┬──────────┘ │ ┌───▼───┐ │ Paint │ 样式 / 布局 / 绘制 └───┬───┘ │ ▼ ┌───────────────────┐ │ 5. Macrotask │ 只执行一个宏任务 └────────┬──────────┘ │ └──── Loop Back
一句话总结:
微任务不清空,浏览器绝不渲染;宏任务一次只跑一个。
四、代码实战:一次看懂所有队列
console.log('1. Script Start'); setTimeout(() => { console.log('2. setTimeout (Macro)'); }, 0); requestAnimationFrame(() => { console.log('3. rAF (Animation)'); }); Promise.resolve() .then(() => { console.log('4. Promise (Micro 1)'); }) .then(() => { console.log('5. Promise (Micro 2)'); }); console.log('6. Script End');
执行顺序解析
- 同步阶段:
1 → 6
- 微任务阶段:
4 → 5
- 渲染阶段:
3(如果本帧需要渲染)
- 宏任务阶段:
2
最终输出顺序:
1 → 6 → 4 → 5 → 3 → 2
注:rAF 与 setTimeout 的先后可能受刷新节奏影响,但 微任务永远最先执行。
五、React 为什么要这样设计?
理解事件循环后,React 的行为会变得非常“合理”。
React 各阶段与事件循环的映射
- Render 阶段:
- 同步执行
- 构建 Virtual DOM
- Commit 阶段:
- 同步更新真实 DOM
- 状态更新调度:
- 借助 微任务 实现批处理
- useEffect:
- 发生在渲染完成之后
- 不阻塞 Paint,避免卡顿
useEffectEvent 的“穿透”原理
useEffectEvent 能拿到最新状态,并不是魔法,而是调度时机的必然结果:- Effect 执行时:
- 渲染早已完成
- 微任务队列已清空
- React 内部的
ref.current已指向最新闭包
因此你读到的永远是尘埃落定之后的最终状态。
六、总结速查表
队列 | 英文 | 代表 | 执行时机 | 备注 |
调用栈 | Call Stack | 同步代码 | 立即执行 | 阻塞一切 |
微任务 | Microtask | Promise | 栈清空后、渲染前 | ⚠️ 可能卡死 |
动画帧 | rAF | requestAnimationFrame | Paint 之前 | 每帧一次 |
宏任务 | Macrotask | setTimeout | 渲染之后 | 一次一个 |
如果你能真正理解这一套模型,那么 React 的调度、批处理、并发渲染 都不再是“黑盒”,而只是事件循环上的一层精妙工程设计。