备注:以下内容针对React v18以下版本。
前言
setState到底是同步还是异步?很多人可能面试都被问到过,就好比这道代码输出题:
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
// 堆代码 duidaima.com
setTimeout(() => {
this.setState({
data: 'setTimeout'
})
console.log("setTimeout ", this.state.data);
})
}
这段代码的输出结果,第一个console.log会输出data,而第二个console.log会输出setTimeout。也就是第一次setState的时候,是异步更新的,而第二次setState的时候,它又变成了同步更新,是不是有点晕呢?我们去源码里看一下setState更新调度的时候到底做了些什么。
探针
setState被调用后最终会走到scheduleUpdateOnFiber函数中,那我们看一下这个函数做了些什么呢?
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的值呢?
我们随便找几个地方看看:
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则不会,所以我们在使用的时候更着重注意这一点。
举个例子:
// 类组件中
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的操作确实比较少见,还是先看一个案例:
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会发生什么:
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的运行理解对于日常开发的帮助也是很大的。