react16 - 半自动批处理
结论
执行顺序
- 合成事件和钩子函数中:异步
- 原生事件和setTimeout中:同步
批量处理
- 合成事件和钩子函数中的this.setState或者setState:会批量处理
- 合成事件和钩子函数中的this.setState或者setState里面写成函数:会批量处理
- 原生事件和setTimeout中任何情况:不会批量处理
源码分析
1. setState
1 | Component.prototype.setState = function(partialState, callback) { |
2. this.updater
this.updater
是在哪个地方进行赋值暂时不用关心,只需要知道他被赋值为 classComponentUpdater
。
classComponentUpdater
和 render
流程里面的 ReactDOM.render
中 scheduleRootUpdate
非常的相似,其实他们就是同一个更新原理。
1 | const classComponentUpdater = { |
状态更新都会创建一个保存更新状态相关内容的对象 Update
。在 render
阶段的 beginWork
中会根据 Update
计算新的 state
。
这里讲这个 Update
通过 enqueueUpdate
放到队列 UpdateQueue
。
3. requestWork
scheduleWork
里会执行 requestWork
方法。
requestWork
中可以看到有多个 if
判断,这里就是 setState
在不同的场景使用会出现同步和异步的根本原因。
1 | function requestWork(root, expirationTime) { |
场景1 - 合成事件
React有着一套自己的合成事件机制,在一个事件调用的时候会经过一些处理,这里不详细描述,最重要的一个函数就是 interactiveUpdates$1
,在执行一个事件的时候会先调用这个函数。
1 | handleClick(){ |
interactiveUpdates$1方法
- isBatchingUpdates = true;让setState不马上更新
- try finally 语句;先执行一个事件里的代码最后才更新
- isBatchingUpdates = previousIsBatchingUpdates;合成事件里setTimeout能马上更新的原因
1 | function interactiveUpdates$1(fn, a, b) { |
isBatchingUpdates变量
interactiveUpdates$1
这个方法中把 isBatchingUpdates
设为了 true
, 导致在 requestWork
方法中, isBatchingUpdates
为 true
,但是 isUnbatchingUpdates
是 false
,而被直接 return
了。
1 | //requestWork |
这就导致了 requestWork
根本没有执行到任何更新的函数,比如 performSyncWork
,但在最开始的 enqueueSetState
这个方法里还是已经将每一次更新都存到了一个 update
队列里。
所以合成事件里的 setState
不会马上更新,而是存入了一个更新队列里(enqueueUpdate)
try finally
interactiveUpdates$1
最后执行了一个 try finally
语法,会先执行 try
代码块中的语句,然后再执行 finally
中的代码,而 fn(a, b)
是在 try
代码块中执行相关的事件回调,而在 finally
里才有 performSyncWork()
;
也就是说我们写的事件监听函数在 try
中执行,但更新在 finally
里,这就导致了所谓的”异步”,state
并没有马上更新并渲染到UI上,而是等到事件执行完之后才更新的。
1 | try { |
场景2 - setTimeout
1 | class App extends Component { |
try
代码块执行到 setTimeout
的时候,这是一个宏任务,把它丢到列队里,并没有去执行,而是先执行的 finally
代码块。
等 finally
执行的时候会执行 isBatchingUpdates = previousIsBatchingUpdates
; 将 isBatchingUpdates
重置为了 false
。
导致最后下次事件循环的时候去执行队列里的 setState
时候, requestWork
走的是和原生事件一样的 expirationTime === Sync if
分支, 可以同步拿到最新的 state
值。
场景3 - 生命周期函数中的setState
三个全局变量:isRendering、isWorking、isCommitting
- isRendering:开始react更新就为true
- isWorking:进入reconciler阶段就为true、进入commit阶段就为true
- isCommitting:进入commit阶段就为true
render前生命周期属于reconciler阶段:isRendering = true、isWorking = true Fiber Reconciler 的执行阶段:
- 阶段一是生成 Fiber 树的渐进阶段,可以被打断。
- 阶段二是批量更新节点的阶段,不可被打断。
现在回过头来看 requestWork
里的第一个if判断:
1 | function requestWork(){ |
和合成事件一样,当 componentDidmount
执行的时候,isRendering
为 true
,react内部并没有更新就先 return
了,执行完 componentDidmount
后才去 commitUpdateQueue
更新。这就导致你在 componentDidmount
中 setState
完去 console.log
拿的结果还是更新前的值。
1 | class App extends Component { |
场景4 - 原生事件
1 | class App extends Component { |
原生事件的调用栈就比较简单了,因为没有走合成事件的那一大堆,直接触发 click
事件,到 requestWork
,在 requestWork
里由于 expirationTime === Sync
的原因,直接走了 performSyncWork
去更新,并不像合成事件或钩子函数中被 return
,所以当你在原生事件中 setState
后,能同步拿到更新后的 state
值。
场景5 - setState批量更新的情况
简单分析源码
React
加入 fiber
架构后,调度之前通过 enqueueUpdate
函数维护的 UpdateQueue
就是挂载在组件对应的 fiber
节点上,我们更新的通过调度最后会进入到 updateClassComponent
方法,里面最终会调用一个getStateFromUpdate
来获取最终的 state
状态。
getStateFromUpdate
函数外面是对 UpdateQueue
队列的一个 while
循环,比如我们连续 setState
三次,那每次都会创建一个 update
实例通过 enqueueUpdate
放入 fiber
的 UpdateQueue
中,这里就是把这三次的state
合并计算出一个最终的值以提高性能。
1 | while (update !== null) { |
getStateFromUpdate
函数主要功能是将存储在更新对象 update
上的 partialState
与上一次的 prevState
进行对象合并,生成一个全新的状态 state
。
1 | function getStateFromUpdate<State>( |
setState传入对象会合并对象
1 | class IncrementByObject extends React.Component { |
如果是一个 Object
,直接看最后的 Object.assign({}, prevState, partialState);
Object.assign
的作用: 主要是将所有可枚举属性的值从一个或多个源对象复制到目标对象,同时返回目标对象。如果目标对象中的属性具有相同的键,则属性将被源对象中的属性覆盖。后来的源对象的属性将类似地覆盖早先的属性。
之前提过在合成事件中或者在生命周期了 state
是不会马上刷新的,是在事件执行完后也就是 try finally
的 finally
里才真正刷新,这就导致了每次 Object.assign
的 partialState
都是 this.state.count + 1
,而 state
的 count
在三次 setState
的时候都不会改变都是0,所以计算过程可以简化如下:
1 | Object.assign({}, {count:0}, {count:1}); |
很明显最终的 state
的 count
只会增加 1。
setState传入函数
1 | increment() { |
如果是一个回调函数function 可以发现 if (typeof payload === 'function')
这里对传入的是否是方法做了判断,如果是方法,就执行
1 | partialState = payload.call(instance, prevState, nextProps); |
instance
对于类组件来说,这里保存类组件的实例在外层的 updateClassInstance
函数中 const instance = workInProgress.stateNode;
赋值的。
这里其实只看 payload
和 prevState
就行了,payload
是我们通过 setState
传入的回调函数,返回最新的 state
,while
循环调用 getStateFromUpdate
每次传入的是 resultState
,也就是说接受的 state
都是上一轮计算之后的新值,因此循环计算的过程可以简化如下:
1 | Object.assign({}, {count:0}, {count:1}); |
可以看到最终的 state
的 count
为增加 3。
react18 - 自动批处理
结论
执行顺序
- 合成事件和钩子函数中:异步
- 原生事件和setTimeout中:异步
批量处理
- 合成事件和钩子函数中:会批量处理
- setState里面写成函数:会批量处理
- 原生事件和setTimeout中:会批量处理
场景
具体解析可以看 《react18.2优先级和批处理场景解析》
1 | export default function App() { |
概念
批处理:React 会尝试将同一上下文中触发的更新合并为一个更新。
就 React18 而言当说到批处理的时候,需同时具备以下三者:
- 包括了多个react更新
- 每个更新具有相同的优先级
- 每个更新都是待执行的
这样做的好处也显而易见:
- 合并不必要的更新,减少更新流程调用次数
- 状态按顺序保存下来,更新时不会出现「竞争问题」
- 最终触发的更新是异步流程,减少浏览器掉帧可能性
1. 更新
对于 hook
有更新队列,对于 react
也有相应的更新(通常伴随着组件render),当然对浏览器还存在页面视图的更新。
当我们调用dispatch或者setState时,上述三种更新都是有涉及的。但是要特别指出的是,批处理中的更新就是指 react
的更新,包含了render,commit阶段等。在后续的批处理部分你将看到三者的差异。
如果我们看 dispatchSetState
的源码,会发现它们主要做了两件事:
- 记录一次
hook
更新(enqueueConcurrentHookUpdate
) - 调度一次
react
更新(scheduleUpdateOnFiber
)
批中的更新就是指调度一次 react
更新 scheduleUpdateOnFiber
1 | // 以下是 dispatchReducerAction 中同样包含的逻辑 |
2. 优先级
在更新部分的相关源码示例中,可以看到 lane
字段,它表示的就是这次更新的优先级。只有优先级相同的多个更新才在一个批中,与之相应的就是这些更新被批处理,反之则不然。
一般而言,如果优先级没有被手动改变,那么相同场景下多次调用 setState
或者 dispatch
对应的更新优先级是相同的。
例外的情况是具有一整个序列而非单一的优先级,像 TransitionLanes
和 RetryLanes
。以 TransitionLanes
为例,它们包含了许多个优先级并不相同并且依次排列的 lane
,但是在 render
场景下,这些 lane
是一起被处理的。
像下面这样的示例中的更新是不会被视为同一批的,startTransition
改变了第二个更新的优先级:
1 | setCount(count + 1) |
3. 待执行
待执行指的是已经调度但还未被执行。通常执行相对于调度而言是异步的。假如两个更新具有相同的优先级,那么:
- 只要一个已执行,另一个未执行,无法批处理
- 只要都未执行,就能批处理(一些异步场景可能带来迷惑性)
对于第一点,当我们手动调用同步执行更新的api时,后续的更新就无法与同步的更新成批,在下面的示例中,你会发现点击将带来两次render。
1 | export default function App() { |
flushSync
可以使更新同步地被执行,这样一来,第二个 setCount
带来的更新与第一个 setCount
的更新无法被批处理,因为 setCount(count + 2)
调用时,第一个更新已经执行完了。
对于第二点,考虑到js事件循环带来的复杂异步特性,在一些让人意想不到的场景也能批处理,下面是一个有趣的示例。
1 | export default function App() { |
click事件对应的更新优先级是被调度在微任务中的,而mouseEnter事件则是另一类。
源码分析
1. setState
setState
所做的就是:
- 将
hook
更新加入更新队列 - 尝试调度一次
react
更新
1 | function dispatchSetState<S, A>( |
2. scheduleUpdateOnFiber
忽略一些琐碎的细节后,可以发现这个函数的核心逻辑甚至更简单:
- 标记一次具有某一优先级的更新(markRootUpdated)
- 调用ensureRootIsScheduled
1 | export function scheduleUpdateOnFiber( |
3. ensureRootIsScheduled
1 | export function ensureRootIsScheduled(root: FiberRoot, currentTime: number) { |
4. processRootScheduleInMicrotask
1 | while (root !== null) { |
5. scheduleTaskForRootDuringMicrotask
1 | // 获取当前所有优先级中最高的优先级 |
关于批处理有三点:
- 多次调用
setState
时候,在ensureRootIsScheduled
中通过didScheduleMicrotask
标记,第一次进入标记为true
,再次进入便不再调度微任务触发processRootScheduleInMicrotask
- 当触发后续微任务触发
processRootScheduleInMicrotask
方法,通过getNextLanes
和getHighestPriorityLane
拿到本次应该(不一定是setState时的那个)更新的优先级newCallbackPriority
- 如果是同步优先级,直接return,因为
processRootScheduleInMicrotask
方法最后会调用flushSyncWorkOnAllRoots
执行一遍同步任务, 否则对比上次等待的更新和本次更新的优先级,即existingCallbackPriority === newCallbackPriority
,如果相等,则提前return,否则通过scheduleCallback
调度更新流程