React setState同步异步问题

6,992 阅读6分钟

React setState同步异步问题


在函数组件中,我们最经常用到的就是useStateuseEffect。 今天在函数组件中使用useState发现了一些问题,setState的更新是异步的,我们先来看看class组件setState异步问题。

1.class组件中setState()异步问题

import React, { PureComponent } from 'react';

class App extends PureComponent {
  constructor(props) {
    super(props);
    this.state = {
      title: 'Hello Alice'
    }
  }
  changText() {
    this.setState({
      title: '你好呀,李银河'
    })
    console.log(this.state.title);  // Hello Alice
  }
  render() {
    return (
      <div>
        <h2>{this.state.title}</h2>
        <button onClick={e => {this.changText()}}>改变文本</button>
      </div>
    );
  }
}
export default App;

我们发现在changText()函数中,我们虽然已经setState给title一个新的值,但是在console.log()的时候打印的依然是原来state中的值。

可见setState是异步的,我们并不能在setState后马上拿到最新的结果

2.为什么要将setState设计为异步呢?

相信大家,刚接触setState的时候一样都会有这样的疑问,直接同步更新不就好了吗?

  • 然后我去找了很多资料都写的不是很清楚,最后在github上看到有人在讨论这个问题,而且这个问题
  • React核心成员(Redux的作者)Dan Abramov也有对应的回复,他在里面写了很长的篇幅来解释
  • github.com/facebook/re…

从Dan Abramov的回复中可以归纳出几点

  • setState设计为异步,可以显著的提升性能;
    • 如果每次调用 setState都进行一次更新,那么意味着render函数会被频繁调用,界面重新渲染,这样同步更新的效率是很低;
    • 如果我们知道可能会得到多个更新,最好批量更新。
  • 如果同步更新了state,但是还没有执行render函数,那么state和props不能保持同步;
    • state和props不能保持一致性,会在开发中产生很多的问题;

那么在class组件中如何可以获取到更新后的值呢?

  • setState()接受两个参数:第二个参数是一个回调函数,这个回调函数会在更新后会执行;
  • 格式如下:setState(partialState, callback)

我们将上面的代码修改一下,可以看到通过回调函数已经可以得到最新的结果了

changeText() {
  this.setState({
    message: "你好啊,李银河"
  }, () => {
    console.log(this.state.message); // 你好啊,李银河
  });
}

当然,我们也可以在生命周期函数实现:

componentDidUpdate(prevProps, provState, snapshot) {
  console.log(this.state.message);
}

3.setState一定是异步更新的吗?

其实不然,我们可以做下面两个测试

1.使用setTimeOut

changeText() {
  setTimeout(() => {
    this.setState({
      message: "你好啊,李银河"
    });
    console.log(this.state.message); // 你好啊,李银河
  }, 0);
}

2.使用原生DOM事件

componentDidMount() {
  const btnEl = document.getElementById("btn");
  btnEl.addEventListener('click', () => {
    this.setState({
      message: "你好啊,李银河"
    });
    console.log(this.state.message); // 你好啊,李银河
  })
}

可以分成两种情况:

  • 在组件生命周期或React合成事件中,setState是异步;
  • 在setTimeout或者原生dom事件中,setState是同步;

4.函数式组件通过hook,setState()异步问题

我们来看,我今天做的案例

import React, { useState, useEffect } from 'react';
import { layoutEmitter } from '@/utils/EventEmitter';

interface ItemProps {
    initNumber: number;
}
export default ({ initNumber = 0 }: ItemProps) => {
    const [state, setState] = useState(initNumber);
    
    return <button
        style={{ fontSize: '30px' }}
        onClick={() => {
            setState(state => state + 1)
            layoutEmitter.emit({ state });
        }}
    >EventEmitter {state} </button>
};

上述代码中,在button的onClick事件中setState,然后把state传给layoutEmitter.emit({ state }); 但是都得不到前面setState后的最新值,也是因setState是异步更新问题造成的,useState中的setState只有一个参数,并没有像class组件中的setState第二个参数是一个回调函数,可以在回调函数中实现同步更新

5.函数式组件实现同步更新

那我们要怎么实现同步更新呢?难道也是在setState(),的第二个参数中传入一个回调函数?

试了一下并不可以,而且报了一个警告

Warning: State updates from the useState() and useReducer() Hooks don't support the second callback argument. 

既然useState这个 Hook行不通,我们就换一个试试,既然都在说React现在全面拥抱Hook那肯定有它强大的地方

6.通过useEffect实现同步更新数据

再次的查找github中有没有相关的我问题,最后在一个讨论里也看到了Dan Abramov的回复,也建议我们通过useEffect来实现

github.com/facebook/re…

我们之前学过useEffect,它的第一个参数是一个回调函数,在默认情况下,每次更新都会执行这个回调函数

那我们就可以在按钮发生点击时setState() 然后在userEffect中实现传递参数,这样我们拿到的数据一定是最新的,那么我们动手尝试一下吧

