第 26 章

Scheduler:React 的并发调度系统

第26章:Scheduler:React 的并发调度系统

React 的并发模式能中断低优先级工作,根源不在 Fiber 本身,而在其上的调度层——Scheduler。

本章核心问题:React 为什么需要自己的调度器?五个优先级级别如何映射到 Lanes? 读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

为什么 React 需要自己的调度器

在实现 Scheduler 之前,React 团队考察了浏览器原生提供的两个调度 API:

requestAnimationFrame(rAF):rAF 回调在每帧绘制前执行,理论上可以利用它来做渲染调度。但 rAF 的问题是:它的执行时机完全由浏览器决定,不能保证回调有足够的执行时间;而且当标签页不可见时,rAF 会停止调用,这意味着后台数据处理也会停止,不符合 React 的需求。

requestIdleCallback(rIC):rIC 在主线程空闲时执行,看起来完美匹配"利用空闲时间做 React 渲染"的需求。但 rIC 有两个致命问题:首先,它的浏览器支持不完整(Safari 直到最近才支持);其次,它的调用频率太低——rIC 的最小超时时间是 1 秒,对于流畅的交互来说太长了。

React 团队最终选择自己实现调度器。核心思路是:利用 MessageChannel 创建宏任务,在每个宏任务中执行一段时间的工作,然后让出控制权给浏览器处理事件和渲染,再通过下一个宏任务继续工作

五个优先级级别

Scheduler 定义了五个优先级常量,每个优先级对应不同的过期时间(超过过期时间的任务会被强制执行,不再等待空闲时机):

// packages/scheduler/src/SchedulerPriorities.js
export const NoPriority = 0;
export const ImmediatePriority = 1;    // 同步优先级,过期时间:-1(立即过期)
export const UserBlockingPriority = 2; // 用户交互优先级,过期时间:250ms
export const NormalPriority = 3;       // 普通优先级,过期时间:5000ms
export const LowPriority = 4;          // 低优先级,过期时间:10000ms
export const IdlePriority = 5;         // 空闲优先级,过期时间:永不过期

// packages/scheduler/src/SchedulerFeatureFlags.js
const IMMEDIATE_PRIORITY_TIMEOUT = -1;
const USER_BLOCKING_PRIORITY_TIMEOUT = 250;
const NORMAL_PRIORITY_TIMEOUT = 5000;
const LOW_PRIORITY_TIMEOUT = 10000;
const IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt; // 永不过期

这些优先级映射到 React 层面的 Lanes 模型,进而影响哪些更新先执行:

workLoop:调度器的核心循环

// packages/scheduler/src/forks/Scheduler.js(简化)
function workLoop(hasTimeRemaining, initialTime) {
  let currentTime = initialTime;
  
  // 将 timerQueue 中已到执行时间的任务移入 taskQueue
  advanceTimers(currentTime);
  
  currentTask = peek(taskQueue);
  
  while (currentTask !== null && !enableSchedulerDebugging) {
    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') {
        // 任务没执行完,返回了一个延续函数
        // 将延续函数设置为同一任务的新 callback
        currentTask.callback = continuationCallback;
      } else {
        if (currentTask === peek(taskQueue)) {
          pop(taskQueue);
        }
      }
      
      advanceTimers(currentTime);
    } else {
      pop(taskQueue);
    }
    
    currentTask = peek(taskQueue);
  }
  
  if (currentTask !== null) {
    return true; // 还有未完成的任务
  } else {
    // taskQueue 空了,检查 timerQueue
    const firstTimer = peek(timerQueue);
    if (firstTimer !== null) {
      requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
    }
    return false;
  }
}

Level 2 · 它是怎么运行的(3-5年经验)

小顶堆:任务队列的实现

Scheduler 内部维护两个小顶堆(Min Heap):

小顶堆可以在 O(log n) 时间内完成任务的插入和取出最小值操作,非常适合优先级调度场景:

// packages/scheduler/src/SchedulerMinHeap.js
export function push(heap, node) {
  const index = heap.length;
  heap.push(node);
  siftUp(heap, node, index);
}

export function peek(heap) {
  return heap.length === 0 ? null : heap[0];
}

export function pop(heap) {
  if (heap.length === 0) return null;
  const first = heap[0];
  const last = heap.pop();
  if (last !== first) {
    heap[0] = last;
    siftDown(heap, last, 0);
  }
  return first;
}

function siftUp(heap, node, i) {
  while (i > 0) {
    const parentIndex = (i - 1) >>> 1; // 右移1位相当于除以2取整
    const parent = heap[parentIndex];
    if (compare(parent, node) > 0) {
      // 父节点优先级低于当前节点,上移
      heap[parentIndex] = node;
      heap[i] = parent;
      i = parentIndex;
    } else {
      return;
    }
  }
}

function compare(a, b) {
  // 优先按 sortIndex(过期时间)排序,相同时按 id(创建顺序)排序
  const diff = a.sortIndex - b.sortIndex;
  return diff !== 0 ? diff : a.id - b.id;
}

任务对象的结构如下:

// scheduleCallback 创建的任务对象
var newTask = {
  id: taskIdCounter++,
  callback,              // 实际要执行的工作函数
  priorityLevel,
  startTime,             // 任务开始时间
  expirationTime,        // 过期时间(startTime + priorityTimeout)
  sortIndex: -1,         // 在堆中的排序键
};

MessageChannel:时间切片的核心机制

