6

深入剖析setState同步异步机制

 3 years ago
source link: http://www.cnblogs.com/zhangnan35/p/14290542.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

关于 setState

setState 的更新是同步还是异步,一直是人们津津乐道的话题。不过,实际上如果我们需要用到更新后的状态值,并不需要强依赖其同步/异步更新机制。在类组件中,我们可以通过 this.setState 的第二参数、 componentDidMountcomponentDidUpdate 等手段来取得更新后的值;而在函数式组件中,则可以通过 useEffect 来获取更新后的状态。所以这个问题,其实有点无聊。

不过,既然大家都这么乐于讨论,今天我们就系统地梳理一下这个问题,主要分为两方面来说:

class-component
function-component

类组件中的 this.setState

在类组件中,这个问题的答案是多样的,首先抛第一个结论:

  • legacy 模式中,更新可能为同步,也可能为异步;
  • concurrent 模式中,一定是异步。

问题一、legacy 模式和 concurrent 模式是什么鬼?

  • 通过 ReactDOM.render(<App />, rootNode) 方式创建应用,则为 legacy 模式,这也是 create-react-app 目前采用的默认模式;

  • 通过 ReactDOM.unstable_createRoot(rootNode).render(<App />) 方式创建的应用,则为 concurrent模式 ,这个模式目前只是一个实验阶段的产物,还不成熟。

legacy 模式下可能同步,也可能异步?

是的,这不是玄学,我们来先抛出结论,再来逐步解释它。

this.setState
this.setState

实验代码如下:

class StateDemo extends React.Component {
    constructor(props) {
        super(props)
        this.state = {
            count: 0
        }
    }
    render() {
        return <div>
            <p>{this.state.count}</p>
            <button onClick={this.increase}>累加</button>
        </div>
    }
    increase = () => {
        this.setState({
            count: this.state.count + 1
        })
        // 异步的,拿不到最新值
        console.log('count', this.state.count)

        // setTimeout 中 setState 是同步的
        setTimeout(() => {
            this.setState({
                count: this.state.count + 1
            })
            // 同步的,可以拿到
            console.log('count in setTimeout', this.state.count)
        }, 0)
    }

    bodyClickHandler = () => {
        this.setState({
            count: this.state.count + 1
        })
        // 可以取到最新值
        console.log('count in body event', this.state.count)
    }

    componentDidMount() {
        // 自己定义的 DOM 事件,setState 是同步的
        document.body.addEventListener('click', this.bodyClickHandler)
    }
    componentWillUnmount() {
        // 及时销毁自定义 DOM 事件
        document.body.removeEventListener('click', this.bodyClickHandler)
    }
}

要解答上述现象,就必须了解 setState 的主流程,以及 react 中的 batchUpdate 机制。

首先我们来看看 setState 的主流程:

  1. 调用 this.setState(newState)
  2. newState 会存入 pending 队列;
    3,判断是不是 batchUpdate
    4,如果是 batchUpdate ,则将组件先保存在所谓的脏组件 dirtyComponents 中;如果不是 batchUpdate ,那么就遍历所有的脏组件,并更新它们。

由此我们可以判定:所谓的异步更新,都命中了 batchUpdate ,先保存在脏组件中就完事;而同步更新,总是会去更新所有的脏组件。

非常有意思,看来是否命中 batchUpdate 是关键。问题也随之而来了,为啥直接调用就能命中 batchUpdate ,而放在异步回调里或者自定义 DOM 事件中就命中不了呢?

这就涉及到一个很有意思的知识点:react 中函数的调用模式。对于刚刚的 increase 函数,还有一些我们看不到的东西,现在我们通过魔法让其显现出来:

increase = () => {
        // 开始:默认处于bashUpdate
        // isBatchingUpdates = true
        this.setState({
            count: this.state.count + 1
        })
        console.log('count', this.state.count)
        // 结束
        // isBatchingUpdates = false

    }
increase = () => {
        // 开始:默认处于bashUpdate
        // isBatchingUpdates = true
        setTimeout(() => {
            // 此时isBatchingUpdates已经设置为了false
            this.setState({
                count: this.state.count + 1
            })
            console.log('count in setTimeout', this.state.count)
        }, 0)
        // 结束
        // isBatchingUpdates = false
    }

当 react 执行我们所书写的函数时,会默认在首位设置 isBatchingUpdates 变量。看到其中的差异了吗?当 setTimeout 执行其回调时, isBatchingUpdates 早已经在同步代码的末尾被置为 false 了,所以没命中 batchUpdate

那自定义 DOM 事件又是怎么回事?代码依然如下:

componentDidMount() {
    // 开始:默认处于bashUpdate
    // isBatchingUpdates = true
    document.body.addEventListener("click", () => {
      // 在回调函数里面,当点击事件触发的时候,isBatchingUpdates早就已经设为false了
      this.setState({
        count: this.state.count + 1,
      });
      console.log("count in body event", this.state.count); // 可以取到最新值。
    });
    // 结束
    // isBatchingUpdates = false
  }

我们可以看到,当 componentDidMount 跑完时, isBatchingUpdates 已经设置为 false 了,而点击事件后来触发,并调用回调函数时,取得的 isBatchingUpdates 当然也是 false ,不会命中 batchUpdate 机制。

总结:

  • this.setState 是同步还是异步,关键就是看能否命中 batchUpdate 机制
  • 能不能命中,就是看 isBatchingUpdatestrue 还是 false
  • 能命中 batchUpdate 的场景包括:生命周期和其调用函数、React中注册的事件和其调用函数。总之,是React可以“管理”的入口,关键是“入口”。

这里要注意一点:React去加isBatchingUpdate的行为不是针对“ 函数 ”,而是针对“ 入口 ”。比如setTimeout、setInterval、自定义DOM事件的回调等,这些都是React“管不到”的入口,所以不会去其首尾设置isBatchingUpdates变量。

concurrent 模式一定是异步更新

因为这个东西只在实验阶段,所以要开启 concurrent 模式,同样需要将 react 升级为实验版本,安装如下依赖:

npm install react@experimental react-dom@experimental

其他代码不用变,只更改 index 文件如下:

- ReactDOM.render(<App />, document.getElementById('root'));

+ ReactDOM.unstable_createRoot(document.getElementById('root')).render(<App />);

则可以发现: 其更新都是异步的,在任何情况下都是如此。

关于函数式组件中 useState 的 setter

在函数式组件中,我们会这样定义状态:

const [count, setCount] = useState(0)

这时候,我们发现当我们无论在同步函数还是在异步回调中调用 setCount 时,打印出来的 count 都是旧值,这时候我们会说: setCount 是异步的。

const [count, setCount] = useState(0);

  // 直接调用
  const handleStrightUpdate = () => {
    setCount(1);
    console.log(count); // 0
  };

  // 放在setTimeout回调中
  const handleSetTimeoutUpdate = () => {
    setTimeout(() => {
      setCount(1);
      console.log(count); // 0
    });
  };

setCount 是异步的,这确实没错,但是产生上述现象的原因不只是异步更新这么简单。原因主要有以下两点:

1,调用 setCount 时,会做合并处理,异步更新该函数式组件对应的 hooks 链表里面的值,然后触发重渲染( re-renders ),从这个角度上来说, setCount 确实是一个异步操作;

2,函数式的 capture-value 特性决定了 console.log(count) 语句打印的始终是一个只存在于当前帧的常量,所以就算无论 setCount 是不是同步的,这里都会打印出旧值。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK