剖析 React 任务调度机制:scheduleCallback 实现原理

2,565 阅读15分钟

本文对应的 react 版本是 18.2.0

unstable_scheduleCallback 这个函数是 react 任务调度核心函数,主要作用是根据任务的优先级进行任务调度

它接收一个优先级(priorityLevel)、一个回调函数(callback)和一个延迟时间({delay: number})作为参数,返回一个任务对象

这个函数主要做了三件事:

  1. 设置任务开始时间
  2. 设置任务过期时间
    • 构建任务队列
  3. 根据任务优先级,调度任务
    • 延时任务和非延时任务调度

这个函数的核心内容是任务队列构建和任务调度,它们分别在【构建任务队列】和【延时任务调度和非延时任务调度】中介绍

设置任务开始时间

通过 getCurrentTime() 获取当前时间,然后加上延迟时间(如果有),得到任务开始时间

延迟时间是从外面传递进来的,可能与 startTransition 有关,它可以在不阻塞主线程的情况下执行一些低优先级的更新

源码:

var currentTime = getCurrentTime();

var startTime;
if (typeof options === "object" && options !== null) {
  var delay = options.delay;
  if (typeof delay === "number" && delay > 0) {
    startTime = currentTime + delay;
  } else {
    startTime = currentTime;
  }
} else {
  startTime = currentTime;
}

设置任务过期时间

任务过期时间:startTime + timeout

timeout 的值取决于任务优先级(priorityLevel),它们的对应关系如下:

  • ImmediatePriority:-1 (立即执行)
  • UserBlockingPriority:250 (250ms 后执行)
  • NormalPriority:5000 (5s 后执行)
  • LowPriority:10000 (10s 后执行)
  • IdlePriority:1073741823 (1073741.823s 后执行)

react 这么设计是因为 expirationTime 的值越小,表示任务越紧急,就需要优先执行

这里有一点需要注意:

  • IdlePriority 这个优先级的过期时间大概在 12 天,这意味着几乎不会过期,通常用于一些不紧急的后台任务,比如数据预加载、用户不可见的更新等
  • 这个优先级的任务会被延迟到其他更重要的任务都完成后再执行

源码:

var timeout;
switch (priorityLevel) {
  case ImmediatePriority:
    timeout = IMMEDIATE_PRIORITY_TIMEOUT;
    break;
  case UserBlockingPriority:
    timeout = USER_BLOCKING_PRIORITY_TIMEOUT;
    break;
  case IdlePriority:
    timeout = IDLE_PRIORITY_TIMEOUT;
    break;
  case LowPriority:
    timeout = LOW_PRIORITY_TIMEOUT;
    break;
  case NormalPriority:
  default:
    timeout = NORMAL_PRIORITY_TIMEOUT;
    break;
}

var expirationTime = startTime + timeout;

根据任务优先级,调度任务

新建任务

task 是一个任务,有以下几个属性:

type Task = {
  id: number;
  callback: Callback | null;
  priorityLevel: PriorityLevel;
  startTime: number;
  expirationTime: number;
  sortIndex: number;
  isQueued?: boolean;
};

这些属性的具体作用:

  • id: 使用的是一个全局变量 taskIdCounter,每次创建任务的时候都会自动加一,用于区分不同的任务
  • callback:一个函数,表示要执行的任务,它会在调度器安排好时间后被调用
    • 这个取决于你在里面做什么,比如更新组件的状态、渲染组件、或者执行其他逻辑
  • priorityLevel:优先级,会影响任务何时被执行,有五种:ImmediatePriority、UserBlockingPriority、IdlePriority、LowPriority 和 NormalPriority
  • startTime:任务开始时间,它是使用 getCurrentTime 函数获取的微秒级别的当前时间
  • expirationTime:任务过期的时间,它是根据 startTime 和 priorityLevel 计算出来的。过期时间越小,表示任务越紧急,越需要优先执行
  • sortIndex:任务在队列中的排序索引,默认为 -1。它实际上会被设置成 startTime 或 expirationTime,以便在插入队列时按照升序排列
    • 保证过期时间越小,越紧急的任务排在最前面
  • isQueued:任务是否已经被加入到队列中。这个属性是为了避免重复插入或删除任务而设置的
    • 在性能分析模式下,会加入这个属性,目前这个值没有启用

如何构建任务队列(见【构建任务队列】)

