React 提升共用狀態 (Lifting State Up) | 子元件事件 (Child Events)

很常你會遇到一種情況是,有好幾個獨立元件同時都跟某個資料狀態 (shared state) 的改變有相依性,React 建議的處理模式是,將這個資料狀態提升 (lifting state up) 到這些元件的某個共同父元件 (closest common ancestor) 上面。

實作概念是,當子元件事件 (Child Events) 發生時,透過父元件傳進來的 callback function 通知父元件有資料變動,然後由父元件統一計算得到新的父元件 state,然後透過 props 反應資料更新回這些子元件。

這 design pattern 幫助程式更乾淨好維護,不會在很多元件中都有一樣的 state copy 需要維護資料同步的問題,也幫助你更好 debug,因為只有一個 source of truth 就是父元件的 state。

拿一個 "判斷水溫是否已經沸騰" 的程式當例子,我們有兩個輸入欄位,分別用來輸入攝氏溫度或華氏溫度,讓使用者可以在任一欄位輸入溫度來判斷溫度是否已達沸騰,這兩個欄位的溫度需要做自動單位換算隨時保持兩邊的溫度值同步:

const scaleNames = {
  c: '攝氏溫度',
  f: '華氏溫度'
};

// 從華氏溫度轉換成攝氏溫度
function toCelsius(fahrenheit) {
  return (fahrenheit - 32) * 5 / 9;
}

// 從攝氏溫度轉換成華氏溫度
function toFahrenheit(celsius) {
  return (celsius * 9 / 5) + 32;
}

// 通用的溫度轉換函數
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();
}

// 沸騰狀態顯示元件
// 這是一個子元件
function BoilingVerdict(props) {
  // 當超過攝氏 100 度告訴使用者水已經沸騰
  if (props.celsius >= 100) {
    return <p>水已經沸騰囉!</p>;
  }
  return <p>水還沒沸騰,還要再煮一會兒</p>;
}

// 溫度輸入元件
// 這是一個子元件
class TemperatureInput extends React.Component {
  constructor(props) {
    super(props);
    this.handleChange = this.handleChange.bind(this);
  }

  handleChange(e) {
    // 當 input value 改變時,透過 parent 傳進來的 callback 通知上層
    this.props.onTemperatureChange(e.target.value);
  }

  render() {
    const temperature = this.props.temperature;
    const scale = this.props.scale;
    return (
      <fieldset>
        <legend>請輸入溫度 - {scaleNames[scale]}:</legend>
        <input value={temperature}
               onChange={this.handleChange} />
      </fieldset>
    );
  }
}

// 判斷水溫是否已經沸騰元件
// 這是一個父元件
class Calculator extends React.Component {
  constructor(props) {
    super(props);
    this.handleCelsiusChange = this.handleCelsiusChange.bind(this);
    this.handleFahrenheitChange = this.handleFahrenheitChange.bind(this);
    
    // 統一在父元件這邊維護一個共用的 state
    this.state = {temperature: '', scale: 'c'};
  }

  // 傳進子元件的 callback function
  // 當子元件中攝氏溫度欄位改變時 call 這個 function 通知父元件
  handleCelsiusChange(temperature) {
    this.setState({scale: 'c', temperature});
  }

  // 傳進子元件的 callback function
  // 當子元件中華氏溫度欄位改變時 call 這個 function 通知父元件
  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;

    // 將最新的溫度 state,透過 props 同步到所有需要這資料的子元件中
    // 將 callback function 也透過 props 傳進子元件,用來讓子元件 Lifting State Up
    return (
      <div>
        <TemperatureInput
          scale="c"
          temperature={celsius}
          onTemperatureChange={this.handleCelsiusChange} />
        <TemperatureInput
          scale="f"
          temperature={fahrenheit}
          onTemperatureChange={this.handleFahrenheitChange} />
        <BoilingVerdict
          celsius={parseFloat(celsius)} />
      </div>
    );
  }
}

ReactDOM.render(
  <Calculator />,
  document.getElementById('root')
);

點我看這個例子的結果