您好,如果喜欢我的文章,可以关注我的公众号「量子前端」,将不定期关注推送前端好文~
以下内容针对React v18以下版本。
前言
setState
到底是同步还是异步?很多人可能面试都被问到过,就好比这道代码输出题:
typescript
constructor(props) {
super(props);
this.state = {
data: 'data'
}
}
componentDidMount() {
this.setState({
data: 'did mount state'
})
console.log("did mount state ", this.state.data);
// did mount state data
setTimeout(() => {
this.setState({
data: 'setTimeout'
})
console.log("setTimeout ", this.state.data);
})
}
这段代码的输出结果,第一个console.log
会输出data
,而第二个console.log
会输出setTimeout
。也就是第一次setState
的时候,是异步更新的,而第二次setState
的时候,它又变成了同步更新,是不是有点晕呢?我们去源码里看一下setState
更新调度的时候到底做了些什么。
探针
setState
被调用后最终会走到scheduleUpdateOnFiber
函数中,那我们看一下这个函数做了些什么呢?
typescript
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();
}
executionContext
代表了React
目前所处的阶段,而NoContext
你可以理解为是React
没活干的状态,而flushSyncCallbackQueue
里面就会去同步调用我们的this.setState
,也就是说同步更新我们的state
。所以,我们已经知道了,当executionContext
为NoContext
的时候,我们的setState
就是同步的。那什么地方会改变executionContext
的值呢?
我们随便找几个地方看看:
typescript
function batchedEventUpdates$1(fn, a) {
var prevExecutionContext = executionContext;
executionContext |= EventContext;
// ...省略
}
function batchedUpdates$1(fn, a) {
var prevExecutionContext = executionContext;
executionContext |= BatchedContext;
// ...省略
}
当React
进入它自己的调度步骤时,会给executionContext
赋予不同的枚举,表示不同的操作和目前React所处的调度状态,而executionContext
的初始值就是NoContext
,所以只要你不进入React
的调度流程,这个值就是NoContext
,那你的setState
就是同步的。
那在useState
呢?自从React
出了hooks
之后,函数组件也能拥有自己的状态,那么如果我们调用它的第二个参数去setState
更改状态,和类组件的this.setState
是一样的效果吗?
没错,因为useState
的set
函数最终也会走到scheduleUpdateOnFiber
,所以在这一点上和this.setState
是没有区别的,相当于使用了一个通用函数。
但是值得注意的是,当我们调用this.setState
的时候,React
会自动帮我们做一个state
的合并,而hook则不会,所以我们在使用的时候更着重注意这一点。
举个例子:
typescript
// 类组件中
state = {
data: "data",
data1: "data1",
};
this.setState({ data: "new data" });
console.log(state);
// { data: 'new data',data1: 'data1' }
// 函数组件中
const [state, setState] = useState({ data: "data", data1: "data1" });
setState({ data: "new data" });
console.log(state);
// { data: 'new data' }
但是如果你自己去尝试在函数组件中的setTimeout
中去调用setState
之后,打印state
,你会发现并没有改变,这时你就会很疑惑,为什么呢?这不是同步执行的么?这其实是一个闭包问题,实际上拿到的还是上一个state
,那打印出来的值自然也还是上一次的,此时真正的state
已经改变了。
相信看到这里对于标题你已经有了答案了吧?只要你进入了React
的调度流程,那就是异步的。只要你没有进入React的调度流程(executionContext === NoContext
),那就是同步的。什么情况不会进入React
的调度流程?setTimeout、setInterval
,直接在DOM上绑定原生事件等。这些都不会走React
的调度流程,你在这种情况下调用setState
,那这次setState
就是同步的。否则就是异步的。而setState
同步执行的情况下,DOM也会被同步执行更新,也就意味着如果多次setState
会导致多次更新,这也是毫无意义且浪费性能的。
在setTimeout、原生事件
中调用setState
的操作确实比较少见,还是先看一个案例:
typescript
const fetch = async () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve('fetch data');
}, 300);
})
}
componentDidMount() {
(async () => {
const data = await this.fetch();
this.setState({data});
console.log("data: ", this.state);
// data: fetch data
})()
}
在生命周期componentDidMount
挂载阶段时发送了一个网络请求,然后拿到请求响应结果后再调用setState
,这时候我们用了async/await
来处理。
这时候我们会发现其实setState
变成同步了,为什么呢?componentDidMount
不是React
的内置钩子函数吗?这难道都不算React
的调度环境吗?因为componentDidMount
执行完毕后,就已经退出了React
调度,而请求的代码是异步的,相当于队列中的宏任务还没处理完毕,等结果请求回来以后,setState
才会执行。async
函数中await
后面的代码其实是异步执行的,这就和在setTimeout
中执行setState
是同样的效果,所以我们的setState
就变成同步的了。
那如果变成同步的情况下滥用setState
会出现什么坏处呢?我们来看看在非React调度环境
下调用setState
会发生什么:
typescript
this.state = {
data: 'init data',
}
componentDidMount() {
setTimeout(() => {
this.setState({data: 'data 1'});
// console.log("dom value", document.querySelector('#state').innerHTML);
this.setState({data: 'data 2'});
// console.log("dom value", document.querySelector('#state').innerHTML);
this.setState({data: 'data 3'});
// console.log("dom value", document.querySelector('#state').innerHTML);
}, 1000)
}
render() {
return (
<div id="state">
{this.state.data}
</div>
);
}
我们先看一下console的输出结果:
可以看到,console
的结果是符合预期的,在setTimeout
中,属于非React调度环境
,在1秒后同步打印了三个最新的结果。
但是界面上出现了从最早的init data
直接变成了data 3
,这是为啥呢?我们每次都能在DOM上拿到最新的state
,是因为React
已经把state
的修改同步更新了,但是为什么界面上没有显示出来?因为对于浏览器来说,渲染线程
和JS线程
是互斥阻塞的,React
代码运行调度时,浏览器是无法渲染的。所以实际上我们把DOM更新了,但是state
又被修改了,React
只好再做一次更新,这样反复了三次,最终React
代码执行完毕后,浏览器才把最终的结果渲染到了页面上,也意味着前两次更新是无用无意义的。
我们把setTimeout
去掉,就会发现三次输出都为init data
,因此此时的setState
就变成了异步的,会把三次更新批量合并到一次去执行,在渲染上也不会出现问题。所以当setState
变成同步时就要注意,不要写出让React
多次更新组件的代码,这样是毫无意义的。
结尾
React
已经帮助我们做了很多优化措施,但是有时代码不同的实现方式导致了React的性能优化失败,相当于我们自己做了反优化,因此深入理解React的运行理解对于日常开发的帮助也是很大的。
如果喜欢我的文章,可以关注我的公众号「量子前端」,将不定期关注推送前端好文~