学习过React的朋友们应该都知道setState和useState,两者在React的开发中具有极其重要的作用,它们一个是类组件中改变状态的方法,另一个是大名鼎鼎的hooks中的一员,在函数式组件中极为常见,但是很多初学者在学习这两个函数时都是半知半解的状态,搞不懂它们到底是同步的?还是异步的?两者有什么区别没有?希望看完这篇文章之后大家能够有所收获!
setState同步异步问题
那我们就先从类组件中的setState来入手吧!在探究之前,我们需要先知道类组件的一些性质以及使用setState的注意事项:
- 首先我们要知道React中
constructor
是唯一可以初始化state的地方,也可以把它理解成一个钩子函数,该函数只会在组件第一次挂载到页面时才会执行一次,后续组件由于setState状态更新而重新渲染时都不会重新执行constructor
函数了,并且constructor
函数是在render
函数和componentDidMount
前执行的,这一点跟hooks
中的useEffect
有点不一样,后者第一次执行是在组件挂载到页面上后,类似于componentDidMount
这个生命周期函数的执行
- 更新状态不要直接修改this.state,这也是纯函数的思想之一。虽然状态是可以改变的,但源码中是会进行新旧state比较的,由于这样操作会导致前后state的引用也就是地址相同,在这种情况下是不会触发组件更新的
- 再说说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 在调用事件处理函数之前就会先调用这个 batchedUpdates
将isBatchingUpdates
修改为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
如果想要解决上述问题应该怎么办呢?下面我个人提供几种方法:
- 我们恰好可以利用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.assign
api,新状态会依次覆盖掉旧状态,所以最终react实际上要更新的状态就是{count:4},rende函数也只重新执行了一次,所以最终我们在页面中看到的数值就是4
- 利用
async
和await
将异步更新转化为同步更新
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()
}
- 利用
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。
- set函数内部执行的操作和useState有些许不同,useState异步执行时是会将要更新的状态放到一个队列中去的,而set函数是每执行一次都会将新状态替换旧状态的,但并不一定会立即渲染组件。
- 如果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源码
总结
本篇文章就先讲到这里吧!下面来简单总结一下:
- 在正常的react的事件流里(如onClick等)
- setState和useState中的set函数是异步执行的(不会立即更新state的结果)
- 多次执行setState和useState的set函数,组件只会重新渲染一次
- 不同的是,setState会更新当前作用域下的状态,但是set函数不会更新,只能在新渲染的组件作用域中访问到
- 同时setState会进行state的合并,但是useState中的set函数做的操作相当于是直接替换,只不过内部有个防抖的优化才导致组件不会立即被重新渲染
- 在setTimeout,Promise.then等异步事件或者原生事件中
- setState和useState的set函数是同步执行的(立即重新渲染组件)
- 多次执行setState和useState的set函数,每一次的执行都会调用一次render