任务调度

根据任务开始时间(startTime)和当前时间(currentTime)来判断是否是延迟任务

  • 如果是,就把任务加入到 timerQueue 中,并根据任务的开始时间,安排一个定时器(requestHostTimeout)来执行任务;
  • 如果不是,就把任务加入到 taskQueue 中,并根据任务的过期时间,安排一个回调函数(requestHostCallback)来执行任务

timerQueue:用来存放延迟任务,也就是那些还没有到达执行时间的任务。它们会在定时器触发后被执行

taskQueue:用来存放非延迟任务,也就是那些可以立即执行的任务。它们会在浏览器空闲时被执行

如果任务的 startTime 大于 currentTime,说明任务还没有到达执行的时间,需要等待一段时间(因为立即执行的任务开始 timeout 是 -1)

执行过程:

  1. 对于延迟任务,排序索引是开始时间(升序排序,索引:newTask.sortIndex = startTime)

    1. 把延迟任务放到 timerQueue 队列中(push(timerQueue, newTask))
    2. 判定当前是否有其他的非延迟任务(peek(taskQueue) === null)或者更早的延迟任务(newTask === peek(timeQueue))
      • peek(taskQueue) === null 说明非延迟任务列表没有任务
      • newTask === peek(timeQueue) 说明刚加入的任务就是最紧急的任务
      • 是否已经安排了一个定时
        • 是:取消定时器
        • 不是:标记已经安排了一个定时器
        • 为什么要这么做:说明当前的任务比之前的任务紧急
      • requestHostTimeout(handleTimeout, startTime - currentTime) 是设置一个定时器,在 startTime - currentTime 这么长的时间后执行 handleTimeout 这个函数,它会从 timerQueue 中取出最紧急的延迟任务并执行它。
  2. 对于非延迟任务,排序索引是过期时间(升序排列,排序索引:newTask.sortIndex = expirationTime)

    1. 把非延迟任务放到 taskQueue 队列中(push(taskQueue, newTask))
    2. 判断当前是否已经安排了一个回调函数(!isHostCallbackScheduled)或者正在执行工作(!isPerformingWork)
      • requestHostCallback(flushWork) 安排一个回调函数,在浏览器空闲时执行 flushWork 这个函数,它会从 taskQueue 中取出最紧急的非延迟任务并执行它。

任务调度的具体过程(见【延时任务调度和非延时任务调度】)

源码简化:

if (startTime > currentTime) {
  newTask.sortIndex = startTime;
  push(timerQueue, newTask);
  if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
    if (isHostTimeoutScheduled) {
      cancelHostTimeout();
    } else {
      isHostTimeoutScheduled = true;
    }
    requestHostTimeout(handleTimeout, startTime - currentTime);
  }
} else {
  newTask.sortIndex = expirationTime;
  push(taskQueue, newTask);
  if (!isHostCallbackScheduled && !isPerformingWork) {
    isHostCallbackScheduled = true;
    requestHostCallback(flushWork);
  }
}

构建任务队列

构建任务队列时,根据任务的 sortIndex 优先级,安排任务的执行顺序

  • timerQueue:sortIndex 是 startTime
  • taskQueue:sortIndex 是 expirationTime

timerQueue 和 taskQueue 队列是个最小堆,执行时每次取出堆顶任务

如何保证元素添加到堆中后,能够快速放到合适的位置呢?

构建最小堆

一个元素的父节点的索引,就是那个元素的索引除以 2,向下取整

由于这里使用数组存放二叉堆,所以元素索引的方式:(index - 1) >>> 1

  • 无符号右移,返回值一定是一个非负数
  • index - 1 的作用是对应数组的索引,因为数组的索引是从 0 开始的

遍历条件通过判断父元素索引是否大于 0 来实现最少遍历次数

比如说当前的索引是 12,要在 12 后面插入一个新的元素:

  • 11 -> 5
  • 5 -> 2
  • 2 -> 1

通过三次遍历就能找到将一个元素放合适位置了

function push<T: Node>(heap: Heap<T>, node: T): void {
  const index = heap.length;
  heap.push(node);
  siftUp(heap, node, index);
}
function siftUp<T: Node>(heap: Heap<T>, node: T, i: number): void {
  let index = i;
  while (index > 0) {
    const parentIndex = (index - 1) >>> 1;
    const parent = heap[parentIndex];
    if (compare(parent, node) > 0) {
      heap[parentIndex] = node;
      heap[index] = parent;
      index = parentIndex;
    } else {
      return;
    }
  }
}

