No.010 狀態提升

狀態提升

通常,多個組件需要反映相同的變化數據,這時我們建議將共享狀態提升到最近的共同父組件中去。讓我們看看它是如何運作的。

在本節中,我們將創建一個用於計算水在給定溫度下是否會沸騰的溫度計算器。

我們將從一個名爲 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 的組件。它渲染一個用於輸入溫度的 <input>,並將其值保存在 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>
    );
  }
}

在 CodePen 上嘗試

添加第二個輸入框

我們的新需求是,在已有攝氏溫度輸入框的基礎上,我們提供華氏度的輸入框,並保持兩個輸入框的數據同步。

我們先從 Calculator 組件中抽離出 TemperatureInput 組件,然後爲其添加一個新的 scaleprop,它可以是 "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>
    );
  }
}

在 CodePen 上嘗試

我們現在有了兩個輸入框,但當你在其中一個輸入溫度時,另一個並不會更新。這與我們的要求相矛盾:我們希望讓它們保持同步。

另外,我們也不能通過 Calculator 組件展示 BoilingVerdict 組件的渲染結果。因爲 Calculator 組件並不知道隱藏在 TemperatureInput 組件中的當前溫度是多少。

編寫轉換函數

首先,我們將編寫兩個可以在攝氏度與華氏度之間相互轉換的函數:

function toCelsius(fahrenheit) {
  return (fahrenheit - 32) * 5 / 9;
}

function toFahrenheit(celsius) {
  return (celsius * 9 / 5) + 32;
}

上述兩個函數僅做數值轉換。而我們將編寫另一個函數,它接受字符串類型的 temperature 和轉換函數作爲參數並返回一個字符串。我們將使用它來依據一個輸入框的值計算出另一個輸入框的值。

當輸入 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 向上移動到它們的最近共同父組件中,便可實現共享 state。這就是所謂的“狀態提升”。接下來,我們將 TemperatureInput 組件中的 state 移動至 Calculator 組件中去。

如果 Calculator 組件擁有了共享的 state,它將成爲兩個溫度輸入框中當前溫度的“數據源”。它能夠使得兩個溫度輸入框的數值彼此保持一致。由於兩個 TemperatureInput 組件的 props 均來自共同的父組件 Calculator,因此兩個輸入框中的內容將始終保持一致。

讓我們看看這是如何一步一步實現的。

首先,我們將 TemperatureInput 組件中的 this.state.temperature 替換爲 this.props.temperature。現在,我們先假定 this.props.temperature 已經存在,儘管將來我們需要通過 Calculator 組件將其傳入:

  render() {
    // Before: const temperature = this.state.temperature;
    const temperature = this.props.temperature;
    // ...

我們知道 props 是隻讀的。當 temperature 存在於 TemperatureInput 組件的 state 中時,組件調用 this.setState() 便可修改它。然而,temperature 是由父組件傳入的 prop,TemperatureInput 組件便失去了對它的控制權。

在 React 中,這個問題通常是通過使用“受控組件”來解決的。與 DOM 中的 <input> 接受 value 和 onChange 一樣,自定義的 TemperatureInput 組件接受 temperature 和 onTemperatureChange 這兩個來自父組件 Calculator 的 props。

現在,當 TemperatureInput 組件想更新溫度時,需調用 this.props.onTemperatureChange來更新它:

  handleChange(e) {
    // Before: this.setState({temperature: e.target.value});
    this.props.onTemperatureChange(e.target.value);
    // ...

注意:

自定義組件中的 temperature 和 onTemperatureChange 這兩個 prop 的命名沒有任何特殊含義。我們可以給它們取其它任意的名字,例如,把它們命名爲 value 和 onChange 就是一種習慣。

onTemperatureChange 的 prop 和 temperature 的 prop 一樣,均由父組件 Calculator 提供。它通過修改父組件自身的內部 state 來處理數據的變化,進而使用新的數值重新渲染兩個輸入框。我們將很快看到修改後的 Calculator 組件效果。

在深入研究 Calculator 組件的變化之前,讓我們回顧一下 TemperatureInput 組件的變化。我們移除組件自身的 state,通過使用 this.props.temperature 替代 this.state.temperature 來讀取溫度數據。當我們想要響應數據改變時,我們需要調用 Calculator 組件提供的 this.props.onTemperatureChange(),而不再使用 this.setState()

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 中。這個 state 就是從兩個輸入框組件中“提升”而來的,並且它將用作兩個輸入框組件的共同“數據源”。這是我們爲了渲染兩個輸入框所需要的所有數據的最小表示。

例如,當我們在攝氏度輸入框中鍵入 37 時,Calculator 組件中的 state 將會是:

{
  temperature: '37',
  scale: 'c'
}

如果我們之後修改華氏度的輸入框中的內容爲 212 時,Calculator 組件中的 state 將會是:

{
  temperature: '212',
  scale: 'f'
}

我們可以存儲兩個輸入框中的值,但這並不是必要的。我們只需要存儲最近修改的溫度及其計量單位即可,根據當前的 temperature 和 scale 就可以計算出另一個輸入框的值。

由於兩個輸入框中的數值由同一個 state 計算而來,因此它們始終保持同步:

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>
    );
  }
}

