引言
很多情况下我们使用的多个组件需要对同一个数据做出对应的反应。在这里我们推荐把这个共享的状态提升到距离这些组件最近的祖先组件。现在让我们来看看这是怎么工作的。
在本章中,我们将会创建一个温度计算器来计算在给定温度下水是否会沸腾。
首先我们现创建一个BoilingVerdict
组件。它接收一个celsius
(摄氏度)作为prop
,并在页面上打印出在这个温度下水是否会沸腾。
function BoilingVerdict(props) {
if (props.celsius >= 100) {
return <p>The water would boil.</p>;
}
return <p>The water would not boil.</p>;
}
接下来,我们将会创建一个Calculator
组件。它渲染了一个输入框来输入温度并将输入值绑定到this.state.temperature
。
除此之外,它也为当前输入的温度渲染相应的BoilingVerdict
。
class Calculator extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.state = {temperature: ''};
}
handleChange(e) {
this.setState({temperature: e.target.value});
}
render() {
const temperature = this.state.temperature;
return (
<fieldset>
<legend>Enter temperature in Celsius:</legend>
<input
value={temperature}
onChange={this.handleChange} />
<BoilingVerdict
celsius={parseFloat(temperature)} />
</fieldset>
);
}
}
添加第二个输入框
现在我们有一个新需求,除了输入摄氏温度之外,我们还需要一个能够输入华氏温度的输入框,并且这两个输入框是同步的。
首先我们可以从Calculator
组件中提取出一个TemperatureInput
组件。并添加一个scale
作为prop来代表摄氏度(c)或华氏温度(f)。
const scaleNames = {
c: 'Celsius',
f: 'Fahrenheit'
};
class TemperatureInput extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.state = {temperature: ''};
}
handleChange(e) {
this.setState({temperature: e.target.value});
}
render() {
const temperature = this.state.temperature;
const scale = this.props.scale;
return (
<fieldset>
<legend>Enter temperature in {scaleNames[scale]}:</legend>
<input value={temperature}
onChange={this.handleChange} />
</fieldset>
);
}
}
现在我们可以更改Calculator
组件来渲染两个不同的温度输入。
class Calculator extends React.Component {
render() {
return (
<div>
<TemperatureInput scale="c" />
<TemperatureInput scale="f" />
</div>
);
}
}
现在我们有了两个输入框,但是当你在其中一个输入时另一个输入框内的数据并不会更新。这就与我们想要这两个输入框同步的需求矛盾了。
我们也没法在Calculator
中展示BoilingVerdict
了,因为Calculator
组件没有办法获取到隐藏在TemperatureInput
组件里的温度。
编写转换函数
为了解决上述的矛盾,我们现编写能够相互转换摄氏度和华氏温度的两个函数。
function toCelsius(fahrenheit) {
return (fahrenheit - 32) * 5 / 9;
}
function toFahrenheit(celsius) {
return (celsius * 9 / 5) + 32;
}
这两个函数用来转换数字,现在我们来编写另一个函数,这个函数讲temperature
字符串和转换函数作为参数并返回一个字符串。我们使用这个函数根据其中一个输入框的数据来计算另一个输入框的数据。
同时如果输入的温度无效那么就返回一个空字符串,对有效的温度返回精确到小数点后三位的数字。
function tryConvert(temperature, convert) {
const input = parseFloat(temperature);
if (Number.isNaN(input)) {
return '';
}
const output = convert(input);
const rounded = Math.round(output * 1000) / 1000;
return rounded.toString();
}
比如,tryConvert('abc', toCelsius)
返回一个空字符串,tryConvert('10.22', toFahrenheit)
返回 '50.396'
。
状态提升
目前,两个TemperatureInput组件都将输入值保存在各自的局部state中。
class TemperatureInput extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.state = {temperature: ''};
}
handleChange(e) {
this.setState({temperature: e.target.value});
}
render() {
const temperature = this.state.temperature;
// ...
但是我们想要这两个输入框能够同步数据,当我们在摄氏度输入时,华氏温度能够同时显示经过转化后的温度,反之亦然。
在React中,共享状态的方法是将state转移到离需要共享组件最近的公共父组件中,这称为“状态提升
”。现在我们将把TemperatureInput
组件中的state转移到Calculator
中。
如果Calculator包含了共享状态,那么它就是这两个温度输入组件的“数据源”,这能使两个输入组件的数据始终保持一致。 因为两个TemperatureInput
组件的props都是来自共同的父组件Calculator
的,所以他们在数据显示上能够保持同步。
现在我们来逐步了解这是怎么完成的。
首先,我们将TemperatureInput
组件中的this.state.temperture
替换成this.props.temperature
。当然this.props.temperature
是通过Calculator组件传递的。
render() {
// Before: const temperature = this.state.temperature;
const temperature = this.props.temperature;
// ...
我们知道props
是只读的,当我们将温度存储在本地state时,我们可以通过setState()
来修改它。但是现在温度是通过父组件传递进来的,那么TemperatureInput
组件就没有修改温度的权限了。
React中,通常的解决方案是将组件变为“受控”的。就像在DOM中<input>
接收value
和onChange
作为prop一样,我们可以让TemperatureInput
组件接收从Calculator
传递来的prop:temperature
和onTemperatureChange
。
现在,当TemperatureInput
想要更新温度时,只要调用this.props.onTemperatureChange
就行了:
handleChange(e) {
// Before: this.setState({temperature: e.target.value});
this.props.onTemperatureChange(e.target.value);
// ...
提示 temperature和onTemperatureChange在这里没有特殊的含义,只是一个属性名称,是可以随意定义的,它也可以是更大众化的value和onChange。
onTermperatureChange
属性和temperature
属性都是由父组件Calculator
提供的。它会通过修改自身的state来处理数据的变化,以此来重新渲染两个输入框内的数据。我们很快就可以看到Calculator
组件的实现细节。
在我们深入了解Calculaor
组件的实现之前,先让我们来回顾一下TemperatureInput
组件做了哪些修改。我们将移除了本地state,将原本通过读取this.state.temperature
获取温度替换成读取this.props.temperature
获取,将通过调用setState()
修改数据替换成调用this.props.onTemperatureChange()
修改数据,这两种都由Calculator
组件提供。
class TemperatureInput extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
}
handleChange(e) {
this.props.onTemperatureChange(e.target.value);
}
render() {
const temperature = this.props.temperature;
const scale = this.props.scale;
return (
<fieldset>
<legend>Enter temperature in {scaleNames[scale]}:</legend>
<input value={temperature}
onChange={this.handleChange} />
</fieldset>
);
}
}
现在让我们来看看Calculator组件。
我们将输入框的temperature和scale存储在本地state中,这是我们从input中提升出来的状态,来作为两个input的数据源。这就是我们渲染两个input需要的最小数据集合。
举个例子,现在我们在摄氏度输入框内输入37,那么Calculator组件的state就将会是这样:
{
temperature: '37',
scale: 'c'
}
如果之后我们在华氏温度输入框中输入212,Calculator的state就将变成:
{
temperature: '212',
scale: 'f'
}
有人会说为什么存储两个input的值?可以,但没必要。只要存储最近修改的值和它所代表的scale就足够了。因为我们可以根据当前的temperature和scale推算出另一个input的值。
现在,输入框的值是同步的了,因为它们的值都是由同一状态计算出来的。
class Calculator extends React.Component {
constructor(props) {
super(props);
this.handleCelsiusChange = this.handleCelsiusChange.bind(this);
this.handleFahrenheitChange = this.handleFahrenheitChange.bind(this);
this.state = {temperature: '', scale: 'c'};
}
handleCelsiusChange(temperature) {
this.setState({scale: 'c', temperature});
}
handleFahrenheitChange(temperature) {
this.setState({scale: 'f', temperature});
}
render() {
const scale = this.state.scale;
const temperature = this.state.temperature;
const celsius = scale === 'f' ? tryConvert(temperature, toCelsius) : temperature;
const fahrenheit = scale === 'c' ? tryConvert(temperature, toFahrenheit) : temperature;
return (
<div>
<TemperatureInput
scale="c"
temperature={celsius}
onTemperatureChange={this.handleCelsiusChange} />
<TemperatureInput
scale="f"
temperature={fahrenheit}
onTemperatureChange={this.handleFahrenheitChange} />
<BoilingVerdict
celsius={parseFloat(celsius)} />
</div>
);
}
}
现在,无论你在哪个输入框输入,Calculator
组件的this.state.scale
和this.state.temperature
都会更新。其中一个输入框的输入按照原样取值,于是用户的输入就这样被保存了,而另一个输入框的值就会根据用户的输入推算出来。
让我们梳理以下当我们在一个输入框输入时代码的运行逻辑:
- React调用DOM标签
<input>
上的onChange方法,在我们的例子中,调用的时TemperatureInput
组件的handleChange
方法。 - 当
TemperatureInput
组件中的handleChange
被调用时,它将最新的数据传递给this.props.onTemperatureChange()
。这里的props都由父组件Calculator
提供。 - 之前渲染的时候,
Calculator
组件在摄氏度的TemperatureInput
组件上声明的onTemperatureChange
事件所调用的方法是handleCelsiusChange
,在华氏温度上的则是handleFahrenheitChange
。所以这两个函数的调用取决于我们在哪个输入框输入数据。 - 在这些方法中,
Calculator
组件调用setState()
将最新输入的值和scale重新渲染。 - React调用
Calculator
组件的render()
方法将UI渲染在页面上。两个输入框的值都在此时根据当前的状态重新计算,温度的换算也在此时进行。 - React调用两个
TemperatureInput
组件的render()
方法,并根据由Calculator
定义的props
渲染在页面上。 - React调用
BoilingVerdict
组件的render()
方法,并根据传进来的摄氏度温度渲染数据。 - React DOM更新沸腾结果和输入框的内容。我们刚刚编辑的输入框接收当前的值,而另一个输入框接收经过转换的值。
每一次更新都按照上述步骤进行,所以两个输入框数据可以保持同步。
经验总结
在React应用中任何可变的数据都应该有唯一数据源。通常来说,state一开始都是包含在需要根据它来渲染数据的组件中。但是如果有其他组件也想要使用这个state,那么就需要把它提升到距离这两个组件最近的公共父组件中。但是相比于保持不同组件的数据同步,我们更应该依靠的是自顶而下的数据流。
相较于双向绑定,状态提升需要编写更多的“样板”代码。但这样做的好处是我们可以更好地定位和分离bug。因为state只存在于各自的组件中,所以bug出现范围就大大减少了。除此之外,我们可以自由地实现任何逻辑来拒绝或修改用户输入。
如果某个数据可以根据state或者props推导出来,那么这个数据就不应该存储在state中。就像本章的实例代码中,我们只存储最新输入的温度和它对应的scale,并不需要将华氏温度和摄氏温度都存储在state中。另一个输入框的数据在调用render()方法时就可以根据当前的数据推算出来。这能让我们清除用户输入或者在不损失用户输入精度的情况下在其他区域使用用户输入的值。
当页面出现错误时,你可以使用React Developer Tools查看props并且在组件树上追溯源头直到找到对应的组件为止。这能够让你轻松地定位bug。