从堆顶取出元素

react 从堆中取去一个元素有两种方法:

  • peek
  • pop

这两个函数的区别是:

  • peek 方法只是取出堆顶的元素,不会删除堆顶的元素
  • pop 方法会删除堆顶的元素,并且重新安排堆的结构(最小堆)

这里主要看 pop 是如何实现的

删除堆顶元素后,pop 将最后一个元素放到堆顶,然后调用 siftDown 方法,重新安排最小堆

从根元素开始,比较根元素和左右子元素的大小,如果根元素比左右子元素都大,那么就交换根元素和左右子元素中较小的那个元素的位置

一个元素的子节点的索引:

  • left = index * 2
  • right = left + 1

因为这里采用的是用数组存放二叉堆,所以元素索引的方式:

  • left = (index + 1) * 2 - 1
  • right = left + 1

这里为什么用 length >>> 1,是因为非叶子节点的个数恰好等于 length / 2 向下取整

遍历条件通过判断父元素索引是否小于 length / 2 来实现最少遍历次数

比如说当前的 length 是 13,要取出堆顶元素

  • index = 0, length = 12, halfLength = 6
    • leftIndex = 1, rightIndex = 2
    • 如果 left 比 node 小, index = 1
  • index = 1, length = 12, halfLength = 6
    • leftIndex = 3, rightIndex = 4
    • 如果 left 比 node 小, index = 3
  • index = 3, left = 12, halfLength = 6
    • leftIndex = 7, rightIndex = 8
    • 如果 left 比 node 小, index = 7
  • index = 7, left = 24, halfLength = 6 ,循环结束

通过三次遍历就能找到将一个元素放合适位置了

function pop<T: Node>(heap: Heap<T>): T | null {
  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 siftDown<T: Node>(heap: Heap<T>, node: T, i: number): void {
  let index = i;
  const length = heap.length;
  const halfLength = length >>> 1;
  while (index < halfLength) {
    const leftIndex = (index + 1) * 2 - 1;
    const left = heap[leftIndex];
    const rightIndex = leftIndex + 1;
    const right = heap[rightIndex];

    if (compare(left, node) < 0) {
      if (rightIndex < length && compare(right, left) < 0) {
        heap[index] = right;
        heap[rightIndex] = node;
        index = rightIndex;
      } else {
        heap[index] = left;
        heap[leftIndex] = node;
        index = leftIndex;
      }
    } else if (rightIndex < length && compare(right, node) < 0) {
      heap[index] = right;
      heap[rightIndex] = node;
      index = rightIndex;
    } else {
      return;
    }
  }
}

compare 函数

compare 函数的实现如下:

  • sortIndex 是任务的优先级,id 是任务的唯一标识
  • 优先级相同的情况下,再比较 id

compare(a, b) > 0 说明 a 任务的优先级更高,b 任务的优先级更低,反之亦然。

function compare(a: Node, b: Node) {
  const diff = a.sortIndex - b.sortIndex;
  return diff !== 0 ? diff : a.id - b.id;
}

小技巧

用数组存储最小堆,一共需要遍历多少次就可以找到目标元素:通过 Math.floor(Math.log2(length - 1)) 计算

延时任务调度和非延时任务调度

延时任务调度使用 requestHostTimeout,非延时任务调度使用 requestHostCallback

  • requestHostTimeout:设置了一个回调函数在指定的延迟时间后被调用,用于在一定的时间过去后执行回调函数
  • requestHostCallback:设置了一个回调函数在浏览器下一个帧被调用,用于在下一帧执行回调函数

requestHostTimeout

这函数是一个 setTimeout,在到达延时时间后执行 callback

源码简化:

const requestHostTimeout = (callback, ms) => {
  setTimeout(() => {
    callback(getCurrentTime());
  }, ms);
};

延时任务回调函数 handleTimeout

handleTimeout 是在 requestHostTimeout 执行是传入的回调函数

执行过程:

  1. 将延迟队列任务中过期任务放到任务列表中
  2. 如果任务列表中有任务,调用 requestHostCallback(flushWork) 执行任务
  3. 如果任务列表中没有任务,判断延迟队列中是否有任务,如果有,调用 requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime) 设置延迟任务

