React 组件状态
React 把组件看成是一个状态机(State Machines)。通过与用户的交互,实现不同状态,然后渲染 UI,让用户界面和数据保持一致。
setState()
则是 React 组件状态更新的入口,调用 setState()
会对一个组件的 state 对象安排一次更新。当 state 改变了,该组件就会重新渲染。
一次组件更新的过程其实很复杂,包括 React 生命周期钩子的执行、虚拟DOM 的创建、diff 对比、真实DOM 的创建 等等。
setState 批量/合并 更新
那么问题来了,是不是每次调用 setState()
都会触发组件重新渲染呢?
如果不确定的话,我们就来做个试验验证一下。由于 React 组件每次渲染都会调用 componentDidUpdate
生命周期方法,我们可以在这个方法中打个日志:
export default class App extends Component {
constructor (p) {
super(p)
this.state = {
name: "peak",
age: 10
}
}
componentDidUpdate() {
// 组件更新时触发
console.log("组件更新")
}
handleClick = () => {
this.setState({
name: "peak1"
})
this.setState({
age: 11
})
}
render () {
return (
<View>
<Text>姓名:{this.state.name},年龄:{this.state.age}</Text>
<TouchableOpacity onPress={this.handleClick}>
<Text>更新组件</Text>
</TouchableOpacity>
</View>
)
}
}
可以发现点击按钮触发 setState()
时,componentDidUpdate
只走了一次,也就是说,并不是每次调用 setState()
都会触发组件重新渲染。
在这个案例中,多个 setState()
被合并成了一次更新,这就是 setState()
的批量更新,或者称为 合并更新。
setState()
的合并更新还有另一种表达方式,就是我们常说的 异步,异步的 setState()
表现为:调用 setState()
之后无法立刻获取到最新的 this.state
值。通过下面的日志可以直观的发现这一点:
handleClick = () => {
this.setState({
name: "peak1"
})
console.log("name=",this.state.name) // 打印:peak
this.setState({
age: 11
})
console.log("age=",this.state.age) // 打印:10
}
What?还有同步的 setState?
实际上合并更新是 React 的一种优化策略,目的在于避免频繁的触发组件重新渲染,但是这个优化是有条件的,并不是所有的 setState()
都能被合并。
下面是 setState 的伪代码:
setState(newState) {
if (this. isBatchingUpdates) {
this.updateQueue.push(newState)
return
}
// 下面是真正的更新: 修改 this.state,dom-diff, lifeCycle...
...
}
setState 会通过一个变量来判断当前状态变更是否能够被合并,如果可以合并,就会将本次更新缓存起来,等到后面来一次性更新;如果不可以合并,就会立即更新组件。
意思就是,当 isBatchingUpdates 为 false 时,setState()
会立即触发组件渲染,同时 this.state
的值也会相应的变化,我们能够立即拿到最新的 this.state
值。此时的 setState()
表现并非是 异步,而是 同步 的。
从这里可以看出,setState(x)
并不等于 this.state = x
。修改 this.state
的时机被 React 封装了一层,只有当真正去渲染组件的时候 this.state
的值才会变化。这就造成了我们看到的 同步 和 异步 的现象。
有的人可能会说,同步 很好啊,我能够立即获取到最新的 this.state
值,很直观。有这种想法的人忽略了一个重要的问题,就是 在同步场景中,每次调用 setState()
变更状态都会触发组件重新渲染,导致性能下降。 正因为如此,所以 React 才引入合并更新来避免组件频繁的重新渲染。
那么问题又来了,既然 同步 更新会导致性能下降,那为什么 React 不直接全都用 异步 呢,这样就能合并更新了。为了找到答案,我们接着往下看。
setState 什么时候是同步,什么时候是异步?
React 的更新是基于 Transaction(事务)的,Transacation 就是给目标函数包裹一下,加上前置和后置的 hook,在开始执行之前先执行 initialize hook
,结束之后再执行 close hook
,这样搭配上 isBatchingUpdates
这样的布尔标志位就可以实现目标函数调用栈内的多次 setState()
全部入 pending 队列,结束后统一 apply 了。
这里的 目标函数 指的是 React 控制的函数,这样的函数主要有两类:React 合成事件 和 生命周期钩子; 而 setTimeout 这样的异步方法是脱离事务的,React 管控不到,所以就没法对其中的 setState()
进行合并了。
我们结合下面的 Demo 来具体分析一下:
class App extends React.Component {
handleClick = () => {
this.setState({x: 1})
this.setState({x: 2})
this.setState({x: 3})
setTimeout(() => {
this.setState({x: 4})
this.setState({x: 5})
this.setState({x: 6})
}, 0)
}
render() {
return (<View>
<TouchableOpacity onPress={this.handleClick}>
<Text>更新组件</Text>
</TouchableOpacity>
</View>
}
}
1、handleClick 是 React 合成事件的回调,React 有控制权,在开始执行该函数的时候会将 isBatchingUpdates
设置为 true,所以 x 为 1、2、3 是合并的;
2、开始执行 setTimeout,这里会将 setTimeout 的回调函数加入了事件循环的宏任务中,等待主线程完成所有任务后来进行调度;
3、handleClick 结束之后 isBatchingUpdates
被重新设置为 false;
4、此时主线程的函数已出栈,开始执行 setTimeout 的回调函数,由于 isBatchingUpdates
的值已经变为了 false,所以 x 为 4、5、6 没有被合并更新,每一次的 setState()
都是同步执行的;
5、总共触发了 4 次组件渲染,其中有 2 次是冗余的。
总结为如下:
- 由 React 控制的事件处理程序、生命周期钩子中的
setState()
是异步的; - React 控制之外的事件中调用
setState()
是同步的。比如网络请求、setTimeout、setInterval、Promise 等; -
setState()
的 “异步” 并不是说内部由异步代码实现,其实本身执行的过程和代码都是同步的,只是合成事件和钩子函数的调用顺序在更新之前,导致在合成事件和钩子函数中没法立马拿到更新后的值,形成了所谓的 “异步”。
由此可以看出 React 对于 setState()
的同步更新其实是迫于无奈,是 React 无法控制的。React 当然想目标函数中的 setState()
都是异步更新的,这样性能也是最好的,能够避免组件频繁的更新渲染,但是条件不允许,React 办不到。
那我们能不能在写代码的时候规避同步的 setState()
调用呢?这是不可能的,除非你的程序非常简单且不需要跟后台进行通信,只要你的程序要请求网络接口,那么就会产生同步的 setState()
调用。那难道就没有办法对同步的 setState()
进行优化,让其合并更新吗?
setState 手动合并(同步转异步)
React 合成事件、生命周期钩子 都在 React 的控制范围内,所以它能够将他们自动加入 React 事务中,让其中的 setState()
合并更新。对于 React 无法控制的目标函数,React 其实也有提供手动加入事务的 API,就是 unstable_batchedUpdates
。
我们将上面 setTimeout 中的代码做一下调整:
class App extends React.Component {
handleClick = () => {
this.setState({x: 1})
this.setState({x: 2})
this.setState({x: 3})
setTimeout(() => {
// 手动将目标函数加入 React 事务中,让其合并更新
unstable_batchedUpdates(() => {
this.setState({x: 4})
this.setState({x: 5})
this.setState({x: 6})
})
}, 0)
}
render() {
return (<View>
<TouchableOpacity onPress={this.handleClick}>
<Text>更新组件</Text>
</TouchableOpacity>
</View>
}
}
x 为 1、2、3 在一个可控的目标函数中,是合并更新的;而 x 为 4、5、6 使用了 unstable_batchedUpdates
加入事务,也是合并更新的。总共有 2 次更新,相较于之前的 4 次减少了 2 次。
unstable_batchedUpdates
API 的原理如下:
function unstable_batchedUpdates(fn) {
this.isBatchingUpdates = true
fn()
this.isBatchingUpdates = false
const finalState = ... //通过this.updateQueue合并出finalState
this.setState(finaleState)
}
这个 API 在 React 和 React Native 中的引入方式有所不同:
-
react 中通过
react-dom
进行引入
import { unstable_batchedUpdates } from "react-dom";
-
react-native 中则直接从
react-native
库中引入
import { unstable_batchedUpdates } from "react-native";
React 的这个 API 确实能够将同步的setState()
转换为异步来进行合并更新,避免组件频繁渲染。
但是根据其前缀 unstable
也可以看出来,这个 API 不是稳定的。实际上这是 React 实验性的 API 之一,并没有全力推给到开发者去使用,所以如果不是特别影响性能,可以不用强制用这个 API 去合并 setState()
。
setState 的隐藏 API
我们在使用 setState 时用的最多就是给它传一个对象,像下面这样:
this.setState({count: 1})
如果 setState 中的 count 需要依赖之前的值,你会怎么处理:
1、第一种方法:使用 setState 的第二个参数
this.setState({ count: this.state.count + 1 }, () => {
// 依赖当前 count 的值
this.setState({ count: this.state.count + 1 })
})
setState()
的第二个参数接收一个函数,这个函数会在当前 setState()
更新完组件之后触发。这种写法有两个缺陷:
- 破坏了 React 合并更新的优化,会导致组件渲染两次;
- 同时这种写法会导致嵌套太深,很不美观。
2、第二种方法:将 setState 转为同步执行
setTimeout(() => {
this.setState({ count: this.state. count + 1 })
this.setState({ count: this.state. count + 1 })
})
通过 setTimeout 能够将 setState()
转为同步代码,这样就能够立即获取到最新的 this.state
值。这个方法不存在嵌套,但是和上面一样,会导致组件渲染两次。
3、终极方法:使用函数式的 setState
setState
其实有一个隐藏 API,第一个参数除了能够接收对象之外,还能够接收一个函数。这个函数接收先前的 state 作为参数,同时返回本次需要变更的 state,如下:
this.setState((state) => {
return { count: state.count + 1 }
})
this.setState((state) => {
return { count: state.count + 1 }
})
函数式的 setState()
能够保证第一个函数参数中的 state 是合并了之前所有状态的,这样后面的函数就能拿到前面函数执行的结果。但是这个过程中并不会改变 this.state
的值,意思就是会等函数执行完后才去进行渲染更新,所以组件只会渲染一次,没有破坏 React 合并更新的优化。
在同一个目标函数中不要混用函数式和对象式这两种API
// 1
this.setState((state) => {
return { count: state.count + 1 }
})
// 2
this.setState({ count: this.state.count + 1 })
// 3
this.setState((state) => {
return { count: state.count + 1 }
})
1、假设一开始的 state.count 为 10
2、第一次执行函数式 setState
,count 为 11
3、第二次执行对象式 setState
,this.state
仍然是没有更新的状态,所以 this.state.count
还是 10,加 1 以后又变回了 11
4、最后再执行函数式 setState
,回调函数中的 state.count
的值是第二步中的到的 11,这里再加 1,所以最终 count 的结果是 12。
可以发现第二个对象式 setState
将第一个函数式设置的 count 抹掉了,正确的做法是都调整为函数式的 setState
,不然可能就会造成上面的问题。所以要避免函数式和对象式的 setState
混用,不然自己可能都会搞迷糊。
总结
在使用 React 作为开发框架的项目中,setState()
应该是我们接触使用最多的 API,大家都习以为常的认为 setState()
是异步更新的,实际上有很多同步更新的场景被大家所忽略,从而忽视了对于 setState 也能进行性能优化的场景。
文章提到的 setState 性能优化主要包含两方面:
- 适时地考虑使用
unstable_batchedUpdates
来手动合并更新,解决 React 无法自动合并更新的场景。由于这个 API 不稳定,所以未来可能会失效,但目前在 RN 0.64.2 及之前的版本中验证还是可以使用的,暂时可以不用担心; - 使用函数式的
setState()
来更新那些依赖于当前的 state 的 state。
本文为原创,转载请注明出处