在 CodePen 上嘗試

現在無論你編輯哪個輸入框中的內容,Calculator 組件中的 this.state.temperature 和 this.state.scale 均會被更新。其中一個輸入框保留用戶的輸入並取值,另一個輸入框始終基於這個值顯示轉換後的結果。

讓我們來重新梳理一下當你對輸入框內容進行編輯時會發生些什麼:

  • React 會調用 DOM 中 <input> 的 onChange 方法。在本實例中,它是 TemperatureInput 組件的 handleChange方法。
  • TemperatureInput 組件中的 handleChange 方法會調用 this.props.onTemperatureChange(),並傳入新輸入的值作爲參數。其 props 諸如 onTemperatureChange 之類,均由父組件 Calculator 提供。
  • 起初渲染時,用於攝氏度輸入的子組件 TemperatureInput 中 onTemperatureChange 方法爲 Calculator 組件中的 handleCelsiusChange 方法,而,用於華氏度輸入的子組件 TemperatureInput 中的 onTemperatureChange 方法爲 Calculator 組件中的 handleFahrenheitChange 方法。因此,無論哪個輸入框被編輯都會調用 Calculator 組件中對應的方法。
  • 在這些方法內部,Calculator 組件通過使用新的輸入值與當前輸入框對應的溫度計量單位來調用 this.setState()進而請求 React 重新渲染自己本身。
  • React 調用 Calculator 組件的 render 方法得到組件的 UI 呈現。溫度轉換在這時進行,兩個輸入框中的數值通過當前輸入溫度和其計量單位來重新計算獲得。
  • React 使用 Calculator 組件提供的新 props 分別調用兩個 TemperatureInput 子組件的 render 方法來獲取子組件的 UI 呈現。
  • React 調用 BoilingVerdict 組件的 render 方法,並將攝氏溫度值以組件 props 方式傳入。
  • React DOM 根據輸入值匹配水是否沸騰,並將結果更新至 DOM。我們剛剛編輯的輸入框接收其當前值,另一個輸入框內容更新爲轉換後的溫度值。

得益於每次的更新都經歷相同的步驟,兩個輸入框的內容才能始終保持同步。

學習小結

在 React 應用中,任何可變數據應當只有一個相對應的唯一“數據源”。通常,state 都是首先添加到需要渲染數據的組件中去。然後,如果其他組件也需要這個 state,那麼你可以將它提升至這些組件的最近共同父組件中。你應當依靠自上而下的數據流,而不是嘗試在不同組件間同步 state。

雖然提升 state 方式比雙向綁定方式需要編寫更多的“樣板”代碼,但帶來的好處是,排查和隔離 bug 所需的工作量將會變少。由於“存在”於組件中的任何 state,僅有組件自己能夠修改它,因此 bug 的排查範圍被大大縮減了。此外,你也可以使用自定義邏輯來拒絕或轉換用戶的輸入。

如果某些數據可以由 props 或 state 推導得出,那麼它就不應該存在於 state 中。舉個例子,本例中我們沒有將 celsiusValue 和 fahrenheitValue 一起保存,而是僅保存了最後修改的 temperature 和它的 scale。這是因爲另一個輸入框的溫度值始終可以通過這兩個值以及組件的 render() 方法獲得。這使得我們能夠清除輸入框內容,亦或是,在不損失用戶操作的輸入框內數值精度的前提下對另一個輸入框內的轉換數值做四捨五入的操作。

當你在 UI 中發現錯誤時,可以使用 React 開發者工具 來檢查問題組件的 props,並且按照組件樹結構逐級向上搜尋,直到定位到負責更新 state 的那個組件。這使得你能夠追蹤到產生 bug 的源頭:

Monitoring State in React DevTools

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章