astak16 / react-study

1 stars 0 forks source link

react 中的任务(代码片段) #8

Open astak16 opened 1 year ago

astak16 commented 1 year ago

task(任务)

taskreact 中的一个任务,它有下面几个属性:

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

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

astak16 commented 1 year ago

任务安排

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

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

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

    1. 把延迟任务放到 timerQueue 队列中(push(timerQueue, newTask)
    2. 判定当前是否有其他的非延迟任务或者更早的延迟任务,如果没有,说明这个新加入的延迟任务是最紧急的。
    3. requestHostTimeout(handleTimeout, startTime - currentTime) 是设置一个定时器,在 startTime - currentTime 这么长的时间后执行 handleTimeout 这个函数,它会从 timerQueue 中取出最紧急的延迟任务并执行它。
  2. 对于非延迟任务,排序索引就是过期时间(升序排列)(排序索引:newTask.sortIndex = expirationTime

    1. 把非延迟任务放到 taskQueue 队列中(push(taskQueue, newTask)
    2. 如果开启了性能模式,就会记录开始时候,并将任务标记为已经加入到队列中
    3. 判断当前是否已经安排了一个回调函数或者正在执行工作
      • isHostCallbackScheduled = true 是标记已经安排了一个回调函数。
      • requestHostCallback(flushWork) 是安排一个回调函数,在浏览器空闲时执行 flushWork 这个函数,它会从 taskQueue 中取出最紧急的非延迟任务并执行它。

timerQueue:是用来存放延迟任务的,也就是那些还没有到达执行时间的任务。它们会在定时器触发后被执行 taskQueue:是用来存放非延迟任务的,也就是那些可以立即执行的任务。它们会在浏览器空闲时被执行

astak16 commented 1 year ago

构建任务队列

根据任务的 sortIndex 优先级,安排任务的执行顺序

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

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

构建最小堆

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

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

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

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

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

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) {
      // The parent is larger. Swap positions.
      heap[parentIndex] = node;
      heap[index] = parent;
      index = parentIndex;
    } else {
      // The parent is smaller. Exit.
      return;
    }
  }
}

从堆顶取出一个元素

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

这两个函数的区别是:

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

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

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

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

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

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

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

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

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

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 the left or right node is smaller, swap with the smaller of those.
    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 {
      // Neither child is smaller. Exit.
      return;
    }
  }
}

compare 函数

compare 函数的实现如下:

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

function compare(a: Node, b: Node) {
  // Compare sort index first, then task id.
  const diff = a.sortIndex - b.sortIndex;
  return diff !== 0 ? diff : a.id - b.id;
}

最后

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

astak16 commented 1 year ago

isHostCallbackScheduled 作用

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

具体工作原理:

在调用 flushWork 函数之前,先把 isHostCallbackScheduled 置为 true,然后再 flushWork 调用时将 isHostCallbackScheduled 设置为 false

如果在 flushWork 函数执行过程中, requestHostCallback 函数被调用了,requestHostCallback 调用说明有新的任务被安排了,那么就需要检查当前是否有任务在执行中

如果有,就不会安排新的任务(超时函数触发、其他新的任务),而是等待当前任务执行完毕后再安排(等待 flushWork 执行完毕)

astak16 commented 1 year ago

延迟任务调度

延迟任务调度:requestHostTimeout(handleTimeout, startTime - currentTime)

handleTimeout

执行逻辑:

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

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

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);
      }
    }
  }
}
astak16 commented 1 year ago

非延迟任务调度

非延迟任务调度:requestHostCallback(flushWork)

flushWork

由宿主回调函数调用(requestHostCallback),内部调用 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

具体逻辑如下:

第一个条件判断:当前的任务,如果任务的过期时间大于当前时间,并且没有剩余的时间或者应该让出控制权,就跳出循环(注释 ①)

如果任务没有过期,并且还有剩余时间(或者不需要让出控制权),它会执行当前任务的回调函数,并传入一个布尔值表示是否超时(注释 ②)

如果回调函数返回了一个新的函数,说明当前任务还没有完成,需要继续执行,那么它会把新的函数赋值给当前任务的回调,并返回 true 表示还有未完成的任务(注释 ③)

如果回调函数没有返回新的函数,说明当前任务已经完成,那么它会从任务队列中移除当前任务(注释 ④)

最后,重复上述步骤,直到任务队列为空或者遇到需要暂停或者让出的情况

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

第一个条件判断 timer.callback === null,说明这个任务已经被取消了,就用 pop 函数把它从 timerQueue 中移除

第二个条件判断 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);
  }
}
astak16 commented 1 year ago

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

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

requestHostTimeout

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

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

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();
};