问:React的useState和setState到底是同步还是异步呢?
先来思考一个老生常谈的问题,setState是同步还是异步?
再深入思考一下,useState是同步还是异步呢?
我们来写几个 demo 试验一下。
先看 useState
同步和异步情况下,连续执行两个 useState 示例
function Component() {
const [a, setA] = useState(1)
const [b, setB] = useState('b')
console.log('render')
function handleClickWithPromise() {
Promise.resolve().then(() => {
setA((a) => a + 1)
setB('bb')
})
}
function handleClickWithoutPromise() {
setA((a) => a + 1)
setB('bb')
}
return (
<Fragment>
<button onClick={handleClickWithPromise}>
{a}-{b} 异步执行 </button>
<button onClick={handleClickWithoutPromise}>
{a}-{b} 同步执行 </button>
</Fragment>
)
}
结论:
- 当点击
同步执行
按钮时,只重新 render 了一次 - 当点击
异步执行
按钮时,render 了两次
同步和异步情况下,连续执行两次同一个 useState 示例
function Component() {
const [a, setA] = useState(1)
console.log('a', a)
function handleClickWithPromise() {
Promise.resolve().then(() => {
setA((a) => a + 1)
setA((a) => a + 1)
})
}
function handleClickWithoutPromise() {
setA((a) => a + 1)
setA((a) => a + 1)
}
return (
<Fragment>
<button onClick={handleClickWithPromise}>{a} 异步执行</button>
<button onClick={handleClickWithoutPromise}>{a} 同步执行</button>
</Fragment>
)
}
- 当点击
同步执行
按钮时,两次 setA 都执行,但合并 render 了一次,打印 3 - 当点击
异步执行
按钮时,两次 setA 各自 render 一次,分别打印 2,3
再看 setState
同步和异步情况下,连续执行两个 setState 示例
class Component extends React.Component {
constructor(props) {
super(props)
this.state = {
a: 1,
b: 'b',
}
}
handleClickWithPromise = () => {
Promise.resolve().then(() => {
this.setState({...this.state, a: 'aa'})
this.setState({...this.state, b: 'bb'})
})
}
handleClickWithoutPromise = () => {
this.setState({...this.state, a: 'aa'})
this.setState({...this.state, b: 'bb'})
}
render() {
console.log('render')
return (
<Fragment>
<button onClick={this.handleClickWithPromise}>异步执行</button>
<button onClick={this.handleClickWithoutPromise}>同步执行</button>
</Fragment>
)
}
}
- 当点击
同步执行
按钮时,只重新 render 了一次 - 当点击
异步执行
按钮时,render 了两次
参考 前端进阶面试题详细解答
跟useState的结果一样
同步和异步情况下,连续执行两次同一个 setState 示例
class Component extends React.Component {
constructor(props) {
super(props)
this.state = {
a: 1,
}
}
handleClickWithPromise = () => {
Promise.resolve().then(() => {
this.setState({a: this.state.a + 1})
this.setState({a: this.state.a + 1})
})
}
handleClickWithoutPromise = () => {
this.setState({a: this.state.a + 1})
this.setState({a: this.state.a + 1})
}
render() {
console.log('a', this.state.a)
return (
<Fragment>
<button onClick={this.handleClickWithPromise}>异步执行</button>
<button onClick={this.handleClickWithoutPromise}>同步执行</button>
</Fragment>
)
}
}
- 当点击
同步执行
按钮时,两次 setState 合并,只执行了最后一次,打印 2 - 当点击
异步执行
按钮时,两次 setState 各自 render 一次,分别打印 2,3
这里跟useState不同,同步执行时useState也会对state进行逐个处理,而setState则只会处理最后一次
为什么会有同步执行和异步执行结果不同呢?
这里就涉及到 react 的 batchUpdate 机制,合并更新。
- 首先,为什么需要合并更新呢?
如果没有合并更新,在每次执行 useState 的时候,组件都要重新 render 一次,会造成无效渲染,浪费时间(因为最后一次渲染会覆盖掉前面所有的渲染效果)。
所以 react 会把一些可以一起更新的 useState/setState 放在一起,进行合并更新。
- 怎么进行合并更新
这里 react 用到了事务机制。
React 中的 Batch Update 是通过「Transaction」实现的。在 React 源码关于 Transaction 的部分,用一大段文字及一幅字符画解释了 Transaction 的作用:
* wrappers (injected at creation time)
* + +
* | |
* +-----------------|--------|--------------+
* | v | |
* | +---------------+ | |
* | +--| wrapper1 |---|----+ |
* | | +---------------+ v | |
* | | +-------------+ | |
* | | +----| wrapper2 |--------+ |
* | | | +-------------+ | | |
* | | | | | |
* | v v v v | wrapper
* | +---+ +---+ +---------+ +---+ +---+ | invariants
* perform(anyMethod) | | | | | | | | | | | | maintained
* +----------------->|-|---|-|---|-->|anyMethod|---|---|-|---|-|-------->
* | | | | | | | | | | | |
* | | | | | | | | | | | |
* | | | | | | | | | | | |
* | +---+ +---+ +---------+ +---+ +---+ |
* | initialize close |
* +-----------------------------------------+
用大白话说就是在实际的 useState/setState 前后各加了段逻辑给包了起来。只要是在同一个事务中的 setState 会进行合并(注意,useState不会进行state的合并)处理。
- 为什么 setTimeout 不能进行事务操作
由于 react 的事件委托机制,调用 onClick 执行的事件,是处于 react 的控制范围的。
而 setTimeout 已经超出了 react 的控制范围,react 无法对 setTimeout 的代码前后加上事务逻辑(除非 react 重写 setTimeout)。
所以当遇到 setTimeout/setInterval/Promise.then(fn)/fetch 回调/xhr 网络回调
时,react 都是无法控制的。
相关react 源码如下:
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 就是同步的
总结
我们来总结一下上述实验的结果:
- 在正常的react的事件流里(如onClick等)
- setState和useState是异步执行的(不会立即更新state的结果)
- 多次执行setState和useState,只会调用一次重新渲染render
- 不同的是,setState会进行state的合并,而useState则不会
- 在setTimeout,Promise.then等异步事件中
- setState和useState是同步执行的(立即更新state的结果)
- 多次执行setState和useState,每一次的执行setState和useState,都会调用一次render
是不是感觉有点绕,自己写一下代码体验一下就好了~