一般使用setState()
时,React并不会保证state会立刻更新,这些操作将延迟批量调用。
这两个词很重要,批量就是延迟的原因。
事实上,我们应该将setState()
视为更新组件的请求而不是立即更新的命令,这一点与Vue不同,不论是Vue2的Object.definedProperty
还是Vue3的Proxy
,都拦截了对象的setter,所以在数据被改变时会被立刻拦截并命令组件开始更新。
为什么React要设计成这样?为了性能(并不是说Vue性能不好,只是框架思路不同)
假设setState()
是同步的,如果在一次点击函数中更新了两次state,组件就会被渲染两次,若把setState()
收集起来统一处理,组件就只需要被渲染一次。这就是setState()
异步的思路,在一个周期内,每遇到一个setState()
React会将它放到队列中,最后会对多个setState()
进行批处理。
目前,一个周期代表的是合成事件和生命周期这些由React管理的过程(我自己称为React周期),也就是说,只有这些场景下的setState()
才是异步的,比如dom原生事件、定时器延时器、promise回调等位置的setState()
仍然是同步的。
异步意味着在setState()
立即使用state是有问题的,这个时候可以使用componentDisUpdate
,或者使用setState()
的第二个参数(回调函数),该回调函数会在state更新后触发,函数组件可以给hook加依赖项。
问题来了,为什么不可以直接更新state,最后统一更新组件呢?React提供的对象(state、props)在组件内部应该是保持一致的,我们没有办法在父组件不渲染的情况下改变props,那么如果state更新了页面却不重新渲染,props就可能会与state出现差异,这是一个危险的现象⚠️。
来看一段代码,点击按钮以后输出什么?
export default class App extends Component {
constructor() {
super();
this.state = {
count: 0,
};
}
increase = () => {
this.setState({ count: this.state.count + 1 });
console.log(this.state.count);
this.setState({ count: this.state.count + 1 });
console.log(this.state.count);
setTimeout(() => {
this.setState({ count: this.state.count + 1 });
console.log(this.state.count);
this.setState({ count: this.state.count + 1 });
console.log(this.state.count);
});
};
render() {
return (
<div className="App">
<button onClick={this.increase}>改变state</button>
</div>
);
}
}
答案是0 0 2 3。前两次是0可以理解,合成事件里setState()
是异步的。那为什么不是0 0 3 4?
以前我认为是因为异步和词法作用域,前两次setState()
接受对象里的count都是0,两次0+1都是1,所以第三次相当于是1+1。其实我依然认为这么想并没错,只是并没有这么简单,还有一层原因:setState()
在批处理时会合并。在内部React是这样处理的:
Object.assign(
previousState,
{count: state.count + 1},
{count: state.count + 1},
...
)
也就是说第一个setState()
会被第二个覆盖掉,第一次作为中间项是不会发生的,参见源码
如果你的setState()
依赖于上一次的state的话,可以使用函数作为setState()
的第一个参数,函数中接收的 state和props都保证为最新,它的返回值会与state进行浅合并。
this.setState((state, props) => {
return {counter: state.counter + 1};
});
为什么函数就可以,因为多个setState()
合并时,都会调用一次函数。
既然setState()
的异步是有益的,在一些本会同步的位置也想异步的话,可以使用ReactDOM.unstable_batchedUpdates
。
React内部事件处理程序都被包装在unstable_batchedUpdates
其中,这就是默认情况下它们被批处理的原因。
但请注意,这个api是不稳定的,在未来的版本发展中很有可能会发生变化。
setTimeout(() => {
ReactDOM.unstable_batchedUpdates(() => {
this.setState({ count: this.state.count + 1 });
console.log(this.state.count);
this.setState({ count: this.state.count + 1 });
console.log(this.state.count);
});
});
这里说四点useState
的注意项,以下所说的setState为useState
返回值里的setState。
异步的问题,先说结论,和类组件一样,setState在React周期(这个词上面解释过)内是异步的,其他地方是同步的。看一个例子
export default function FunCpn() {
const [count, setCount] = useState(0);
useLayoutEffect(() => {
console.log("object");
});
const handleClick = () => {
// Promise.resolve(1).then(() => {
setCount(1);
console.log(count);
setCount(2);
console.log(count);
// });
};
return (
<div>
<button onClick={handleClick}>ssssssss</button>
</div>
);
}
上面的点击函数触发时,useLayoutEffect
只触发一次,两次输出count也都是0,而且输出顺序是0 0 object
符合预期。但是当加入Promise回调时,上面说过类组件的话就会变成同步调用,但在这里输出的count都是0,感觉是异步的,但useLayoutEffect
却被调用了两次,而且输出顺序为object 0 object 0
,又说明是同步的。其实这里确实是同步的,count输出0是因为闭包。
在组件后续的更新渲染中,useState
并不是不会执行,只是返回的第一个值将始终是更新后最新的state,并且React 会确保setState函数的标识是稳定的,不会在重新渲染时发生变化。这就是为什么可以安全地从hook的依赖列表中省略 setState。
setState也可以接受一个函数,这个函数只有一个参数,不会接收props,也不会自动合并更新对象,并且不支持state更新之后的回调函数。
setState(prevState => {
return {...prevState, ...updatedValues};
});
useState
接收的参数只会在组件的初始渲染中起作用,后续渲染时会被忽略。如果初始 state 需要通过复杂计算获得,则可以传入一个函数,在函数中计算并返回初始的state,此函数只在初始渲染时被调用:
const [state, setState] = useState(() => {
const initialState = someExpensiveComputation(props);
return initialState;
});