组件state
设计合适的state
组件 state 必须能代表一个组件 UI 呈现的完整状态集,即组件的任何 UI 改变都可以从 state 的变化中反映出来;同时,state 还必须代表一个组件 UI 呈现的最小状态集,即 state 中的所有状态都用于反映组件 UI 的变化,没有任何多余的状态,也不应该存在通过其他状态计算而来的中间状态。
我们通过一个例子来解释上面的定义。假设需要开发一个购物车组件,需要展示的信息有购买的物品列表以及物品的总金额。设计一个错误的 state:
// 错误的 state 示例
{
"purchaseList": [],
"totalCost": 0
}
这里的 state 是初始状态,因此 purchaseList 初始化为一个空数组,totalCost 初始化为 0,这个 state 的设计确实可以满足组件 UI 呈现的完整状态集这一条件,但是它包含一个无用的状态 totalCost,因为 totalCost 可以根据购买的每一项物品的价格和数量计算得出,所以有了 purchaseList,就可以计算出 totalCost,totalCost 属于中间状态,可以省略。
state 所代表的一个组件 UI 呈现的完整状态集又可以分成两类数据:用作渲染组件时使用到的数据的来源以及用作组件 UI 展现形式的判断依据。例如,下面的 Hello 组件定义了 user 和 display 作为组件 state,user 是组件最终要在界面上呈现的数据,而 display 决定了 <h1> 标签是否需要渲染,是组件 UI 呈现形式的判断依据。
class Hello extends React.Component {
constructor(props) {
super(props);
this.state = {
user: 'React',
display: true
}
}
render() {
return (
<div>
{
this.state.display ? <h1>Hello, {this.state.user}</h1> : null
}
</div>
);
}
}
state 还容易和 props 以及组件的普通属性混淆。这是我们第一次提到组件的普通属性,所以先明确一下组件普通属性的定义。我们的组件都是使用 ES6 的 class 定义的,所以组件的属性其实也就是 class 的属性(更确切的说法是 class 实例化对象的属性,但因为 JavaScript 本质上是没有类的定义的,class 只不过是 ES6 提供的语法糖,所以这里模糊化类和对象的区别)。在 ES6 中,可以使用 this.{属性名} 定义一个 class 的属性,也可以说属性是直接挂载到 this 下的变量。因此,state、props 实际上也是组件的属性,只不过它们是 React 为我们在 Component class 中预定义好的属性。除了 state、props 以外的其他组件属性称为组件的普通属性。
假设一个组件需要显示当前时间,并且这个时间每秒都会自动更新,这个组件内就需要定义一个计时器,在这个计时器中每隔 1 秒更新一次组件的 state。这个计时器变量并不适合定义到组件的 state 中,因为它并不代表组件 UI 呈现状态,它只是用来更改组件的 state,这时就到了组件的普通属性发挥作用的时候了。例如,下面的代码为 Hello 组件定义了 timer 属性,用来定时更新组件状态:
class Hello extends React.Component {
constructor(props) {
super(props);
this.timer = null; // 普通属性
this.state = {
date : new Date()
}
this.updateDate = this.updateDate.bind(this);
}
componentDidMount() {
this.timer = setInterval(this.updateDate, 1000)
}
componentWillUnmount() {
clearInterval(this.timer);
}
updateDate() {
this.setState({
date: new Date()
});
}
render(){
return (
<div>
<h1>Hello</h1>
<h1>{this.state.date.toString()}</h1>
</div>
);
}
}
因此,当我们在组件中需要用到一个变量,并且它与组件的渲染无关时,就应该把这个变量定义为组件的普通属性,直接挂载到 this 下,而不是作为组件的 state。还有一个更加直观的判断方法,就是看组件 render 方法中有没有使用到这个变量,如果没有,它就是一个普通属性。
state 和 props 又有什么区别呢?state 和 props 都直接和组件的 UI 渲染有关,它们的变化都会触发组件重新渲染,但 props 对于使用它的组件来说是只读的,是通过父组件传递过来的,要想修改 props,只能在父组件中修改;而 state 是组件内部自己维护的状态,是可变的。
总结一下,组件中用到的一个变量是不是应该作为 state 可以通过下面的 4 条依据进行判断:
-
这个变量是否通过 props 从父组件中获取?如果是,那么它不是一个状态。
-
这个变量是否在组件的整个生命周期中都保持不变?如果是,那么它不是一个状态。
-
这个变量是否可以通过其他状态(state)或者属性(props)计算得到?如果是,那么它不是一个状态。
-
这个变量是否在组件的 render 方法中使用?如果不是,那么它不是一个状态。这种情况下,这个变量更适合定义为组件的一个普通属性。
正确修改state
state 可以通过 this.state.{属性}
的方式直接获取,但当修改 state 时,往往有很多陷阱需要注意。下面介绍常见的三种陷阱:
不能直接修改state
直接修改 state,组件并不会重新触发 render。例如:
// 错误
this.state.title = 'React';
正确的修改方式是使用 setState():
// 正确
this.setState({title: 'React'});
state的更新是异步的
调用 setState 时,组件的 state 并不会立即改变,setState 只是把要修改的状态放入一个队列中,React 会优化真正的执行时机,并且出于性能原因,可能会将多次 setState 的状态修改合并成一次状态修改。所以不要依赖当前的 state,计算下一个 state。当真正执行状态修改时,依赖的 this.state 并不能保证是最新的 state,因为 React 会把多次 state 的修改合并成一次,这时 this.state 还是这几次 state 修改前的 state。另外,需要注意的是,同样不能依赖当前的 props 计算下一个状态,因为 props 的更新也是异步的。
举个例子,对于一个电商类应用,在购物车中,点击一次购买数量按钮,购买的数量就会加 1,如果连续点击两次按钮,就会连续调用两次 this.setState({quantity:this.state.quantity + 1}),在 React 合并多次修改为一次的情况下,相当于等价执行了如下代码:
Object.assign(
previousState,
{quantity: this.state.quantity + 1},
{quantity: this.state.quantity + 1}
)
于是,后面的操作覆盖前面的操作,最终购买的数量只增加 1。
如果有这样的需求,可以使用另一个接收一个函数作为参数的 setState,这个函数有两个参数,第一个是当前最新状态(本次组件状态修改生效后的状态)的前一个状态 preState(本次组件状态修改前的状态),第二个参数是当前最新的属性 props。代码如下:
// 正确
this.setState((preState, props) => ({
counter: preState.quantity + 1;
}))
state的更新是一个合并的过程
当调用 setState 修改组件状态时,只需要传入发生改变的 state,而不是组件完整的 state,因为组件 state 的更新是一个合并的过程。例如,一个组件的状态为:
this.state = {
title: 'React',
content: 'React is an wonderful JS library!'
}
当只需要修改状态 title 时,将修改后的 title 传给 setState 即可:
this.setState({title: 'Reactjs'});
React 会合并新的 title 到原来的组件状态中,同时保留原有的状态 content,合并后的 state 为:
this.state = {
title: 'Reactjs',
content: 'React is an wonderful JS library!'
}
state与不可变对象
React 官方建议把 state 当作不可变对象,一方面,直接修改 this.state,组件并不会重新 render;另一方面,state 中包含的所有状态都应该是不可变对象。当 state 中的某个状态发生变化时,应该重新创建这个状态对象,而不是直接修改原来的状态。那么,当状态发生变化时,如何创建新的状态呢?根据状态的类型可以分成以下三种情况:
状态的类型是不可变类型(数字、字符串、布尔值、null、undefined)
这种情况最简单,因为状态是不可变类型,所以直接给要修改的状态赋一个新值即可。例如要修改 count(数字类型)、title(字符串类型)、success(布尔类型)三个状态:
this.setState({
count: 1,
title: 'React',
success: true
})
状态的类型是数组
例如有一个数组类型的状态 books,当向 books 中增加一本书时,可使用数组的 concat 方法或 ES6 的数组扩展语法(spread syntax):
// 方法一:使用 preState、concat 创建新数组
this.setState(preState => ({
books: preState.books.concat(['React Guide']);
}))
// 方法二:ES6 spread syntax
this.setState(preState => ({
books: [...preState.books, 'React Guide'];
}))
当从 books 中截取部分元素作为新状态时,可使用数组的 slice 方法:
this.setState(preState => ({
books: preState.books.slice(1,3);
}))
当从 books 中过滤部分元素后,作为新状态时,可使用数组的 filter 方法:
this.setState(preState => ({
books: preState.books.filter(item => {
return item !== 'React';
});
}))
注意,不要使用 push、pop、shift、unshift、splice 等方法修改数组类型的状态,因为这些方法都是在原数组的基础上修改的,而 concat、slice、filter 会返回一个新的数组。
状态的类型是普通对象(不包含字符串、数组)
(1)使用 ES6 的 Object.assgin 方法:
this.setState(preState => ({
owner: Object.assign({}, preState.owner, {name: 'Jason'});
}))
(2)使用对象扩展语法(object spread properties):
this.setState(preState => ({
owner: {...preState.owner, name: 'Jason'};
}))
总结一下,创建新的状态对象的关键是,避免使用会直接修改原对象的方法,而是使用可以返回一个新对象的方法。当然,也可以使用一些 Immutable 的 JS 库(如 Immutable.js )实现类似的效果。
为什么 React 推荐组件的状态是不可变对象呢?一方面是因为对不可变对象的修改会返回一个新对象,不需要担心原有对象在不小心的情况下被修改导致的错误,方便程序的管理和调试;另一方面是出于性能考虑,当对象组件状态都是不可变对象时,在组件的 shouldComponentUpdate 方法中仅需要比较前后两次状态对象的引用就可以判断状态是否真的改变,从而避免不必要的 render 调用。在第 5 章会详细介绍这部分内容。