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

您所在的位置:网站首页 delay函数原理 剖析 React 任务调度机制:scheduleCallback 实现原理

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

2023-04-02 03:33| 来源: 网络整理| 查看: 265

本文对应的 react 版本是 18.2.0

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

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

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

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

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

设置任务开始时间

通过 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)

执行过程:

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

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

对于非延迟任务,排序索引是过期时间(升序排列,排序索引:newTask.sortIndex = expirationTime)

把非延迟任务放到 taskQueue 队列中(push(taskQueue, newTask)) 判断当前是否已经安排了一个回调函数(!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(heap: Heap, node: T): void { const index = heap.length; heap.push(node); siftUp(heap, node, index); } 复制代码 function siftUp(heap: Heap, 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(heap: Heap): 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(heap: Heap, 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 执行是传入的回调函数

执行过程:

将延迟队列任务中过期任务放到任务列表中 如果任务列表中有任务,调用 requestHostCallback(flushWork) 执行任务 如果任务列表中没有任务,判断延迟队列中是否有任务,如果有,调用 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

执行过程:

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

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

因为 setTimeout 是基于时间的,如果浏览器被挂起(例如,当用户切换到其他标签或最小化窗口时),setTimeout 也会被挂起,而 MessageChannel 不会 还有 setTimeout 的精度也不够,可能存在一定的误差 然后当有大量的任务需要执行时,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

执行逻辑:

如果当前任务的过期时间(expirationTime)大于当前时间(currentTime),并且没有剩余的时间(!hasTimeRemaining)或者应该让出控制权(shouldYieldToHost),就跳出循环(注释 ①) 如果任务没有过期,并且还有剩余时间(或者不需要让出控制权),它会执行当前任务的回调函数,并传入一个布尔值表示是否超时(注释 ②) const continuationCallback = callback(didUserCallbackTimeout); 如果回调函数返回了一个新的函数,说明当前任务还没有完成,需要继续执行,那么它会把新的函数赋值给当前任务的回调,并返回 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


【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3