细品setState和useState的set函数

·  阅读 908
细品setState和useState的set函数

学习过React的朋友们应该都知道setState和useState,两者在React的开发中具有极其重要的作用,它们一个是类组件中改变状态的方法,另一个是大名鼎鼎的hooks中的一员,在函数式组件中极为常见,但是很多初学者在学习这两个函数时都是半知半解的状态,搞不懂它们到底是同步的?还是异步的?两者有什么区别没有?希望看完这篇文章之后大家能够有所收获!

setState同步异步问题

那我们就先从类组件中的setState来入手吧!在探究之前,我们需要先知道类组件的一些性质以及使用setState的注意事项:

  1. 首先我们要知道React中constructor是唯一可以初始化state的地方,也可以把它理解成一个钩子函数,该函数只会在组件第一次挂载到页面时才会执行一次,后续组件由于setState状态更新而重新渲染时都不会重新执行constructor函数了,并且constructor函数是在render函数和componentDidMount前执行的,这一点跟hooks中的useEffect有点不一样,后者第一次执行是在组件挂载到页面上后,类似于componentDidMount这个生命周期函数的执行

  1. 更新状态不要直接修改this.state,这也是纯函数的思想之一。虽然状态是可以改变的,但源码中是会进行新旧state比较的,由于这样操作会导致前后state的引用也就是地址相同,在这种情况下是不会触发组件更新的
  1. 再说说setState方法的使用吧!其主要作用相信大家都不陌生,就是去改变当前组件保存的状态,该方法接受两个参数,即setState(stateChange[, callback])
  • 第一个参数可以直接是新状态对应的对象,也可以是一个函数,react会自动判别传进去的是一个对象还是函数;如果传递进去的是一个函数,那么react会自动帮我们调用它,并在调用时会传递过来两个参数,第一个是前一次的状态,第二个是从父组件接受到的props值,我们可以利用这两个参数确定返回值,这个返回值就是我们想要更新的新状态所对应的对象
// 第一个参数直接传递一个对象
clickFn () {
    this.setState({
        count: 1
    })
}
// 第一个参数是一个函数,该函数的返回值会被当做要更新的对象
clickFn () {
    this.setState((state, props) => {
    	return state.count + 1
    })
}
复制代码
  • 第二个参数是一个回调函数(非必传),该回调函数会在状态改变成功之后自动执行,在该函数中我们就可以通过this.state获取最新的值去执行相关操作了
clickFn () {
    this.setState({
        count: 2
    }, () => {
        console.log(this.state.count)  // 2
    })
}

clickFn () {
		this.setState((preState, props) => {
      	return {...preState, count: preState.count + 1}
    }, () => {
      	console.log(this.state.count); // 2
    })
}
复制代码

清楚了上面几个知识点之后,我们就可以正式开始研究setState是同步还是异步的问题了,大多数人理解的setState可能是异步的,因为react官方中给出了这样一段话,导致很多人误解无论setState在哪里执行,其都是异步的

setState() does not always immediately update the component. It may batch or defer the update until later. This makes reading this.state right after calling setState() a potential pitfall.

// setState函数执行了之后不总是会立即更新组件,他可能会批量处理更新或将更新推迟到以后。这可能会导致执行了setState函数后立即读取this.state会陷入潜在的陷阱

我们可以先了解执行了setState之后会发生什么?

  • setState内部执行的过程是很复杂的,大致过程包括了更新state、创建新的节点,再经过diff算法比对差异,决定需要重新渲染哪一部分的内容,最终形成新的页面

从这些点我们可以大致地知道setState都做了哪些操作,其实还是蛮多的。如果每执行一次setState函数,这个过程都要完整的走一次,diff算法的比较以及Dom的更新都会在一定程度上消耗性能,这样雪球越滚越大之后最终就可能会出现性能上的问题

React肯定是考虑到了这些的,所以在源码中对setState函数做了一些处理,多个连续的setState函数所对应的新状态可能会在执行过程中被合并,最终再一次性更新完毕,这也可能是大部分人所认为setState函数异步更新,下面我们来看几个Demo:

export default class test extends PureComponent {
    constructor() {
        super()
        this.state = {
            count: 1
        }
    }

    increase() {
        this.setState({
            count: this.state.count + 1
        })
        console.log(this.state.count); // setState函数执行了一次时,这里打印出来的count值还是1,说明状态并没有被同步更改,在这里setState是异步的
    }

    render() {
        return (
            <button onClick={e => this.increase()}>
                {this.state.count}
            </button>
        )
    }
}
复制代码
componentDidMount() {
  document.querySelector('button').addEventListener('click', e => {
    this.setState({
      count: this.state.count + 1
    })
    console.log(this.state.count); // setState函数执行了一次时,这里打印出来的count值为2,说明状态并已经被同步更改,在这里setState是同步的
  })
}
复制代码

通过上面一个实例,可能打破了那些一贯认为setState是异步的那些人的认知,通过js的事件addEventListener绑定事件会发现,在setState执行完之后居然能够打印出更新过后的状态值,简直是匪夷所思。其实,我们并不能绝对地说setState到底是同步的还是异步的,这个是需要视情况而定的。如果我们想要真正的了解到为什么setState有时同步,有时异步,那我们就必须要知道React对setState到底做了什么?

React实际上为useState的set函数/setState 前后各加了段逻辑给包了起来。在 React 的 setState 函数实现中,会根据一个变量 isBatchingUpdates 判断是直接更新 this.state 还是放到队列中延时更新,而 isBatchingUpdates 默认是 false,表示 setState 会同步更新 this.state;但是,有一个函数 batchedUpdates,该函数会把 isBatchingUpdates 修改为 true,而当 React 在调用事件处理函数之前就会先调用这个 batchedUpdatesisBatchingUpdates修改为true,这样由 React 控制的事件处理过程 setState 不会同步更新 this.state,而是将新状态放到队列中延时更新。一般来说,只要是在react控制范围内的事件内使用setState,setState就是异步更新的,否则,setState就是同步更新

那么到底在哪些具体情况下是同步的,哪些是异步的呢?举个典型的例子:我们通常会给某个标签绑定onClick点击事件,由于 react 的事件委托机制,调用 onClick 执行的事件,像这种正常的react事件流是处于 react 的控制范围内的,所以其对应函数中的setState是异步更新的;比较典型的还有setTimeout,我们通常利用它做一些定时操作,但是如果在其内部使用setState,我们会发现它其实是同步更新的,这是因为 setTimeout 已经超出了 react 的控制范围,react 无法对 setTimeout 的代码前后加上事务逻辑(除非 react 重写 setTimeout

所以当遇到 setTimeout/setInterval/Promise.then(fn)/fetch 回调/xhr 网络回调时,react 都是无法控制的,则如果在他们所对应的作用域下使用setState,那么此时setState毫无疑问就是同步的;为什么要搞清楚这些问题呢?因为在开发中的很多场景你可能需要连续多次调用setState函数,此时遇到问题你就可以快速地分析出解决方法

下面是我就从实际开发的角度展示的demo:

increase() {
  Promise.resolve().then(res => { // then回调是放在异步队列中执行的,当点击了一次按钮之后,该函数才会被执行,此处使用promise模拟点击按钮之后发送网络请求,then回调模拟将接收到响应数据更新到状态中
    this.setState({
      count: this.state.count + 1
    })
    console.log(this.state.count); // 2
    this.setState({
      count: this.state.count + 1
    })
    console.log(this.state.count); // 3
    this.setState({
      count: this.state.count + 1
    })
    console.log(this.state.count); // 4
  })
}
复制代码

可以看到每次setState之后,我们打印出来的都是最新的状态,从而才能做到数字的叠加;不过这里有个注意的点就是:每次setState执行时,组件都会被重新渲染一次,render函数也会执行,所以如果以后真的遇到了这种情况,想在异步任务中使用多个setState又不想组件多次渲染,可以考虑把用到的状态都放到一个对象中集中管理,这样就只需要使用一次setState更新这个对象就行,render函数也只会重新渲染一次

但并不是说setState是异步执行的就不会遇到问题了,比如下面的demo:

// 用户点击了按钮之后执行increase函数,该函数是通过react内部的onClick来绑定的
increase() {
  this.setState({
    count: this.state.count + 1
  })
  console.log(this.state.count); // 1
  this.setState({
    count: this.state.count + 1
  })
  console.log(this.state.count); // 1
  this.setState({
    count: this.state.count + 1
  })
  console.log(this.state.count); // 1
}
复制代码

我们会发现,多次执行了setState函数来更新状态,但是state并没有立即更新,说明setState在这种情况下是异步执行的,render函数只会渲染一次,但是问题就来了,我在这里执行了3次setState,理应上最新状态应该为4才对,但是值却为2,这个正是react官方所说的“陷阱”。

由于每次执行setState都是通过this.state来获取最新状态,但是setState如果是异步更新的话不会立马就将状态改变,自然获取到的都是旧的状态,相当于执行了3次setState({count: 1 + 1})操作,最终react会通过Array.assign函数合并存储在队列中的新旧状态,导致最终更新的状态是{count: 2},所以页面中显示的数字就是2,而不是4

如果想要解决上述问题应该怎么办呢?下面我个人提供几种方法:

  1. 我们恰好可以利用setState第一个参数可以是函数的性质,react调用该函数时会把要更新的最新状态以及props传递进来,我们就可以通过preStatae来获取到最新一次的状态(这个新状态可能还没有被更新,但是我们可以获取到)
increase() {
  this.setState(preState => ({ ...preState, count: preState.count + 1 }))
  console.log(this.state.count);
  this.setState(preState => ({ ...preState, count: preState.count + 1 }))
  console.log(this.state.count);
  this.setState(preState => ({ ...preState, count: preState.count + 1 }))
  console.log(this.state.count);
}
复制代码

改成这样操作后,在每个setState函数里面都可以获取到最新的state,比如第一个setState函数的参数preState.count值为1,第二个为2,第三个为3,实质上执行的操作就是:

increase() {
  this.setState({ count: 2 })
  this.setState({ count: 3 })
  this.setState({ count: 4 })
}
复制代码

react在队列中合成这几个状态时,通过调用Array.assignapi,新状态会依次覆盖掉旧状态,所以最终react实际上要更新的状态就是{count:4},rende函数也只重新执行了一次,所以最终我们在页面中看到的数值就是4

  1. 利用asyncawait将异步更新转化为同步更新
increase() {
  const handleFn = async () => {
    await this.setState({
      count: this.state.count + 1
    })
    console.log(this.state.count); // 2
    await this.setState({
      count: this.state.count + 1
    })
    
    console.log(this.state.count); // 3
    await this.setState({
      count: this.state.count + 1
    })
    console.log(this.state.count); // 4
  }
  handleFn()
}
复制代码
  1. 利用setTimeout将setState转为同步
increase() {
  setTimeout(() => {
    this.setState({count: this.state.count + 1})
    console.log(this.state.count); // 2
    this.setState({count: this.state.count + 1})
    console.log(this.state.count); // 3
    this.setState({count: this.state.count + 1})
    console.log(this.state.count); // 4
  }, 0)
}
复制代码

个人推荐第一种方法,第二种和第三种都是将setState暴力的转为同步更新,这样子没执行一次setState,组件和render函数就重新执行一次,有点消耗性能

类组件的setState就介绍到这里了,如果有人想要深入了解的,那么我认为可以去看看react的源码,不过建议等react运用熟练了之后再进行源码的阅读,要不然学习起来会有点吃力;

useState的set函数同步异步问题

话不多说,接下来我们就进入hooks中的useState所返回的set函数解读,但其实useState的set函数和类组件的setState函数有跟大的相似之处,我们直接看几个Demo吧!

import React, { memo, useEffect, useRef, useState } from 'react'

export default memo(function Test() {
    const [count, setCount] = useState(1)

    const clickFn = () => {
        setCount(count + 1)
        setCount(count + 1)
        setCount(count + 1)
        console.log(count); // 1
    }

    return (
        <div onClick={clickFn}>
            {count}
        </div>
    )
})
复制代码

连续调用了三次set函数之后,打印出的count值还是为1,说明setCount在react事件流里面也是异步更新的,组件和render函数只会被重新渲染一次,但是屏幕中显示的数字并不是4,而是2。

  1. set函数内部执行的操作和useState有些许不同,useState异步执行时是会将要更新的状态放到一个队列中去的,而set函数是每执行一次都会将新状态替换旧状态的,但并不一定会立即渲染组件。
  2. 如果set函数确定是同步执行的了,那么每执行set函数一次,组件都会被重新渲染;如果set函数确定是异步执行的了,其内部其实是有一个防抖优化的,连续的更新同一个状态会使得内部状态不断地被替换,最终留下的是最新的那个。在此例中,相当于执行了3次setCount(2),最终替换为新状态的当然是数值2了

如果想要执行set函数之后count变为4,解决方法还是类似的

const clickFn = () => {
  setCount(state => state + 1) // state的值为1
  setCount(state => state + 1) // state的值为2
  setCount(state => state + 1) // state的值为3
  console.log(count); // 1
}
复制代码

状态仍然是异步更新,组件也只重新渲染了一次。所以打印出的值是旧状态,但是渲染到页面上的值是4

此时重点来了,那我们是不是也能用跟setState解决类似问题中所用到的setTimeout方法和async结合await的方法解决类似问题呢?我们来测试一下:

export default memo(function Test() {
    const [count, setCount] = useState(1)

    console.log('组件渲染了,count的值为' + count)

    const clickFn = () => {
        setTimeout(() => {
            setCount(count + 1)
            console.log(count); // 1
            setCount(count + 1)
            console.log(count); // 1
            setCount(count + 1)
            console.log(count); // 1
        }, 0)
    }

    return (
        <div onClick={clickFn}>
            {count}
        </div>
    )
})
复制代码

当点击了一次按钮后,控制台打印的数据为:

可以看到在set函数下方再打印count的值,仍然是旧的状态,跟类组件中的setState有所不同,如果是后者,那么打印出来的应该是最新的状态才对,难道set函数在setTimeout里面变为了异步执行的吗?

并不是,可以再控制台看到,当第一次执行了setCount函数的时候,组件就会重新渲染,第二次执行setCount函数时,组件也会重新渲染。这就意味这在setTimeout中的set函数仍然还是同步执行的,但是在执行完set函数之后,当前作用域中的count并没有发生改变,所以在当前作用域下获取count的值仍然为旧的状态,但是在新渲染的组件作用域中就可以访问到最新的状态,count打印出来是2;但是这样问题就来了,由于当前作用域下状态并没有改变,setCount的参数永远都是2,所以并不能达到我们想要的目的;使用async结合await的方法也还是会遇到这个问题,所以我们在处理这种类型的问题时,还是使用给set函数传一个函数的方法比较稳妥一点

不过看到上面的打印结果,有些人可能会疑惑为什么第三次执行set函数组件没有重新渲染,这就涉及到另外一个知识点了:react会对新旧状态做一个比较,如果是第二次相同了,那么它是不会让组件重新渲染的,如果想深入研究的话可以去看一下react源码

总结

本篇文章就先讲到这里吧!下面来简单总结一下:

  1. 在正常的react的事件流里(如onClick等)
  • setState和useState中的set函数是异步执行的(不会立即更新state的结果)
  • 多次执行setState和useState的set函数,组件只会重新渲染一次
  • 不同的是,setState会更新当前作用域下的状态,但是set函数不会更新,只能在新渲染的组件作用域中访问到
  • 同时setState会进行state的合并,但是useState中的set函数做的操作相当于是直接替换,只不过内部有个防抖的优化才导致组件不会立即被重新渲染
  1. 在setTimeout,Promise.then等异步事件或者原生事件中
  • setState和useState的set函数是同步执行的(立即重新渲染组件)
  • 多次执行setState和useState的set函数,每一次的执行都会调用一次render
分类:
前端
标签:
分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改