likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 14

本文对应的 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 (立即执行)
  • UserBlockingPriority250 (250ms 后执行)
  • NormalPriority5000 (5s 后执行)
  • LowPriority10000 (10s 后执行)
  • IdlePriority1073741823 (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:优先级,会影响任务何时被执行,有五种:ImmediatePriorityUserBlockingPriorityIdlePriorityLowPriorityNormalPriority
  • startTime:任务开始时间,它是使用 getCurrentTime 函数获取的微秒级别的当前时间
  • expirationTime:任务过期的时间,它是根据 startTimepriorityLevel 计算出来的。过期时间越小,表示任务越紧急,越需要优先执行
  • sortIndex:任务在队列中的排序索引,默认为 -1。它实际上会被设置成 startTimeexpirationTime,以便在插入队列时按照升序排列
    • 保证过期时间越小,越紧急的任务排在最前面
  • 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 优先级,安排任务的执行顺序

  • timerQueuesortIndexstartTime
  • taskQueuesortIndexexpirationTime

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

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

构建最小堆

一个元素的父节点的索引,就是那个元素的索引除以 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 来实现最少遍历次数

比如说当前的 length13,要取出堆顶元素

  • index = 0, length = 12, halfLength = 6
    • leftIndex = 1, rightIndex = 2
    • 如果 leftnode 小, index = 1
  • index = 1, length = 12, halfLength = 6
    • leftIndex = 3, rightIndex = 4
    • 如果 leftnode 小, index = 3
  • index = 3, left = 12, halfLength = 6
    • leftIndex = 7, rightIndex = 8
    • 如果 leftnode 小, 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 而不是 setTimeoutsetTimeout 是个托底方案):

  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. 将延时队列中的过期任务放到非延时队列中,等待执行
    • 延时队列执行时,将过期任务放到非延时队列中
    • 非延时队列执行时,先检查有没过期的延时任务

往期文章

更多 react 源码文章