React 源码分析 7-state 计算流程和优先级
setState 执行之后会发生什么
setState
执行之后,会执行一个叫 enqueueSetState
的方法,这个主要作用是创建 Update
对象和发起调度,可以看下这个函数的逻辑,
从上面源码可以清晰知道,setState
调用之后做的 4 件事情
根据组件实例获取其 Fiber 节点
创建
Update
对象将
Update
对象关联到 Fiber 节点的updateQueue
属性中发起调度
根据组件实例获取其 Fiber 节点
其实就是拿组件实例中的 _reactInternals
属性,这个就是当前组件所对应的 Fiber 节点
题外话:react 利用双缓存机制来完成 Fiber 树的构建和替换,也就是 current
和 workInProgress
两棵树,那 enqueueSetState
里面拿的是那棵树下的 Fiber 节点呢?
创建 update 对象
属性的含义如下:
eventTime:update 对象创建的时间,用于
ensureRootIsScheduled
计算过期时间用lane:此次更新的优先级
payload:setState 的第一个参数
callback:setState 的第二个参数
next:连接的下一个 update 对象
将Update
对象关联到 Fiber 节点的updateQueue
属性
这里执行的是 enqueueUpdate
函数,下面是我简化过后的逻辑
可以看到这里的逻辑主要是将 update 对象放到 fiber 对象的 updateQueue.shared.pending
属性中, updateQueue.shared.pending
是一个环状链表。
那为什么需要把它设计为一个环状链表?我是这样理解的
shared.pending
存放的是链表的最后一个节点,那么在环状链表中,链表的最后一个节点的 next 指针,是指向环状链表的头部节点,这样我们就能快速知道链表的首尾节点当知道首尾节点后,就能很轻松的合并两个链表。比如有两条链表 a、b,我们想要把 b append 到 a 的后面,可以这样做
后面即使有 c、d 链表,同样也可以用相同的办法合并到 a。react 在构建 updateQueue
链表上也用了类似的手法,新产生的 update
对象通过类似上面的操作合并到 updateQueue
链表,
发起调度
在 enqueueUpdate
末尾,执行了 scheduleUpdateOnFiber
函数,该方法最终会调用 ensureRootIsScheduled
函数来调度 react 的应用根节点。
当进入 performConcurrentWorkOnRoot
函数时,就代表进入了 reconcile
阶段,也就是我们说的 render
阶段。render
阶段是一个自顶向下再自底向上的过程,从 react 的应用根节点开始一直向下遍历,再从底部节点往上回归,这就是render
阶段的节点遍历过程。
这里我们需要知道的是,在render
阶段自顶向下遍历的过程中,如果遇到组件类型的 Fiber 节点,我们会执行 processUpdateQueue
函数,这个函数主要负责的是组件更新时 state 的计算
processUpdateQueue 做了什么
processUpdateQueue
函数主要做了三件事情
构造本轮更新的
updateQueue
,并缓存到 currentFiber 节点中循环遍历
updateQueue
,计算得到newState
,构造下轮更新的updateQueue
更新 workInProgress 节点中的
updateQueue
、memoizedState
属性
这里的 updateQueue
并不指代源码中 Fiber 节点的 updateQueue
,可以理解为从 firstBaseUpdate
到 lastBaseUpdate
的整条更新队列。这里为了方便描述和理解,直接用 updateQueue
替代说明。
变量解释
因为涉及的变量比较多,processUpdateQueue
函数的逻辑看起来并不怎么清晰,所以我先列出一些变量的解释方便理解
shared.pending:
enqueueSetState
产生的 update 对象 环形链表first/lastBaseUpdate:-- 下面我会用 baseUpdate 代替
当前 Fiber 节点中
updateQueue
对象中的属性,代表当前组件整个更新队列链表的首尾节点first/lastPendingUpdate:下面我会用 pendingUpdate 代替
shared.pending
剪开后的产物,分别代表新产生的 update 对象 链表的首尾节点,最终会合并到 currentFiber 和 workInProgress 两棵树的更新队列尾部newFirst/LastBaseUpdate:下面我会用 newBaseUpdate 代替
newState 计算过程会得到,只要存在低优先级的 update 对象,这两个变量就会有值。这两个变量会赋值给 workInProgress 的
baseUpdate
,作为下一轮更新 update 对象 链表的首尾节点baseState:newState 计算过程依赖的初始 state
memoizedState:当前组件实例的 state,
processUpdateQueue
末尾会将 newState 赋值给这个变量,
构造本轮更新的 updateQueue
上面我们说到 shared.pending
是enqueueSetState
产生的 update 对象 环形链表,在这里我们需要剪断这个环形列表取得其中的首尾节点,去组建我们的更新队列。那如何剪断呢?
shared.pending
是环形链表的尾部节点,它的下一个节点就是环形链表的头部节点,参考上一小节我们提到的链表合并操作。
这样就能剪断环形链表,拿到我们想要的新的 update 对象 —— pendingUpdate
。接着我们要拿着这个 pendingUpdate
做两件事情:
将
pendingUpdate
合并到当前 Fiber 节点的更新队列将
pendingUpdate
合并到 currentFiber 树 中对应 Fiber 节点 的更新队列
为什么要做这两件事情?
第一个是解决状态连续性问题,当出现多个 setState 更新时,我们要确保当前 update 对象 的更新是以前一个 update 对象 计算出来的 state 为前提。所以我们需要构造一个更新队列,新的 update 对象 要合并到更新队列的尾部,从而维护 state 计算的连续性
第二个是解决 update 对象丢失问题。在
shared.pending
被剪开之后,shared.pending
会被赋值为 null,当有高优先级任务进来时,低优先级任务就会被打断,也就意味着 workInProgress 树会被还原,shared.pending
剪开之后得到的pendingUpdate
就会丢失。这时就需要将pendingUpdate
合并到 currentFiber 树 的更新队列中
接下来可以大致看一下这一部分的源码
相关参考视频讲解:进入学习
源码看起来很多,但本质上只做了一件事,从源码中可以看出这部分主要就是把 shared.pending
剪开,拿到我们的 pendingUpdate
,再把 pendingUpdate
合并到本轮更新和 currentFiber 节点的 baseUpdate
中。
计算 newState
在这部分的源码中,除了计算 newState
,还有另外一个重要工作是,构造下一轮更新用的 updateQueue
。
到这里可能会有疑问,为什么需要构造下轮更新的
updateQueue
,本轮更新我们把shared.pending
里面的对象遍历计算完,再把 state 更新,下轮更新进来再根据这个 state 计算不行好了吗?
如果没有高优先级任务打断机制,确实是不需要在这里构造下轮更新的 updateQueue
,因为每轮更新我们只会依赖当前的 state 和 shared.pending
。
打断机制下,低优先级任务重启后的执行,需要依赖完整的更新队列才能保证 state 的连续性和正确性。下面我举个例子
我们期望能实现的效果是 **0 -> 2 -> 3
**,需求如下:
高优先级任务打断低优先级任务之后,不以低优先级任务计算得到的 baseState 做计算
低优先级任务重启后,不能覆盖高优先级任务计算得到的值,且需要根据低优先级任务计算得到的 newState,作为高优先级的 baseState 再去执行一次高优先级任务
知道了需求,我们可以大概列一下实现思路:
低优先级任务打断后,高优先级任务执行之前,需要还原到低优先级任务执行之前的 workInPregress 节点,确保不受低优先级任务计算得到的 baseState 影响
需要维护一个更新对象队列,按执行顺序存储 update 对象,确保低优先级重启后,依然会执行高优先级任务
上面说的需求和实现思路在 react 的源码中实现其实是非常简单的,但要理解其中的含义可能需要费点功夫,下面可以看看我改动过后的源码,可以直接从 do...while
开始看
逻辑如下:
优先级不够
重新构造更新队列
newBaseUpdate
,留到低优先级任务重启遍历记录当前
newState
,留到低优先级任务重启作为 baseState 计算优先级足够
看看
newBaseUpdate
有没有东西,有东西就把当前 update 对象也合并进去计算
newState
这里 newState
的计算逻辑很简单
payload 是值。用对象包裹合并到 prevState 即可
payload 是函数。传入 prevState 计算,将函数返回值也合并到 prevState 即可
更新 workInProgress
节点
更新 workInProgress 节点属性的逻辑不多,主要就是把 newBaseState、newBaseUpate
赋值给 workInProgress 节点,作为下一轮更新的 baseState
和更新队列使用
如果
newLastBaseUpdate
为空,代表所有 update 对象为空,本轮更新计算得到的newState
可以完全作为下轮更新的baseState
使用。否则只能用出现首个不够优先级的 update 对象时缓存下来的newState
作为下轮更新的baseState
更新
baseUpdate
,当所有 update 对象优先级足够,baseUpdate
的值一般为空。只有存在优先级不够的 update 对象时,才会有值将
newState
赋值给memoizedState
,memoizedState
代表当前组件的所有 state
总结
看到上面的原理解析是不是很复杂,我们可以忽略所有的实现细节,回归现象本质,state 计算就是遍历 update 对象 链表根据 payload 得到新的 state。在此前提下,因为优先级机制,打断之后会还原 workInProgress
节点,从而会引起 update 对象 丢失问题 和 state 计算连续性问题。解决这两个问题才是我们上面说的复杂的实现细节
update 对象丢失问题
为什么会丢失
我们知道高优先级任务进来会打断低优先级任务的执行,打断之后会将当前的 workInProgress
节点还原为开始的状态,也就是可以理解为会将 workInProgress
树还原为当前页面所渲染的 currentFiber
节点。当 workInProgress
节点还原之后,我们本来存在 workInProgress
中的 updateQueue
属性也会被重置,那就意味着低优先级的 update 对象会丢失。
上面说到的,setState 产生的新 update 对象 是会放在 currentFiber
节点上也是这个原因,如果 setState 产生的新 update 对象 放到 workInProgress
上,只要 workInProgress
被还原,这些 update 对象 就会丢失
如何解决
我们在 processUpdateQueue
函数的开始阶段,将新产生的 update 对象,也就是 shared.pending
中的值,合并到 currentFiber( workInProgress.alternate )
节点的 firstBaseUpdate
和 lastBaseUpdate
。具体规则如下
currentFiber
节点不存在lastBaseUpdate
,将新的 update 对象赋值给currentFiber
节点的firstBaseUpdate
和lastBaseUpdate
属性currentFiber
节点存在lastBaseUpdate
,将新的 update 对象拼接到currentFiber
节点的lastBaseUpdate
节点后面,也就是说新的 update 对象会成为currentFiber
节点新的lastBaseUpdat
节点
还原 workInProgress
节点执行的函数是 prepareFreshStack
,里面会用 currentFiber
节点的属性覆盖 workInProgress
节点,从而实现还原功能。所以就算 workInProgress
节点被重置,我们只要把 update 对象 合并到 currentFiber
节点上,还原的时候依然会存在于新的 workInProgress
节点
state 计算的连续性
问题现象
我们上面说到,低优先级任务重启,不能覆盖高优先级任务计算得到的值,且需要根据低优先级任务计算得到的 newState,作为高优先级的 baseState 再去执行一次高优先级任务。什么意思呢这是?
上面代码所产生的 update 对象如下
先执行
AUpdate
任务AUpdate
的优先级比BUpdate
的低,BUpdate
会打断AUpdate
的执行。那么
BUpdate
执行完,count 的值为 2 问题来了BUpdate
是后进来的,AUpdate
不能覆盖掉BUpdate
的结果AUpdate
执行的结果 count 会变成 1,那么BUpdate
的结果需要在此基础上计算,也就是要得到 3
这也就决定了我们要用队列的形式去存储所有 update 对象。update 对象的存储顺序决定了 state 计算的前后依赖性,从而保证状态的连续性和准确性
明确很重要的一点,优先级高低只会影响某个 update 对象 是否会提前执行,不会影响最终的 state 结果。最终的 state 结果还是由更新队列中 update 对象 的顺序决定的
如何解决
我们看到 processUpdateQueue
中有两部分都是在构造更新队列的
一部分是位于函数开头的,将 update 对象 合并到
currentFiber
节点一部分是位于函数末尾的,将
newBaseUpdate
赋值给workInProgress
节点这两部分双剑合璧就完美解决我们的需求,currentFiber
是作用于本轮更新,workInProgress
则作用于下一轮更新,因为双缓存机制的存在,在 commit 阶段 结尾,react 应用根节点的 current 指针就会指向workInProgress
节点,workInProgress
节点在下一轮更新就会变成currentFiber
节点。
这样无论是什么优先级,只要按顺序构造出更新队列,我就能计算出正确的 newState,同时利用队列的性质,保证 update 对象 间 state 计算 的连续性
评论