React vs Vue 调度机制深度剖析:从源码到事件循环的完整解读
2025年12月1日 16:04
本文基于 React 18.2.0 和 Vue 3.2.45 源码分析,带你真正理解两大框架的调度设计差异。
📑 目录
- 事件循环:理解调度的基础
- Vue 3:Promise.then 微任务调度(源码解析)
- React 18:MessageChannel 宏任务调度(源码解析)
- 为什么不用 setTimeout?为什么不用 rAF?
- 时间切片的真正含义
- 可中断渲染的实现原理
- 两种设计的权衡与适用场景
- 总结
一、事件循环:理解调度的基础
在深入源码之前,必须先理解浏览器事件循环的运行机制:
┌─────────────────────────────────────────────────────────────┐
│ 一轮事件循环 │
├─────────────────────────────────────────────────────────────┤
│ ① 执行一个宏任务(Script / setTimeout / MessageChannel) │
│ ↓ │
│ ② 清空所有微任务(Promise.then / queueMicrotask) │
│ ↓ │
│ ③ 浏览器判断是否需要渲染 │
│ ├─ 是 → 执行 rAF → Layout → Paint → Composite │
│ └─ 否 → 跳过渲染 │
│ ↓ │
│ ④ 进入下一轮事件循环 │
└─────────────────────────────────────────────────────────────┘
🔑 关键结论
| 任务类型 | 执行时机 | 是否阻塞渲染 |
|---|---|---|
| 微任务 | 当前宏任务结束后立即执行 | ✅ 会阻塞(必须清空) |
| 宏任务 | 下一轮事件循环 | ❌ 不阻塞(之间有渲染机会) |
这个差异是 Vue 和 React 选择不同调度策略的根本原因。
二、Vue 3:Promise.then 微任务调度(源码解析)
2.1 nextTick 源码
// Vue 3.2.45 - packages/runtime-core/src/scheduler.ts
const resolvedPromise = Promise.resolve() as Promise<any>
let currentFlushPromise: Promise<void> | null = null
export function nextTick<T = void>(
this: T,
fn?: (this: T) => void
): Promise<void> {
const p = currentFlushPromise || resolvedPromise
return fn ? p.then(this ? fn.bind(this) : fn) : p
}
极简实现:nextTick 本质就是 Promise.resolve().then(fn)。
2.2 任务队列调度源码
// Vue 3.2.45 - packages/runtime-core/src/scheduler.ts
const queue: SchedulerJob[] = []
let isFlushing = false
let isFlushPending = false
export function queueJob(job: SchedulerJob) {
// 去重:同一个 job 不会重复入队
if (
!queue.length ||
!queue.includes(
job,
isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex
)
) {
if (job.id == null) {
queue.push(job)
} else {
// 按 id 排序插入(保证父组件先于子组件更新)
queue.splice(findInsertionIndex(job.id), 0, job)
}
queueFlush()
}
}
function queueFlush() {
if (!isFlushing && !isFlushPending) {
isFlushPending = true
// 🔥 核心:使用 Promise.then 调度
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}
2.3 任务执行源码
function flushJobs(seen?: CountMap) {
isFlushPending = false
isFlushing = true
// 按 id 排序,确保:
// 1. 父组件先于子组件更新
// 2. 父组件的 watch 先于子组件执行
queue.sort(comparator)
try {
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex]
if (job && job.active !== false) {
callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
}
}
} finally {
flushIndex = 0
queue.length = 0
isFlushing = false
currentFlushPromise = null
}
}
2.4 Vue 更新流程图
┌──────────────────────────────────────────────────────────────┐
│ 同步代码 │
│ │
│ data.value = 'new' ──→ 触发 setter │
│ ↓ │
│ queueJob(componentUpdateFn) │
│ ↓ │
│ queueFlush() │
│ ↓ │
│ Promise.resolve().then(flushJobs) │
│ │ │
└──────────────────────────────│───────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────────┐
│ 微任务阶段 │
│ │
│ flushJobs() 执行 │
│ ↓ │
│ queue.sort() ──→ 按 id 排序 │
│ ↓ │
│ 遍历执行所有 job ──→ patch DOM │
│ │
└──────────────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────────┐
│ 浏览器渲染 │
│ Layout → Paint → Composite │
└──────────────────────────────────────────────────────────────┘
2.5 Vue 选择微任务的原因
| 优势 | 说明 |
|---|---|
| 更新极快 | 数据变更后,在同一轮事件循环内完成 DOM 更新 |
| 批量优化 | 多次数据变更只触发一次 DOM 更新(去重 + 排序) |
| 实现简单 | 不需要复杂的调度器,代码量极少 |
| 符合直觉 | 同步代码执行完毕后,DOM 立即反映最新状态 |
Vue 的设计哲学:响应式系统 + 编译优化已经让 patch 开销足够小,不需要可中断渲染。
三、React 18:MessageChannel 宏任务调度(源码解析)
3.1 调度入口源码
// React 18.2.0 - packages/scheduler/src/forks/Scheduler.js
let schedulePerformWorkUntilDeadline;
if (typeof MessageChannel !== 'undefined') {
// 🔥 核心:使用 MessageChannel 调度宏任务
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};
} else {
// 降级方案:setTimeout
schedulePerformWorkUntilDeadline = () => {
setTimeout(performWorkUntilDeadline, 0);
};
}
注意:React 只用 MessageChannel,不依赖 requestAnimationFrame!
3.2 时间切片核心源码
// React 18.2.0 - packages/scheduler/src/forks/Scheduler.js
// 每个时间切片的默认时长:5ms
let frameInterval = 5;
const performWorkUntilDeadline = () => {
if (scheduledHostCallback !== null) {
const currentTime = getCurrentTime();
// 🔥 关键:计算本次切片的截止时间
startTime = currentTime;
const hasTimeRemaining = true;
let hasMoreWork = true;
try {
// 执行任务,返回是否还有剩余任务
hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
} finally {
if (hasMoreWork) {
// 🔥 还有任务,调度下一个宏任务继续执行
schedulePerformWorkUntilDeadline();
} else {
isMessageLoopRunning = false;
scheduledHostCallback = null;
}
}
} else {
isMessageLoopRunning = false;
}
};
3.3 任务循环与时间检查
// React 18.2.0 - packages/scheduler/src/forks/Scheduler.js
function workLoop(hasTimeRemaining, initialTime) {
let currentTime = initialTime;
advanceTimers(currentTime);
currentTask = peek(taskQueue);
while (
currentTask !== null &&
!(enableSchedulerDebugging && isSchedulerPaused)
) {
if (
currentTask.expirationTime > currentTime &&
// 🔥 关键:检查是否应该让出控制权
(!hasTimeRemaining || shouldYieldToHost())
) {
// 时间片用完,跳出循环,让出主线程
break;
}
const callback = currentTask.callback;
if (typeof callback === 'function') {
currentTask.callback = null;
currentPriorityLevel = currentTask.priorityLevel;
const didUserCallbackTimeout =
currentTask.expirationTime <= currentTime;
// 执行任务
const continuationCallback = callback(didUserCallbackTimeout);
currentTime = getCurrentTime();
if (typeof continuationCallback === 'function') {
// 任务未完成,保留继续执行
currentTask.callback = continuationCallback;
} else {
if (currentTask === peek(taskQueue)) {
pop(taskQueue);
}
}
advanceTimers(currentTime);
} else {
pop(taskQueue);
}
currentTask = peek(taskQueue);
}
// 返回是否还有剩余任务
return currentTask !== null;
}
3.4 让出控制权的判断逻辑
// React 18.2.0 - packages/scheduler/src/forks/Scheduler.js
function shouldYieldToHost() {
const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) {
// 时间片未用完,继续执行
return false;
}
// 🔥 时间片用完,应该让出控制权
// 让浏览器有机会渲染和响应用户输入
return true;
}
3.5 React 更新流程图
┌──────────────────────────────────────────────────────────────┐
│ 同步代码 │
│ │
│ setState({ count: 1 }) ──→ 创建 Update 对象 │
│ ↓ │
│ scheduleUpdateOnFiber() │
│ ↓ │
│ ensureRootIsScheduled() │
│ ↓ │
│ scheduleCallback(bindPerformWork) │
│ ↓ │
│ MessageChannel.postMessage() │
│ │ │
└──────────────────────────────────│───────────────────────────┘
↓
┌──────────────────────────────────────────────────────────────┐
│ 微任务阶段(如有) │
│ 清空微任务队列 │
└──────────────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────────┐
│ 🎨 浏览器渲染机会 │
│ 可能发生:Layout / Paint / 用户输入响应 │
└──────────────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────────┐
│ 下一个宏任务 │
│ │
│ performWorkUntilDeadline() │
│ ↓ │
│ workLoop() ──→ 执行 Fiber 任务 │
│ ↓ │
│ shouldYieldToHost() ──→ 检查是否需要让出 │
│ ├─ 是 → postMessage() 调度下一个切片 │
│ └─ 否 → 继续执行当前任务 │
│ │
└──────────────────────────────────────────────────────────────┘
↓
(循环直到完成)
↓
┌──────────────────────────────────────────────────────────────┐
│ Commit 阶段 │
│ 同步执行,不可中断 │
│ 真正修改 DOM │
└──────────────────────────────────────────────────────────────┘
四、为什么不用 setTimeout?为什么不用 rAF?
4.1 为什么不用 setTimeout(fn, 0)?
// React 源码注释 - packages/scheduler/src/forks/Scheduler.js
// setTimeout 的问题:
// 1. 浏览器规范规定嵌套调用超过 5 次后,最小延迟为 4ms
// 2. 这会导致调度延迟累积,影响性能
// 实验证明:
setTimeout(() => {
setTimeout(() => {
setTimeout(() => {
setTimeout(() => {
setTimeout(() => {
// 这里开始,每次调用至少延迟 4ms
console.log('delay >= 4ms');
}, 0);
}, 0);
}, 0);
}, 0);
}, 0);
MessageChannel 的优势:没有 4ms 的最小延迟限制,可以更精确地控制时间切片。
4.2 为什么不用 requestAnimationFrame?
// 很多文章误以为 React 使用 rAF,实际上:
// ❌ 不使用 rAF 的原因:
// 1. rAF 受浏览器刷新率限制(通常 16.67ms 一次)
// 2. 后台标签页中 rAF 会暂停
// 3. rAF 的触发时机不稳定
// ✅ MessageChannel 的优势:
// 1. 不受帧率限制
// 2. 后台标签页仍然工作
// 3. 调度时机更可控
4.3 源码中的降级策略
// React 18.2.0 - packages/scheduler/src/forks/Scheduler.js
let schedulePerformWorkUntilDeadline;
if (typeof MessageChannel !== 'undefined') {
// 首选:MessageChannel
const channel = new MessageChannel();
// ...
} else {
// 降级:setTimeout(仅在不支持 MessageChannel 的环境)
schedulePerformWorkUntilDeadline = () => {
setTimeout(performWorkUntilDeadline, 0);
};
}
五、时间切片的真正含义
5.1 误解澄清
很多文章展示这样的示意图:
[chunk1] — 渲染 — [chunk2] — 渲染 — [chunk3] — 渲染 — ...
这是不准确的!正确的理解是:
[chunk1] — 渲染机会 — [chunk2] — 渲染机会 — [chunk3] — 渲染机会 — ...
↑ ↑ ↑
可能渲染 可能渲染 可能渲染
可能响应输入 可能响应输入 可能响应输入
可能什么都不做 可能什么都不做 可能什么都不做
5.2 浏览器决定是否渲染
浏览器在每个宏任务之间会判断是否需要渲染:
- 有 DOM 变更需要反映?→ 渲染
- 有动画帧需要更新?→ 渲染
- 没有视觉变化?→ 跳过渲染,直接执行下一个宏任务
5.3 时间切片的核心价值
| 价值 | 说明 |
|---|---|
| 响应用户输入 | 长任务期间,用户点击/输入可以被处理 |
| 保持动画流畅 | 动画帧可以在切片之间执行 |
| 避免页面卡死 | 复杂渲染不会阻塞整个页面 |
5.4 实验验证
// 验证微任务会阻塞渲染
function blockWithMicrotasks() {
let count = 0;
function task() {
const start = performance.now();
while (performance.now() - start < 10) {} // 阻塞 10ms
count++;
if (count < 100) {
Promise.resolve().then(task); // 微任务
}
}
Promise.resolve().then(task);
}
// 结果:页面完全卡住 ~1 秒
// 验证宏任务之间有渲染机会
function blockWithMacrotasks() {
const channel = new MessageChannel();
let count = 0;
channel.port1.onmessage = () => {
const start = performance.now();
while (performance.now() - start < 10) {} // 阻塞 10ms
count++;
if (count < 100) {
channel.port2.postMessage(null); // 宏任务
}
};
channel.port2.postMessage(null);
}
// 结果:页面仍然可以响应,动画流畅
5.5 🚨 常见误解澄清(很多前端不知道的真相)
这是事件循环中一个常见盲区——很多人背诵"宏任务→微任务→渲染",但错误地理解成"每次都渲染"。
误解 vs 真相
| 误解 | 真相 |
|---|---|
| "每个宏任务后都会渲染" | ❌ 浏览器按需渲染,可能连续执行多个宏任务后才渲染一次 |
| "60fps = 每 16.67ms 必渲染" | ❌ 没有视觉变化时浏览器会跳过,省电省资源 |
| "rAF 每 16.67ms 触发一次" | ❌ 后台标签页可能降到 1fps 甚至完全暂停 |
| "JS 可以强制浏览器渲染" | ❌ JS 只能"请求",浏览器自己决定何时渲染 |
浏览器的渲染决策流程
宏任务 A 完成
↓
微任务清空
↓
┌─────────────────────────────────────┐
│ 浏览器判断:需要渲染吗? │
│ │
│ ✅ 需要渲染的情况: │
│ • 有 DOM 变更待反映 │
│ • 有 CSS 动画/过渡在进行 │
│ • 有 rAF 回调等待执行 │
│ • 距离上次渲染已过 ~16.67ms │
│ │
│ ❌ 跳过渲染的情况: │
│ • 没有任何视觉变化 │
│ • 距离上次渲染太短(<16.67ms) │
│ • 页面在后台标签页 │
└─────────────────────────────────────┘
↓
宏任务 B 开始
关键点:这个决策完全由浏览器控制,JavaScript 无法干预。
实验证明
// 如果每个宏任务后都渲染,1000 个宏任务需要 16.67s
const channel = new MessageChannel();
let count = 0;
const start = performance.now();
channel.port1.onmessage = () => {
count++;
if (count < 1000) {
channel.port2.postMessage(null);
} else {
console.log(`完成:${performance.now() - start}ms`);
// 实际结果:~10-20ms
// 证明:绝大多数宏任务之间没有发生渲染
}
};
channel.port2.postMessage(null);
为什么这个知识点重要?
理解这点后,你会真正明白:
-
React 时间切片不是"让浏览器每次都渲染" → 而是"给浏览器机会做它想做的事"(渲染、响应输入、或什么都不做)
-
rAF 不是定时器 → 而是浏览器说"我决定要渲染了,你有啥要准备的吗?"
-
性能优化的本质 → 不是"让渲染更快",而是"别挡着浏览器的路"
六、可中断渲染的实现原理
6.1 Fiber 架构是基础
// React Fiber 节点结构(简化)
interface Fiber {
type: any;
child: Fiber | null; // 第一个子节点
sibling: Fiber | null; // 下一个兄弟节点
return: Fiber | null; // 父节点
// ...
}
// Fiber 树可以通过链表遍历,随时暂停和恢复
6.2 workLoopConcurrent 源码
// React 18.2.0 - packages/react-reconciler/src/ReactFiberWorkLoop.js
function workLoopConcurrent() {
// 🔥 关键:每处理一个 Fiber 节点,都检查是否需要让出
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
function performUnitOfWork(unitOfWork: Fiber): void {
const current = unitOfWork.alternate;
// beginWork: 处理当前节点
let next = beginWork(current, unitOfWork, renderLanes);
unitOfWork.memoizedProps = unitOfWork.pendingProps;
if (next === null) {
// 没有子节点,完成当前节点
completeUnitOfWork(unitOfWork);
} else {
// 有子节点,下次处理子节点
workInProgress = next;
}
}
6.3 中断与恢复的流程
开始渲染
↓
┌──────────────────────────────────────┐
│ workLoopConcurrent() │
│ │
│ while (workInProgress && !yield) { │
│ performUnitOfWork(fiber) │
│ } │
│ │
│ ← 时间片用完,shouldYield() = true │
└──────────────────────────────────────┘
↓
保存 workInProgress
↓
postMessage 调度下一个切片
↓
浏览器渲染/响应输入
↓
┌──────────────────────────────────────┐
│ 下一个切片继续从 workInProgress │
│ 继续遍历 Fiber 树 │
└──────────────────────────────────────┘
七、两种设计的权衡与适用场景
7.1 核心对比
| 维度 | Vue 3 (Promise.then) | React 18 (MessageChannel) |
|---|---|---|
| 调度类型 | 微任务 | 宏任务 |
| 更新延迟 | ~0ms(同一轮事件循环) | ~0-5ms(下一轮事件循环) |
| 可中断 | ❌ 不可中断 | ✅ 可中断 |
| 优先级调度 | ❌ 无 | ✅ 有(Lane 模型) |
| 时间切片 | ❌ 无 | ✅ 有(5ms 切片) |
| 实现复杂度 | 简单(~200 行) | 复杂(~1000+ 行) |
| 调试难度 | 低 | 高 |
7.2 适用场景
Vue 更适合:
- 中小型应用
- 表单/列表/CRUD 类应用
- 需要快速响应的交互
- 团队规模较小
React 更适合:
- 大型复杂应用
- 高频动画 + 复杂 UI 同时存在
- 需要 Suspense 数据加载
- 有并发渲染需求
7.3 Vue 为什么不需要时间切片?
Vue 的优化策略:
┌─────────────────────────────────────────┐
│ 编译时优化 │
│ • 静态节点提升 │
│ • patchFlag 标记动态节点 │
│ • 事件处理函数缓存 │
└─────────────────────────────────────────┘
↓ 结合
┌─────────────────────────────────────────┐
│ 响应式精确追踪 │
│ • 只更新依赖变化的组件 │
│ • 不需要 shouldComponentUpdate │
└─────────────────────────────────────────┘
↓ 结果
┌─────────────────────────────────────────┐
│ patch 开销极小 │
│ • 大多数情况下 < 1ms │
│ • 不需要可中断渲染 │
└─────────────────────────────────────────┘
八、总结
📊 一图总结
┌─────────────────────────────────────────────────────────────────┐
│ 事件循环与框架调度 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Vue 3 React 18 │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ setState │ │ setState │ │
│ └──────┬──────┘ └──────┬──────┘ │
│ ↓ ↓ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ queueJob │ │ schedule │ │
│ │ (去重/排序) │ │ Callback │ │
│ └──────┬──────┘ └──────┬──────┘ │
│ ↓ ↓ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Promise │ │ Message │ │
│ │ .then() │ │ Channel │ │
│ │ (微任务) │ │ (宏任务) │ │
│ └──────┬──────┘ └──────┬──────┘ │
│ ↓ ↓ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ flushJobs │ │ 浏览器渲染 │ ← 渲染机会! │
│ │ patch DOM │ │ 机会 │ │
│ └──────┬──────┘ └──────┬──────┘ │
│ ↓ ↓ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ 浏览器渲染 │ │ workLoop │ │
│ └─────────────┘ │ Fiber 处理 │ │
│ │ (可中断) │ │
│ └──────┬──────┘ │
│ ↓ │
│ ┌─────────────┐ │
│ │ 时间片用完? │ │
│ │ postMessage │ │
│ │ 继续调度 │ │
│ └─────────────┘ │
│ │
│ 特点:快速、简单、不可中断 特点:可中断、可调度、复杂 │
│ │
└─────────────────────────────────────────────────────────────────┘
🎯 核心结论
-
Vue 使用
Promise.then(微任务)- 更新极快,同一轮事件循环内完成
- 简单高效,编译优化 + 响应式让 patch 开销很小
- 不需要可中断,因为不需要
-
React 使用
MessageChannel(宏任务)- 宏任务之间有渲染机会,不阻塞页面
- 支持时间切片(5ms 一个切片)
- 支持可中断渲染(Fiber 架构)
- 不使用 rAF(常见误解!)
-
选择差异源于设计目标不同
- Vue:追求简单高效,响应式 + 编译优化解决性能问题
- React:追求可控调度,Fiber + Scheduler 解决复杂场景
-
没有绝对的优劣
- 小型应用:Vue 的同步更新更直观
- 大型应用:React 的调度能力更强
📚 参考资料
💡 作者注:本文所有源码引用基于 React 18.2.0 和 Vue 3.2.45,如有更新请以官方仓库为准。