从行为上理解setState

814 阅读5分钟

setState为什么是异步的?

一般使用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出现差异,这是一个危险的现象⚠️。

setState的合并

来看一段代码,点击按钮以后输出什么?

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()会被第二个覆盖掉,第一次作为中间项是不会发生的,参见源码

TPomSe.png

如果你的setState()依赖于上一次的state的话,可以使用函数作为setState()的第一个参数,函数中接收的 state和props都保证为最新,它的返回值会与state进行浅合并。

this.setState((state, props) => {
  return {counter: state.counter + 1};
});

为什么函数就可以,因为多个setState()合并时,都会调用一次函数。

TP7mqA.png

强行使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

这里说四点useState的注意项,以下所说的setState为useState返回值里的setState。

  1. 异步的问题,先说结论,和类组件一样,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是因为闭包。

  2. 在组件后续的更新渲染中,useState 并不是不会执行,只是返回的第一个值将始终是更新后最新的state,并且React 会确保setState函数的标识是稳定的,不会在重新渲染时发生变化。这就是为什么可以安全地从hook的依赖列表中省略 setState。

  3. setState也可以接受一个函数,这个函数只有一个参数,不会接收props,也不会自动合并更新对象,并且不支持state更新之后的回调函数。

    setState(prevState => {
      return {...prevState, ...updatedValues};
    });
    
  4. useState接收的参数只会在组件的初始渲染中起作用,后续渲染时会被忽略。如果初始 state 需要通过复杂计算获得,则可以传入一个函数,在函数中计算并返回初始的state,此函数只在初始渲染时被调用:

    const [state, setState] = useState(() => {
      const initialState = someExpensiveComputation(props);
      return initialState;
    });