总的来说延迟任务的执行逻辑就是:如果任务列表中有任务,执行任务,如果没有,判断延迟队列中是否有任务,如果有,设置延迟任务

简单说:将延时队列中的过期任务放到非延时队列中,然后调用 requestHostCallback 执行非延时任务

function handleTimeout(currentTime: number) {
  isHostTimeoutScheduled = false;
  advanceTimers(currentTime);

  if (!isHostCallbackScheduled) {
    if (peek(taskQueue) !== null) {
      isHostCallbackScheduled = true;
      requestHostCallback(flushWork);
    } else {
      const firstTimer = peek(timerQueue);
      if (firstTimer !== null) {
        requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
      }
    }
  }
}

requestHostCallback

执行过程:

  1. requestHostCallback 执行时,调用 schedulePerformWorkUntilDeadline
  2. 当 schedulePerformWorkUntilDeadline 被调用了,会执行 port2.postMessage(null)
  3. 当 port2.postMessage 执行时, port1.onmessage 将会被调用
  4. port1.onmessage 函数体是 performWorkUntilDeadline,所以 performWorkUntilDeadline 会被执行

react 为什么使用使用 MessageChannel 而不是 setTimeout(setTimeout 是个托底方案):

  1. 因为 setTimeout 是基于时间的,如果浏览器被挂起(例如,当用户切换到其他标签或最小化窗口时),setTimeout 也会被挂起,而 MessageChannel 不会
  2. 还有 setTimeout 的精度也不够,可能存在一定的误差
  3. 然后当有大量的任务需要执行时,setTimeout 会产生很多的定时器,从而影响性能

源码简化:

let scheduledHostCallback = null;
let schedulePerformWorkUntilDeadline;

const performWorkUntilDeadline = () => {
  if (scheduledHostCallback !== null) {
    const hasMoreWork = scheduledHostCallback();
    if (hasMoreWork) {
      schedulePerformWorkUntilDeadline();
    }
  }
};

const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
schedulePerformWorkUntilDeadline = () => {
  port.postMessage(null);
};

const requestHostCallback = (callback) => {
  scheduledHostCallback = callback;
  schedulePerformWorkUntilDeadline();
};

非延时任务回调函数 flushWork

调用 workLoop 函数,workLoop 函数会根据任务的优先级和过期时间,以及浏览器的空闲时间,决定是否继续执行下一个任务,还是暂停执行并交还控制权给浏览器

源码简化:

function flushWork(hasTimeRemaining: boolean, initialTime: number) {
  isHostCallbackScheduled = false;
  if (isHostTimeoutScheduled) {
    isHostTimeoutScheduled = false;
    cancelHostTimeout();
  }

  isPerformingWork = true;
  const previousPriorityLevel = currentPriorityLevel;
  try {
    return workLoop(hasTimeRemaining, initialTime);
  } finally {
    currentTask = null;
    currentPriorityLevel = previousPriorityLevel;
    isPerformingWork = false;
  }
}

wookLoop

wookLoop 函数在浏览器空闲时执行 taskQueue

执行逻辑:

  1. 如果当前任务的过期时间(expirationTime)大于当前时间(currentTime),并且没有剩余的时间(!hasTimeRemaining)或者应该让出控制权(shouldYieldToHost),就跳出循环(注释 ①)
  2. 如果任务没有过期,并且还有剩余时间(或者不需要让出控制权),它会执行当前任务的回调函数,并传入一个布尔值表示是否超时(注释 ②)
    • const continuationCallback = callback(didUserCallbackTimeout);
  3. 如果回调函数返回了一个新的函数,说明当前任务还没有完成,需要继续执行,那么它会把新的函数赋值给当前任务的回调,并返回 true 表示还有未完成的任务(注释 ③)
  4. 如果回调函数没有返回新的函数,说明当前任务已经完成,那么它会从任务队列中移除当前任务(注释 ④)
  5. 最后,重复上述步骤,直到任务队列为空或者遇到需要暂停或者让出的情况

源码简化:

function workLoop(hasTimeRemaining: boolean, initialTime: number) {
  let currentTime = initialTime;
  advanceTimers(currentTime);
  currentTask = peek(taskQueue);
  while (
    currentTask !== null &&
    !(enableSchedulerDebugging && isSchedulerPaused)
  ) {
    if (
      // ① 表示当前任务过期时间大于当前时间
      currentTask.expirationTime > currentTime &&
      // hasTimeRemaining 表示有没有剩余时间
      // shouldYidldToHost() 表示需要让出给主机
      (!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;
        advanceTimers(currentTime);
        return true;
      } else {
        // ④ 没返回函数,说明当前任务已经完成,从任务队列中移除
        if (currentTask === peek(taskQueue)) {
          pop(taskQueue);
        }
        advanceTimers(currentTime);
      }
    } else {
      // 如果任务没有回到函数,就从任务队列中移除
      pop(taskQueue);
    }
    currentTask = peek(taskQueue);
  }
}

判断 timerQueue 队列中是否有任务过期

timerQueue 队列是用来存放延时任务的

非延迟队列 taskQueue 的优先级比较高,如果一直在执行这个任务队列,可能会导致 timerQueue 中的任务过期

react 通过 advanceTimers 函数,将过期任务从 timerQueue 中取出,放入 taskQueue 中

执行逻辑:

  1. timer.callback === null 说明这个任务已经被取消了,就用 pop 函数把它从 timerQueue 中移除
  2. timer.startTime <= currentTime 说明这个任务已经可以执行了,就用 pop 函数把它从 timerQueue 中移除,并把它的 sortIndex 设置为 expirationTime,然后用 push 函数把它加入到 taskQueue 中
function advanceTimers(currentTime: number) {
  let timer = peek(timerQueue);
  while (timer !== null) {
    if (timer.callback === null) {
      pop(timerQueue);
    } else if (timer.startTime <= currentTime) {
      pop(timerQueue);
      timer.sortIndex = timer.expirationTime;
      push(taskQueue, timer);
    } else {
      return;
    }
    timer = peek(timerQueue);
  }
}

变量介绍

这些变量的作用是保证 react 在任务时不会出现逻辑错误,我们在学习源码的时候,其实不看这些变量,也不影响理解

不过呢,既然在学习 react 源码了,还是要了解下这些变量存在的意义:

  1. isHostCallbackScheduled
  2. isHostTimeoutScheduled
  3. isPerformingWork

这 3 个变量都是保证保证调度器的稳定和效率,避免不必要的重复或者中断

isSchedulerPaused 用于暂停下一个任务的调度

也就是说,一个任务如果执行了,是不会被中断或者暂停的

isHostCallbackScheduled

工作原理:

  1. 在调用 flushWork 函数之前,先把 isHostCallbackScheduled 置为 true,然后再 flushWork 调用时将 isHostCallbackScheduled 设置为 false
  2. 如果在 flushWork 函数执行过程中, requestHostCallback 函数被调用了,requestHostCallback 调用说明有新的任务被安排了,那么就需要检查当前是否有任务在执行中
  3. 如果有,就不会安排新的任务(超时函数触发、其他新的任务),而是等待当前任务执行完毕后再安排(等待 flushWork 执行完毕)

isHostTimeoutScheduled

用于标记是否已经设置了一个超时回调函数。如果为 true,表示调度器已经使用 setTimeout 函数安排了一个回调函数,如果为 false,表示调度器还没有安排回调函数

工作原理:

  1. 在调用 handleTimeout 函数之间,先把 isHostTimeoutScheduled 置为 true,然后再 handleTimeout 调用时将 isHostTimeoutScheduled 设置为 false
  2. 如果在 handleTimeout 函数执行过程中,有一个延时任务被安排了,就会调用 cancelHostTimeout 函数,取消当前的定时器,然后重新安排一个定时器,这样就保证了只有一个定时器在运行

isPerformingWork

在 flushWork 函数执行时,将会标记为 true,表示当前正在执行任务,保证在当前任务执行完成之前不会安排新的任务

在当前任务执行完之后,isPerformingWork 会被置为 false

总结

  1. 任务队列分为延时队列和非延时队列
  2. 延时队列和非延时队列都是最小堆(堆顶的任务优先级最高)
  3. 将延时队列中的过期任务放到非延时队列中,等待执行
    • 延时队列执行时,将过期任务放到非延时队列中
    • 非延时队列执行时,先检查有没过期的延时任务

往期文章

  1. 深入探究 React 原生事件的工作原理
  2. React Lane 算法:一文详解 8 种 Lane 操作

更多 react 源码文章