Scheduler 使用 MessageChannel 来创建宏任务,实现时间切片:

// packages/scheduler/src/forks/Scheduler.js(简化)
const channel = new MessageChannel();
const port = channel.port2;

// 每次发送消息,就会在下一个宏任务中执行 performWorkUntilDeadline
channel.port1.onmessage = performWorkUntilDeadline;

const schedulePerformWorkUntilDeadline = () => {
  port.postMessage(null);
};

为什么不用 setTimeout(fn, 0)?原因很关键:HTML 规范规定,嵌套超过 5 层的 setTimeout 最小延迟为 4ms。这 4ms 的强制延迟对于 60fps 的每帧 16.6ms 来说是巨大的浪费。而 MessageChannel 没有这个限制,可以实现真正的零延迟调度。

// packages/scheduler/src/forks/Scheduler.js
function performWorkUntilDeadline() {
  if (scheduledHostCallback !== null) {
    const currentTime = getCurrentTime();
    
    // 计算本次宏任务的截止时间
    // frameInterval 默认是 5ms(每帧的工作时间预算)
    deadline = currentTime + frameInterval;
    
    const hasMoreWork = scheduledHostCallback(true, currentTime);
    
    if (!hasMoreWork) {
      isMessageLoopRunning = false;
      scheduledHostCallback = null;
    } else {
      // 还有工作,继续发送消息触发下一个宏任务
      port.postMessage(null);
    }
  } else {
    isMessageLoopRunning = false;
  }
}

Level 3 · 规范怎么定义的(资深)

shouldYieldToHost:让出控制权的决策

shouldYield(在内部叫 shouldYieldToHost)是并发模式的关键判断函数:

// packages/scheduler/src/forks/Scheduler.js
function shouldYieldToHost() {
  const timeElapsed = getCurrentTime() - startTime;
  
  if (timeElapsed < frameInterval) {
    // 当前帧还有时间,继续执行
    return false;
  }
  
  // 超过时间预算,让出控制权
  // 注意:React 19 中这里还会检查 isInputPending(如果浏览器支持)
  if (enableIsInputPending) {
    if (needsPaint) {
      // 浏览器需要绘制,立即让出
      return true;
    }
    if (timeElapsed < continuousInputInterval) {
      // 时间不长,检查是否有输入事件待处理
      if (isInputPending !== null) {
        return isInputPending();
      }
    } else if (timeElapsed < maxInterval) {
      if (isInputPending !== null) {
        return isInputPending(continuousOptions);
      }
    } else {
      return true;
    }
  }
  
  return true;
}

isInputPending 是 Chrome 提供的实验性 API(navigator.scheduling.isInputPending()),允许 JavaScript 在不让出控制权的情况下查询是否有未处理的用户输入事件。当没有输入事件时,React 可以在一个宏任务中执行更多工作,进一步减少调度开销。

Lanes 模型:React 18/19 的优先级细化

React 18 引入了 Lanes 模型作为比 Scheduler 优先级更细粒度的内部优先级系统:

// packages/react-reconciler/src/ReactFiberLane.js(部分)
export const NoLanes = /*                        */ 0b0000000000000000000000000000000;
export const SyncLane = /*                       */ 0b0000000000000000000000000000001;
export const InputContinuousHydrationLane = /*   */ 0b0000000000000000000000000000010;
export const InputContinuousLane = /*            */ 0b0000000000000000000000000000100;
export const DefaultHydrationLane = /*           */ 0b0000000000000000000000000001000;
export const DefaultLane = /*                    */ 0b0000000000000000000000000010000;
export const TransitionHydrationLane = /*        */ 0b0000000000000000000000000100000;
export const TransitionLanes = /*               */ 0b0000000001111111111111111000000;
// ... 更多 lanes

Lanes 使用位掩码(bitmask)表示,这使得"合并多个优先级"、"检查某优先级是否在集合中"等操作可以用位运算高效完成:

// 合并两个 lane 集合
function mergeLanes(a, b) {
  return a | b;
}

// 检查 lanes 是否包含某个 subset
function isSubsetOfLanes(set, subset) {
  return (set & subset) === subset;
}

// 从集合中移除某些 lanes
function removeLanes(set, subset) {
  return set & ~subset;
}

React 19 在 Lanes 模型上进一步扩展,为 Actions(异步 transitions)引入了专用的 lanes,确保 useOptimistic 更新和服务器响应能够按正确的优先级顺序处理。

Scheduler 和 Lanes 共同构成了 React 并发的底层基础设施。前者管理"什么时候执行工作",后者管理"哪些工作应该优先执行"。掌握这两个系统,你就真正理解了 React 并发不是魔法,而是精心设计的工程实现。


Level 4 · 边界与陷阱(所有人)

生产环境常见问题

在实际项目中,本章涵盖的概念最常见的问题包括:

  1. 忽视边界条件:在正常路径下工作正常的代码,在异常路径(网络失败、用户快速操作、组件卸载)下可能产生 bug。始终考虑清理和取消逻辑。

  2. 过早优化:在没有测量的情况下添加优化代码,增加了复杂度但不一定提升性能。先用 Profiler 确认问题存在,再实施优化。

  3. 文档与实际行为的差异:React 的行为在不同版本间可能有微妙差异,尤其是并发模式相关的特性。始终以实际测试结果为准。

本章评分
4.6  / 5  (4 评分)

💬 留言讨论