阅读视图

发现新文章,点击刷新页面。

React vs Vue 调度机制深度剖析:从源码到事件循环的完整解读

本文基于 React 18.2.0Vue 3.2.45 源码分析,带你真正理解两大框架的调度设计差异。


📑 目录

  1. 事件循环:理解调度的基础
  2. Vue 3:Promise.then 微任务调度(源码解析)
  3. React 18:MessageChannel 宏任务调度(源码解析)
  4. 为什么不用 setTimeout?为什么不用 rAF?
  5. 时间切片的真正含义
  6. 可中断渲染的实现原理
  7. 两种设计的权衡与适用场景
  8. 总结

一、事件循环:理解调度的基础

在深入源码之前,必须先理解浏览器事件循环的运行机制:

┌─────────────────────────────────────────────────────────────┐
│                      一轮事件循环                            │
├─────────────────────────────────────────────────────────────┤
│  ① 执行一个宏任务(Script / setTimeout / MessageChannel)   │
│     ↓                                                       │
│  ② 清空所有微任务(Promise.then / queueMicrotask)          │
│     ↓                                                       │
│  ③ 浏览器判断是否需要渲染                                   │
│     ├─ 是 → 执行 rAF → LayoutPaintComposite           │
│     └─ 否 → 跳过渲染                                        │
│     ↓                                                       │
│  ④ 进入下一轮事件循环                                       │
└─────────────────────────────────────────────────────────────┘

🔑 关键结论

任务类型 执行时机 是否阻塞渲染
微任务 当前宏任务结束后立即执行 ✅ 会阻塞(必须清空)
宏任务 下一轮事件循环 ❌ 不阻塞(之间有渲染机会)

这个差异是 Vue 和 React 选择不同调度策略的根本原因


二、Vue 3:Promise.then 微任务调度(源码解析)

2.1 nextTick 源码

📁 源码位置:packages/runtime-core/src/scheduler.ts

// 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 调度入口源码

📁 源码位置:packages/scheduler/src/forks/Scheduler.js

// 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);

为什么这个知识点重要?

理解这点后,你会真正明白:

  1. React 时间切片不是"让浏览器每次都渲染" → 而是"给浏览器机会做它想做的事"(渲染、响应输入、或什么都不做)

  2. rAF 不是定时器 → 而是浏览器说"我决定要渲染了,你有啥要准备的吗?"

  3. 性能优化的本质 → 不是"让渲染更快",而是"别挡着浏览器的路"


六、可中断渲染的实现原理

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 │                 │
│                                 │ 继续调度    │                 │
│                                 └─────────────┘                 │
│                                                                 │
│  特点:快速、简单、不可中断     特点:可中断、可调度、复杂       │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

🎯 核心结论

  1. Vue 使用 Promise.then(微任务)

    • 更新极快,同一轮事件循环内完成
    • 简单高效,编译优化 + 响应式让 patch 开销很小
    • 不需要可中断,因为不需要
  2. React 使用 MessageChannel(宏任务)

    • 宏任务之间有渲染机会,不阻塞页面
    • 支持时间切片(5ms 一个切片)
    • 支持可中断渲染(Fiber 架构)
    • 不使用 rAF(常见误解!)
  3. 选择差异源于设计目标不同

    • Vue:追求简单高效,响应式 + 编译优化解决性能问题
    • React:追求可控调度,Fiber + Scheduler 解决复杂场景
  4. 没有绝对的优劣

    • 小型应用:Vue 的同步更新更直观
    • 大型应用:React 的调度能力更强

📚 参考资料

  1. Vue 3 Scheduler 源码
  2. React Scheduler 源码
  3. HTML Living Standard - Event Loop
  4. MessageChannel MDN 文档

💡 作者注:本文所有源码引用基于 React 18.2.0 和 Vue 3.2.45,如有更新请以官方仓库为准。

❌