前言
首先明确一下:
所谓的同步还是异步其实指的是调用setState
后能否马上得到更新后的值,即是否立即调用render
函数渲染视图;
能得到最新值则为同步,不能得到最新值则为异步;
而不是指的setState
这个函数是同步还是异步,单纯的说 setState
函数肯定是同步的。
开始阅读前,有两点需要注意:
- 而且因为
hooks
存在闭包的问题,容易混淆视听,这里先暂时使用class
组件的形式作为示例,当完全明白class
组件的setState
原理后,再去深入看hooks
你会有一种原来如此的感觉。 - 因为React v18 引入了自动批处理功能,
setState
的表现与 v18 版本以下的版本完全不一样,所以本文会先讲解 v17 版本的setState
原理,之后再对比 v18 和 v17 版本的差异性。
大家好,我是【前端探险家克鲁】。微信公众号、知乎、掘金、CSDN同名,欢迎查看我的个人简介,一起学习提升。
注意以下代码运行在 react@17.0.2
react-dom@17.0.2
下。
一道面试题
下面先来看一道经典的面试题:以下代码中,点击按钮后控制台输出什么?
javascript
import React from 'react';
import './App.css';
class AppClass extends React.Component {
state = {
count: 0,
};
handleClick = () => {
this.setState({ count: 1 });
console.log('count: ', this.state.count);
this.setState({ count: 2 });
console.log('count: ', this.state.count);
setTimeout(() => {
this.setState({ count: 3 });
console.log('count: ', this.state.count);
this.setState({ count: 4 });
console.log('count: ', this.state.count);
}, 0);
};
render() {
return (
<div className='App'>
<button onClick={this.handleClick}>count = {this.state.count}</button>
</div>
);
}
}
export default AppClass;
思考下,你的答案是什么?看下真实的控制台输出是什么:
分析下:
- 第一个输出
count: 0
,说明setState
是异步执行的,所以在调用之后打印count
还是初始值0
- 第二个输出还是
count: 0
,说明setState
还是异步执行的 - 第三个输出
count: 3
,而且在打印语句前正是调用setState
将count
置为了3
,很奇怪,这里的setState
是同步执行的 - 第四个输出
count: 4
,而且前面也正是调用setState
将count
置为了4
, 这里setState
也是同步执行的
是不是有点晕,不慌,继续往下看,一点点理清思路。
结论
先说下结论,带着疑问和结论去分析问题更好理解:
在react
可调度范围内的setState
就是异步的,反之,则为同步
问:什么是react
可调度范围内呢?
答:react
合成事件内同步执行的setState
就是可调度范围。
问:什么是react
可调度范围外呢?
答:宏任务:setTimeout
,微任务:.then
,或直接在DOM元素上绑定的事件等都是react
可调度范围外。
有了结论的加持,再来分析下以上的输出:
handleClick
函数是react
的合成事件,所以其内部的setState
是异步的- 进入
handleClick
函数内部,发现前两个setState
是没有被setTimeout
包裹的,在调度范围内,故表现为异步,所以前两次的输出都是0
- 还有两个
setState
是在setTimeout
内的,不在react
调度范围内,故表现为同步,所以每次setState
执行后都可以立即获取到更新后的值。
深入原理
从合成事件入手,react
中所有的合成事件都会经过如下函数处理:
scss
/* 所有的事件都将经过此函数统一处理 */
function dispatchEventForLegacyPluginEventSystem(){
// handleTopLevel 事件处理函数
batchedEventUpdates(handleTopLevel, bookKeeping);
}
重点看下这个batchedEventUpdates
函数
javascript
function batchedEventUpdates(fn,a){
/* 开启批量更新 */
isBatchingEventUpdates = true;
try {
/* 这里执行了的事件处理函数, 比如在一次点击事件中触发setState,那么它将在这个函数内执行 */
return batchedEventUpdatesImpl(fn, a, b);
} finally {
/* 完成一次事件,批量更新 */
isBatchingEventUpdates = false;
}
}
如上可以分析出
流程在 React 事件执行之前通过 isBatchingEventUpdates=true
打开开关,开启事件批量更新,这里也就是上面所说的react
可调度范围内。
当该事件结束,再通过 isBatchingEventUpdates = false
关闭开关,表示当前调度结束。
当事件函数中存在异步代码即setTimeout等时,同步的batchedEventUpdatesImpl
函数已经执行完成,此时的isBatchingEventUpdates
标志已经被置为false
。
而在用户自定义的事件函数中,根本无法进入react的合成事件中,就不会开启批量更新。
在 batchedEventUpdatesImpl
函数中会去调度Fiber
节点,调度主要函数代码如下:
scss
export function scheduleUpdateOnFiber(
fiber: Fiber,
expirationTime: ExpirationTime,
) {
const priorityLevel = getCurrentPriorityLevel();
if (expirationTime === Sync) {
if (
// Check if we're inside unbatchedUpdates
(executionContext & LegacyUnbatchedContext) !== NoContext &&
// Check if we're not already rendering
(executionContext & (RenderContext | CommitContext)) === NoContext
) {
performSyncWorkOnRoot(root);
} else {
ensureRootIsScheduled(root);
schedulePendingInteractions(root, expirationTime);
// 当前已经调度完成,启动同步刷新
if (executionContext === NoContext) {
// Flush the synchronous work now, unless we're already working or inside
// a batch. This is intentionally inside scheduleUpdateOnFiber instead of
// scheduleCallbackForFiber to preserve the ability to schedule a callback
// without immediately flushing it. We only do this for user-initiated
// updates, to preserve historical behavior of legacy mode.
flushSyncCallbackQueue();
}
}
} else {
// Schedule a discrete update but only if it's not Sync.
if (
(executionContext & DiscreteEventContext) !== NoContext &&
// Only updates at user-blocking priority or greater are considered
// discrete, even inside a discrete event.
(priorityLevel === UserBlockingPriority ||
priorityLevel === ImmediatePriority)
) {
// This is the result of a discrete event. Track the lowest priority
// discrete update per root so we can flush them early, if needed.
if (rootsWithPendingDiscreteUpdates === null) {
rootsWithPendingDiscreteUpdates = new Map([[root, expirationTime]]);
} else {
const lastDiscreteTime = rootsWithPendingDiscreteUpdates.get(root);
if (
lastDiscreteTime === undefined ||
lastDiscreteTime > expirationTime
) {
rootsWithPendingDiscreteUpdates.set(root, expirationTime);
}
}
}
// Schedule other updates after in case the callback is sync.
ensureRootIsScheduled(root);
schedulePendingInteractions(root, expirationTime);
}
}
重点看下以下代码并翻译下注释:
scss
// 当前已经调度完成,启动同步刷新
if (executionContext === NoContext) {
// 立即刷新所有同步工作,除非我们已经在调度中或处于批处理中。
// 这段代码故意放在 scheduleUpdateOnFiber 函数中,而不是scheduleCallbackForFiber 中,以保留在不立即刷新它的情况下调度回调的能力。我们只对用户发起的更新执行此操作,以保留传统模式的历史行为。
flushSyncCallbackQueue();
}
上面的代码表示如果当前react
处在空闲状态即没有进行调度任务时,则启用同步刷新。
总结
当React的数据变化在合成事件中触发时:
- React通过设置全局变量
isBatchingEventUpdates
来标志当前的变化是否发生在React的可调度范围内。 - 如果在可调度范围内,那么将开启批量更新,即表现为异步刷新。
- 如果不在可调度范围内,那么将进入
flushSyncCallbackQueue
函数进行同步刷新。 - 由于只有在React合成事件中才会设置
isBatchingEventUpdates
标志,因此像setTimeout
、自定义监听事件
、.then
等触发的数据更新都无法触发批处理,即表现为同步刷新。
后记
React v18版本即将到来,敬请期待......