import React, { useState, useEffect } from 'react';
import { layoutEmitter } from '@/utils/EventEmitter';

interface ItemProps {
    initNumber: number;
}
export default ({ initNumber = 0 }: ItemProps) => {

    const [state, setState] = useState(initNumber);

    useEffect(() => {
        layoutEmitter.emit({ state });
    }, [state])

    return <button
        style={{ fontSize: '30px' }}
        onClick={() => {
            setState(state => state + 1)
        }}
    >EventEmitter {state} </button>
};

代码写完,我们运行看看发现出错了

PLAINTEXT
Type error: _this.subscriptions not a function 

我们查看EventEmitter.tsindex.ts发现代码执行的顺序是是在index.ts中调用useSubscription后将this.subscriptions = subscription;然后emit再通过this来调用

type Subscription<T> = (val: T) => void;
class EventEmitter<T> {
    private subscriptions: { (arg0: T): void; (val: T): void; } | undefined;

    emit = (val: T) => {
        //@ts-ignore
        this.subscriptions(val);
    };

    useSubscription = (callback: Subscription<T>) => {
        function subscription(val: T) {
            if (callback) {
                callback(val);
            }
        }
        this.subscriptions = subscription;
    };
}
const layoutEmitter = new EventEmitter();
export { layoutEmitter };

而我们的index.ts使用了这个组件这就造成了,在index挂载到DOM上也会去执行下的useEffect,这样就造成了按钮还没点击,就已经掉调用了一次layoutEmitter.emit({ state });

而这时候this.subscriptions = subscription;还并没有执行到

export default () => {
    const [list, setList] = useState([]);

    useEffect(() => {
        layoutEmitter.useSubscription((data) => {
            list.push(data);
            console.log(data);
            console.log(list);
            const listData = [...list]
            setList(listData);
        });
    }, [])

    return (
        <div>
            <EventEmitterButton initNumber={11} />
            <p>list length:{list.length}</p>
            {
                list.map((item: ItemProps) => <p key={item.state}>{item.state}</p>)
            }
        </div>
    )
};

那我们要如何去解决这个问题呢?

useEffect的第二个参数 DependencyList是一个数组,可以帮助我们解决这个问题。

这个参数的作用是该useEffect在哪些state发生变化时,才重新执行;(受谁的影响)

我们来看看案例:

//1.第二个参数不填,在源码中deps?: DependencyList是一个可以选参数,我们不写也没关系
	  useEffect(()=>{
    console.log(props.number)
    setNumber(props.number)
  }) //所有更新都执行
  
//2.一个空的数组
  useEffect(()=>{
    console.log(props)
  },[]) //仅在挂载和卸载的时候执行

//3.定义为state
  const [state,setState] = useState(0)
  useEffect(()=>{
    console.log(state)
  },[state]) //count更新时执行

了解完了这些,我们接着完善我们的代码

export default ({ initNumber = 0 }: ItemProps) => {
    const [state, setState] = useState(initNumber);
    useEffect(() => {
        layoutEmitter.emit({ state });
    }, [state])
    return <button
        style={{ fontSize: '30px' }}
        onClick={() => {
            setState(state => state + 1)
            setFlag(false)
        }}
    >EventEmitter {state} </button>
};

但是这样我们还是不解决问题,因为不管DependencyList我们填什么,当index挂载到Dom上都会执行,然后传递过来一个initNumber,那是不是就没有办法操作了呢?

我们可以再定义一个useState,const [flag, setFlag] = useState(true);来控制当我们没有点击按钮时不触发,useEffect,然后将条件判断放在useEffect内这样我们就可以达到目的

ps:不能将useEffect放在条件判断语句内,这是Hook的规范
// 1. initNumber需要定义参数类型
export default ({ initNumber = 0 }: ItemProps) => {
    const [state, setState] = useState(initNumber);
    const [flag, setFlag] = useState(true);
    useEffect(() => {
        if (!flag) {
            layoutEmitter.emit({ state });
        }
    }, [state])
    return <button
        style={{ fontSize: '30px' }}
        onClick={() => {
            setState(state => state + 1)
            setFlag(false)
        }}
    >EventEmitter {state} </button>
};

这样我们就实现了需求,并且按钮发生点时,在页面也会渲染出正确的数据。

以上实现由于本人目前技术不足,另辟蹊径的方法,这样实现可能会带来一些问题。

6.关于React setState的探讨

在掘金上一篇关于React 中 setState 是一个宏任务还是微任务? 文章下面有这么一条评论:

说得通俗一点,setState是一个伪异步,或者可以称为defer,即延迟执行但本身还在一个事件循环,所以它的执行顺序在同步代码后、异步代码前。为什么会有这种现象?这就要说到react的合成事件了,react的批处理更新也得益于合成事件,可以试下脱离react事件,使用原生事件执行setState,你会得到同步的代码。