在React的concurrent模式下,低优先级任务执行过程中,一旦有更高优先级的任务进来,那么这个低优先级的任务会被取消,优先执行高优先级任务。等高优先级任务做完了,低优先级任务会被重新做一遍。 我们用一个具体的例子来理解一下高优先级任务插队。 有这样一个组件,state为0,进入页面,会调用setState将state加1,这个作为低优先级任务。React开始进行更新,在这个低优先级任务尚未完成时,模拟按钮点击,state加2,这个作为高优先级任务。可以看到,页面上的数字变化为0 -> 2 -> 3,而不是0 -> 1 -> 3。这就说明,当低优先级任务(加1)正在进行时,高优先级任务进来了,而它会把state设置为2。由于高优先级任务的插队,设置state为1的低优先级任务会被取消,先做高优先级任务,所以数字从0变成了2。而高优先级任务完成之后,低优先级任务会被重做,所以state再从2加到了3。 现象如下: 利用chrome的性能分析工具捕捉更新过程,可以明显看到优先级插队的过程 完整的profile文件我保存下来了,可以载入到chrome中详细查看。 可以再看一下这个过程中两个任务优先级在调度过程中的信息 高优先级插队示例代码文件。 低优先级任务饥饿问题示例代码文件。 接下来我们就来从setState开始,探讨一下这种插队行为的本质,内容涉及update对象的生成、发起调度、工作循环、高优任务插队、update对象的处理、低优先级任务重做等内容。 产生更新 当调用setState时,意味着组件对应的fiber节点产生了一个更新。setState实际上是生成一个update对象,调用enqueueSetState,将这个update对象连接到fiber节点的updateQueue链表中. Component.prototype.setState = function(partialState, callback) { this.updater.enqueueSetState(this, partialState, callback, 'setState'); }; enqueueSetState的职责是创建update对象,将它入队fiber节点的update链表(updateQueue),然后发起调度。 enqueueSetState(inst, payload, callback) { // 获取当前触发更新的fiber节点。inst是组件实例 const fiber = getInstance(inst); // eventTime是当前触发更新的时间戳 const eventTime = requestEventTime(); const suspenseConfig = requestCurrentSuspenseConfig(); // 获取本次update的优先级 const lane = requestUpdateLane(fiber, suspenseConfig); // 创建update对象 const update = createUpdate(eventTime, lane, suspenseConfig); // payload就是setState的参数,回调函数或者是对象的形式。 // 处理更新时参与计算新状态的过程 update.payload = payload; // 将update放入fiber的updateQueue enqueueUpdate(fiber, update); // 开始进行调度 scheduleUpdateOnFiber(fiber, lane, eventTime); } 梳理一下enqueueSetState中具体做的事情: 找到fiber 首先获取产生更新的组件所对应的fiber节点,因为产生的update对象需要放到fiber节点的updateQueue上。然后获取当前这个update产生的时间,这与更新的饥饿问题相关,我们暂且不考虑,而且下一步的suspenseConfig可以先忽略。 计算优先级 之后比较重要的是计算当前这个更新它的优先级lane: const lane = requestUpdateLane(fiber, suspenseConfig); 计算这个优先级的时候,是如何决定根据什么东西去计算呢?这还得从React的合成事件说起。 事件触发时,合成事件机制调用scheduler中的runWithPriority函数,目的是以该交互事件对应的事件优先级去派发真正的事件流程。runWithPriority会将事件优先级转化为scheduler内部的优先级并记录下来。当调用requestUpdateLane计算lane的时候,会去获取scheduler中的优先级,以此作为lane计算的依据。 这部分的源码在这里 创建update对象, 入队updateQueue 根据lane和eventTime还有suspenseConfig,去创建一个update对象,结构如下: const update: Update<*> = { eventTime, lane, suspenseConfig, tag: UpdateState, payload: null, callback: null, next: null, };
再之后就是去调用React任务执行的入口函数:scheduleUpdateOnFiber去调度执行更新任务了。 现在我们知道了,产生更新的fiber节点上会有一个updateQueue,它包含了刚刚产生的update。下面该进入scheduleUpdateOnFiber了,开始进入真正的域名交易调度流程。通过调用scheduleUpdateOnFiber,render阶段的构建workInProgress树的任务会被调度执行,这个过程中,fiber上的updateQueue会被处理。 调度准备 React的更新入口是scheduleUpdateOnFiber,它区分update的lane,将同步更新和异步更新分流,让二者进入各自的流程。但在此之前,它会做几个比较重要的工作:
这三步可以视为更新执行前的准备工作。 第1个可以防止死循环卡死的情况。 第2个,如果fiber.lanes不为空,则说明该fiber节点有更新,而fiber.childLanes是判断当前子树是否有更新的重要依据,若有更新,则继续向下构建,否则直接复用已有的fiber树,就不往下循环了,可以屏蔽掉那些无需更新的fiber节点。 第3个是将当前update对象的lane加入到root.pendingLanes中,保证真正开始做更新任务的时候,获取到update的lane,从而作为本次更新的渲染优先级(renderLanes),去更新。 实际上在更新时候获取到的renderLanes,并不一定包含update对象的lane,因为有可能它只是一个较低优先级的更新,有可能在它前面有高优先级的更新 梳理完scheduleUpdateOnFiber的大致逻辑之后,我们来看一下它的源码: export function scheduleUpdateOnFiber( fiber: Fiber, lane: Lane, eventTime: number, ) { // 第一步,检查是否有无限更新 checkForNestedUpdates(); ... // 第二步,向上收集fiber.childLanes const root = markUpdateLaneFromFiberToRoot(fiber, lane); ... // 第三步,在root上标记更新,将update的lane放到root.pendingLanes markRootUpdated(root, lane, eventTime); ... // 根据Scheduler的优先级获取到对应的React优先级 const priorityLevel = getCurrentPriorityLevel(); if (lane === SyncLane) { // 本次更新是同步的,例如传统的同步渲染模式 if ( (executionContext & LegacyUnbatchedContext) !== NoContext && (executionContext & (RenderContext | CommitContext)) === NoContext ) { // 如果是本次更新是同步的,并且当前还未渲染,意味着主线程空闲,并没有React的 // 更新任务在执行,那么调用performSyncWorkOnRoot开始执行同步任务 ... performSyncWorkOnRoot(root); } else { // 如果是本次更新是同步的,不过当前有React更新任务正在进行, // 而且因为无法打断,所以调用ensureRootIsScheduled // 目的是去复用已经在更新的任务,让这个已有的任务 // 把这次更新顺便做了 ensureRootIsScheduled(root, eventTime); ... } } else { ... // Schedule other updates after in case the callback is sync. // 如果是更新是异步的,调用ensureRootIsScheduled去进入异步调度 ensureRootIsScheduled(root, eventTime); schedulePendingInteractions(root, lane); } ... } scheduleUpdateOnFiber 的完整源码在这里,这里是第二步:markUpdateLaneFromFiberToRoot 和 第三步: markRootUpdated的完整源码,我都做了注释。 经过了前面的准备工作后,scheduleUpdateOnFiber最终会调用ensureRootIsScheduled,来让React任务被调度,这是一个非常重要的函数,它关乎同等或较低任务的收敛、 开始调度 在开始讲解ensureRootIsScheduled之前,我们有必要弄清楚React的更新任务的本质。 React任务的本质 一个update的产生最终会使React在内存中根据现有的fiber树构建一棵新的fiber树,新的state的计算、diff操作、以及一些生命周期的调用,都会在这个构建过程中进行。这个整体的构建工作被称为render阶段,这个render阶段整体就是一个完整的React更新任务,更新任务可以看作执行一个函数,这个函数在concurrent模式下就是performConcurrentWorkOnRoot,更新任务的调度可以看成是这个函数被scheduler按照任务优先级安排它何时执行。 Scheduler的调度和React的调度是两个完全不同的概念,React的调度是协调任务进入哪种Scheduler的调度模式,它的调度并不涉及任务的执行,而Scheduler是调度机制的真正核心,它是实打实地去执行任务,没有它,React的任务再重要也无法执行,希望读者加以区分这两种概念。 当一个任务被调度之后,scheduler就会生成一个任务对象(task),它的结构如下所示,除了callback之外暂时不需要关心其他字段的含义。 var newTask = { id: taskIdCounter++, // 任务函数,也就是 performConcurrentWorkOnRoot callback, // 任务调度优先级,由即将讲到的任务优先级转化而来 priorityLevel, // 任务开始执行的时间点 startTime, // 任务的过期时间 expirationTime, // 在小顶堆任务队列中排序的依据 sortIndex: -1, }; 每当生成了一个这样的任务,它就会被挂载到root节点的callbackNode属性上,以表示当前已经有任务被调度了,同时会将任务优先级存储到root的callbackPriority上, 所以在调度任务的时候,任务优先级是不可或缺的一个重要角色。 任务优先级 任务本身是由更新产生的,因此任务优先级本质上是和update的优先级,即update.lane有关(只是有关,不一定是由它而来)。得出的任务优先级属于lanePriority,它不是update的lane,而且与scheduler内部的调度优先级是两个概念,React中的优先级转化关系可以看我总结过的一篇文章:React中的优先级,我们这里只探讨任务优先级的生成过程。 在 调度准备 的最后提到过,update.lane会被放入root.pendingLanes,随后会获取root.pendingLanes中最优先级的那些lanes作为renderLanes。任务优先级的生成就发生在计算renderLanes的阶段,任务优先级其实就是renderLanes对应的lanePriority。因为renderLanes是本次更新的优先级基准,所以它对应的lanePriority被作为任务优先级来衡量本次更新任务的优先级权重理所应当。 root.pendingLanes,包含了当前fiber树中所有待处理的update的lane。 任务优先级有三类:
最右面的两个lane分别为同步优先级和同步批量优先级,剩下左边的lane几乎所有都和concurrent模式有关。 export const SyncLane: Lane = /* */ 0b0000000000000000000000000000001; export const SyncBatchedLane: Lane = /* */ 0b0000000000000000000000000000010; concurrent模式下的lanes:/* */ 0b1111111111111111111111111111100; 计算renderLanes的函数是getNextLanes,生成任务优先级的函数是getHighestPriorityLanes 任务优先级决定着任务在React中被如何调度,而由任务优先级转化成的任务调度优先级(上面给出的scheduler的task结构中的priorityLevel), 任务调度协调 - ensureRootIsScheduled 目前为止我们了解了任务和任务优先级的本质,下面正式进入任务的调度过程。React这边对任务的调度本质上其实是以任务优先级为基准,去操作多个或单个任务。 多个任务的情况,相对于新任务,会对现有任务进行或复用,或取消的操作,单个任务的情况,对任务进行或同步,或异步,或批量同步(暂时不需要关注) 的调度决策, 让我们看一看ensureRootIsScheduled函数做的事情,先是准备本次任务调度协调所需要的lanes和任务优先级,然后判断是否真的需要调度
接下来是协调任务调度的过程:
这里有两点需要说明:
这是因为每次调度去获取任务优先级的时候,都只获取root.pendingLanes中最紧急的那部分lanes对应的优先级,低优先级的update持有的lane对应的优先级是无法被获取到的。通过这种办法,可以将来自同一事件中的多个更新收敛到一个任务中去执行,言外之意就是同一个事件触发的多次更新的优先级是一样的,没必要发起多次任务调度。例如在一个事件中多次调用setState: class Demo extends React.Component { state = { count: 0 } onClick = () => { this.setState({ count: 1 }) this.setState({ count: 2 }) } render() { return <button onClick={onClick}>{this.state.count}</button> } } 页面上会直接显示出2,虽然onClick事件调用了两次setState,但只会引起一次调度,设置count为2的那次调度被因为优先级与设置count为1的那次任务的优先级相同,
要注意一点,用来做新旧任务比较的优先级与这里将任务加入到scheduler中传入的优先级不是一个,后者可由前者通过lanePriorityToSchedulerPriority转化而来。 经过以上的分析,相信大家已经对ensureRootIsScheduled的运行机制比较清晰了,现在让我们看一下它的实现: function ensureRootIsScheduled(root: FiberRoot, currentTime: number) { // 获取旧任务 const existingCallbackNode = root.callbackNode; // 记录任务的过期时间,检查是否有过期任务,有则立即将它放到root.expiredLanes, // 便于接下来将这个任务以同步模式立即调度 markStarvedLanesAsExpired(root, currentTime); // 获取renderLanes const nextLanes = getNextLanes( root, root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes, ); // 获取renderLanes对应的任务优先级 const newCallbackPriority = returnNextLanesPriority(); if (nextLanes === NoLanes) { // 如果渲染优先级为空,则不需要调度 if (existingCallbackNode !== null) { cancelCallback(existingCallbackNode); root.callbackNode = null; root.callbackPriority = NoLanePriority; } return; } // 如果存在旧任务,那么看一下能否复用 if (existingCallbackNode !== null) { // 获取旧任务的优先级 const existingCallbackPriority = root.callbackPriority; // 如果新旧任务的优先级相同,则无需调度 if (existingCallbackPriority === newCallbackPriority) { return; } // 代码执行到这里说明新任务的优先级高于旧任务的优先级 // 取消掉旧任务,实现高优先级任务插队 cancelCallback(existingCallbackNode); } // 调度一个新任务 let newCallbackNode; if (newCallbackPriority === SyncLanePriority) { // 若新任务的优先级为同步优先级,则同步调度,传统的同步渲染和过期任务会走这里 newCallbackNode = scheduleSyncCallback( performSyncWorkOnRoot.bind(null, root), ); } else if (newCallbackPriority === SyncBatchedLanePriority) { // 同步模式到concurrent模式的过渡模式:blocking模式会走这里 newCallbackNode = scheduleCallback( ImmediateSchedulerPriority, performSyncWorkOnRoot.bind(null, root), ); } else { // concurrent模式的渲染会走这里 // 根据任务优先级获取Scheduler的调度优先级 const schedulerPriorityLevel = lanePriorityToSchedulerPriority( newCallbackPriority, ); // 计算出调度优先级之后,开始让Scheduler调度React的更新任务 newCallbackNode = scheduleCallback( schedulerPriorityLevel, performConcurrentWorkOnRoot.bind(null, root), ); } // 更新root上的任务优先级和任务,以便下次发起调度时候可以获取到 root.callbackPriority = newCallbackPriority; root.callbackNode = newCallbackNode; } ensureRootIsScheduled实际上是在任务调度层面整合了高优先级任务的插队和任务饥饿问题的关键逻辑,这只是宏观层面的决策,决策背后的原因是React处理更新时 处理更新 一旦有更新产生,update对象就会被放入updateQueue并挂载到fiber节点上。构建fiber树时,会带着renderLanes去处理updateQueue,在beginWork阶段,对于类组件 循环updateQueue去计算状态的过程实际上较为复杂,因为低优先级update会被跳过并且会重做,所以这涉及到最终状态统一的问题,关于这一过程的原理解读在我的这篇文章里:扒一扒React计算状态的原理,在本篇文章中只关注优先级相关的部分。 关于优先级的部分比较好理解,就是只处理优先级足够的update,跳过那些优先级不足的update,并且将这些update的lane放到fiber.lanes中。我们直接来看一下实现: function processUpdateQueue<State>( workInProgress: Fiber, props: any, instance: any, renderLanes: Lanes, ): void { ... if (firstBaseUpdate !== null) { let update = firstBaseUpdate; do { const updateLane = update.lane; // isSubsetOfLanes函数的意义是,判断当前更新的优先级(updateLane) // 是否在渲染优先级(renderLanes)中如果不在,那么就说明优先级不足 if (!isSubsetOfLanes(renderLanes, updateLane)) { ... /* * * newLanes会在最后被赋值到workInProgress.lanes上,而它又最终 * 会被收集到root.pendingLanes。 * * 再次更新时会从root上的pendingLanes中找出应该在本次中更新的优先 * 级(renderLanes),renderLanes含有本次跳过的优先级,再次进入, * processUpdateQueue wip的优先级符合要求,被更新掉,低优先级任务 * 因此被重做 * */ newLanes = mergeLanes(newLanes, updateLane); } else { // 优先级足够,去计算state ... } } while (true); // 将newLanes赋值给workInProgress.lanes, // 就是将被跳过的update的lane放到fiber.lanes workInProgress.lanes = newLanes; } } 只处理优先级足够的update是让高优先级任务被执行掉的最本质原因,在循环了一次updateQueue之后,那些被跳过的update的lane又被放入了fiber.lanes,现在,只需要将它放到root.pendingLanes中,就能表示在本轮更新后,仍然有任务未被处理,从而实现低优先级任务被重新调度。所以接下来的过程就是fiber节点的完成阶段:completeWork阶段去收集这些lanes。 收集未被处理的lane 在completeUnitOfWork的时候,fiber.lanes 和 childLanes被一层一层收集到父级fiber的childLanes中,该过程发生在completeUnitOfWork函数中调用的resetChildLanes,它循环fiber节点的子树,将子节点及其兄弟节点中的lanes和childLanes收集到当前正在complete阶段的fiber节点上的childLanes。 假设第3层中的<List/>和<Table/>组件都分别有update因为优先级不够而被跳过,那么在它们父级的div fiber节点completeUnitOfWork的时候,会调用resetChildLanes root(pendingLanes: 0b01110) | 1 App | | 2 compeleteUnitOfWork-----------> div (childLanes: 0b01110) / / 3 <List/> ---------> <Table/> --------> p (lanes: 0b00010) (lanes: 0b00100) (childLanes: 0b01000) / / / / / 4 p ul / / li ------> li 在每一次往上循环的时候,都会调用resetChildLanes,目的是将fiber.childLanes层层收集。 function completeUnitOfWork(unitOfWork: Fiber): void { // 已经结束beginWork阶段的fiber节点被称为completedWork let completedWork = unitOfWork; do { // 向上一直循环到root的过程 ... // fiber节点的.flags上没有Incomplete,说明是正常完成了工作 if ((completedWork.flags & Incomplete) === NoFlags) { ... // 调用resetChildLanes去收集lanes resetChildLanes(completedWork); ... } else {/*...*/} ... } while (completedWork !== null); ... } resetChildLanes中只收集当前正在complete的fiber节点的子节点和兄弟节点的lanes以及childLanes: function resetChildLanes(completedWork: Fiber) { ... let newChildLanes = NoLanes; if (enableProfilerTimer && (completedWork.mode & ProfileMode) !== NoMode) { // profile相关,无需关注 } else { // 循环子节点和兄弟节点,收集lanes let child = completedWork.child; while (child !== null) { // 收集过程 newChildLanes = mergeLanes( newChildLanes, mergeLanes(child.lanes, child.childLanes), ); child = child.sibling; } } // 将收集到的lanes放到该fiber节点的childLanes中 completedWork.childLanes = newChildLanes; } 最后将这些收集到的childLanes放到root.pendingLanes的过程,是发生在本次更新的commit阶段中,因为render阶段的渲染优先级来自root.pendingLanes,不能随意地修改它。所以要在render阶段之后的commit阶段去修改。我们看一下commitRootImpl中这个过程的实现: function commitRootImpl(root, renderPriorityLevel) { // 将收集到的childLanes,连同root自己的lanes,一并赋值给remainingLanes let remainingLanes = mergeLanes(finishedWork.lanes, finishedWork.childLanes); // markRootFinished中会将remainingLanes赋值给remainingLanes markRootFinished(root, remainingLanes); ... } 重新发起调度 至此,我们将低优先级任务的lane重新收集到了root.pendingLanes中,这时只需要再发起一次调度就可以了,通过在commit阶段再次调用ensureRootIsScheduled去实现,这样就又会走一遍调度的流程,低优先级任务被执行。 function commitRootImpl(root, renderPriorityLevel) { // 将收集到的childLanes,连同root自己的lanes,一并赋值给remainingLanes let remainingLanes = mergeLanes(finishedWork.lanes, finishedWork.childLanes); // markRootFinished中会将remainingLanes赋值给remainingLanes markRootFinished(root, remainingLanes); ... // 在每次所有更新完成的时候都会调用这个ensureRootIsScheduled // 以保证root上任何的pendingLanes都能被处理 ensureRootIsScheduled(root, now()); } 总结 高优先级任务插队,低优先级任务重做的整个过程共有四个关键点:
整个流程始终以高优先级任务为重,顾全大局,最能够体现React提升用户体验的决心。 |
|