React源码之任务调度 + 简单实现

68 阅读6分钟

React源码之任务调度 + 简单实现

(免责声明:文章内容纯个人理解,本人只是学习并且总结,不是全部Scheduler,只是一些核心点)

最近正在研究的是,当一个任务到来时,react如何调度任务。

首先,react任务调度的核心在Scheduler包,可以认为它是一个js实现的任务调度器,没有和react强绑定的关系。

  1. 小顶堆

    任务有很多,应当如何保存,以便调度器可以调度。在Scheduler中,我们使用了小顶堆的数据结构。由于是堆结构,任务调度时只需要关注堆顶的任务,优先级最高的任务就是堆顶任务,复杂度会低很多。这里手写一下小顶堆的结构。

export type Heap = Array<Node>
export type Node = {
    id: number;
    sortIndex: number
}
//堆顶元素
export function peek(heap: Heap): Node | null {
    return heap.length === 0 ? null : heap[0];
}
export function push(heap: Heap, node: Node): void {
    const index = heap.length;
    heap.push(node);
    siftUp(heap, node, index);
  }
export function pop(heap: Heap): Node | 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 siftUp(heap: Heap, node: Node, i: number) {
    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;
      }
    }
  }
function siftDown(heap: Heap, node: Node, i: number) {
    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;
      }
    }
  }

function compare(a: Node, b: Node) {
    const diff = a.sortIndex - b.sortIndex;
    return diff !== 0 ? diff : a.id - b.id;
  }
  1. 任务调度器

一个任务,是一个什么数据结构?任务进来肯定是一个函数,而我们需要封装成我们需要的结构。

export type Task = {
    id: number; //任务ID
    callback: Callback | null //任务回调函数
    priorityLevel: PriorityLevel //任务优先级
    startTime: number; //任务开始时间(进入调度器的时间)
    expirationTime: number //任务过期时间
    sortIndex: number; //任务排序索引
};

其次,任务有优先级,优先级高的应当先被执行,每个优先级有一个过期时间,先过期的任务应该先执行。

export type PriorityLevel = 0 | 1 | 2 | 3| 4 | 5
//任务优先级越高,值越小
export const NoPriority = 0;
export const ImmediatePriority = 1;
export const UserBlockingPriority = 2;
export const NormalPriority = 3;
export const LowPriority = 4;
export const IdlePriority = 5;

const maxSigned31BitInt = 1073741823;
export const IMMEDIATE_PRIORITY_TIMEOUT = -1;
export const USER_BLOCKING_PRIORITY_TIMEOUT = 250;
export const NORMAL_PRIORITY_TIMEOUT = 5000;
export const LOW_PRIORITY_TIMEOUT = 10000;
export const IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt;
export function getTimeoutByPriorityLevel(priorityLevel: PriorityLevel) {
    let timeout: number;
    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;
    }
    return timeout;
}

在探讨Scheduler时,先定义一些常量。具体作用看注释。

let taskIdCounter = 1; //任务ID计数器
let startTime = -1 //时间切片起始时间
let frameInterval = 5 //时间切片 = 5ms
let isPerformingWork = false //是否正在调度任务
let isHostCallbackScheduled = false //标记是否安排浏览器调度任务
let isMessageLoopRunning = false //是否启动消息循环
//任务池,最小堆
const taskQueue: Array<Task> = []
//当前任务
let currentTask: Task | null = null

//当前任务优先级
let currentPriority: PriorityLevel = NoPriority;

当一个任务到来时,通过scheduleCallback函数进入调度入口,并且这是唯一暴露给其他包的函数。 看看这个函数主要做什么?

  1. 创建任务
  2. 放入小顶堆
  3. 根据当前状态决定是否向浏览器注册任务
export function scheduleCallback(
    priorityLevel:PriorityLevel,
    callback:Callback,
){
    //任务进入调度
    const currentTime = getCurrentTime()
    let startTime: number;
    startTime = currentTime;
    const timeout = getTimeoutByPriorityLevel(priorityLevel);
    const expirationTime = startTime + timeout;
    const newTask = {
        id: taskIdCounter++,
        callback,
        priorityLevel,
        startTime, //任务开始调度理论时间
        expirationTime,  //过期时间
        sortIndex: -1 //越小越优先调度
    }
    newTask.sortIndex = expirationTime
    push(taskQueue,newTask)
    if(!isHostCallbackScheduled && !isPerformingWork){ //即使是false也不用担心,浏览器已经在执行了
        isHostCallbackScheduled = true
        requestHostCallback() //给浏览器注册一个回调函数
    }
}

在上述代码中,执行了requestHostCallback(),这个函数主要作用是通知浏览器执行任务。

function requestHostCallback(){
    if(!isMessageLoopRunning){
        isMessageLoopRunning = true
        schedulePerformWorkUntilDeadline() //时间切片内执行,直到时间切片结束
    }
}
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline
//时间切片内执行,直到时间切片结束
function schedulePerformWorkUntilDeadline() {
    port.postMessage(null);
}
function performWorkUntilDeadline(){
    if(isMessageLoopRunning){
        //一个work的起始时间
        const currentTime = getCurrentTime()
        let hasOtherWork = false;
        try{    
            hasOtherWork = flushWork(currentTime)
        } finally{
            if(hasOtherWork){
                //如果还有其他任务需要执行,继续调度
                schedulePerformWorkUntilDeadline();
            }else{
                isMessageLoopRunning = false;
            }
        }
    }
}

这里有一个时间切片的概念。试想有一个时间很长的任务,如果一直执行,势必会消耗很长时间。如果再进入一个高优先级任务,那么这个任务就没办法及时调度,就会阻塞。所以引入了时间切片的概念。

React中,时间切片为5ms,在这个时间段里,会执行任务,如果任务无法完成则中断这个任务,等待后续调度。为什么可以中断任务,这是由React Fiber完成的。对于React Fiber,以后再说。

requestHostCallback 就像是 requestIdleCallback, 通知浏览器我要执行任务!

在React中自己实现了这样一个通知,使用的是MessageChannel(),他可以创建一个宏任务,达到批量处理一组任务的目的。

  1. 执行任务

之前是在调度任务, 到现在任务要执行了。看上面的代码,已经知道执行任务是通过flushWork(currentTime)执行的,这个函数有一个返回值,如果任务还没执行完就是true,执行完就是false。

function flushWork(initialTime: number) {
    isHostCallbackScheduled = false
    isPerformingWork = true
    let previousPriorityLevel = currentPriority
    try{
        return workLoop(initialTime)
    } finally {
        currentPriority = previousPriorityLevel
        currentTask = null
        isPerformingWork = false
    }
}

//todo
//控制权交还给主线程,当前时间 - 开始时间 >= 5ms
function shouldYieldToHost() {
    const timeElapsed = getCurrentTime() - startTime
    return timeElapsed >= frameInterval
}
//很多task要执行,每个task有一个callback
//一个work就是一个时间切片内执行的一些task
//时间切片要循环,就在workLoop中实现
// 返回true表示还有任务未完成,需要继续执行
function workLoop(initialTime: number) {
    let currentTime = initialTime
    currentTask = peek(taskQueue) as Task
    while(currentTask !== null) {
        if(currentTask.expirationTime > currentTime && shouldYieldToHost()) {
            break;
        }
        const callback = currentTask.callback
        if(isFn(callback)) { //当前任务未被取消(存在),任务有效
            currentTask.callback = null //将任务的回调函数置为null,表示任务已开始执行,防止任务重复执行
            currentPriority = currentTask.priorityLevel
            const didUserCallbackTimeout = currentTask.expirationTime <= currentTime
            const continuationCallback = callback(didUserCallbackTimeout) //执行任务回调函数
            if(isFn(continuationCallback)) { //如果回调函数返回了一个新的任务,即当前任务没有执行完成
                currentTask.callback = continuationCallback
                return true
            }else{
                if(currentTask === peek(taskQueue)) { //如果当前任务仍然是堆顶任务
                    pop(taskQueue); //从任务池中删除
                }
            }
        }else{ //任务无效
            pop(taskQueue); //从任务池中删除
        }
        currentTask = peek(taskQueue) as Task; //获取下一个任务
    }
    if(currentTask !== null) {
        return true
    } else{
        return false; //没有更多任务需要执行
    }
}

任务执行的核心在workLoop,它的作用就是在时间切片内循环执行任务。代码注释写的详细无比,自己阅读一下。反正没人看,我写到自己理解就不想写了。

顺便贴一下用到的工具函数

export function getCurrentTime(): number {
  return performance.now();
}

export function isArray(sth: any) {
  return Array.isArray(sth);
}

export function isNum(sth: any) {
  return typeof sth === "number";
}

export function isObject(sth: any) {
  return typeof sth === "object";
}

export function isFn(sth: any) {
  return typeof sth === "function";
}

export function isStr(sth: any) {
  return typeof sth === "string";
}