【譯】React v16.4.0:你可能並不需要派生狀態(Derived State)

Skandar-Ln發表於2018-06-29

【譯】React v16.4.0:你可能並不需要派生狀態(Derived State)

很長一段時間,componentWillReceiveProps生命週期是在不進行額外render的前提下,響應props中的改變並更新state的唯一方式。在16.3版本中,我們介紹了一個新的替代生命週期getDerivedStateFromProps去更安全地解決相同的問題。同時,我們意識到人們對這兩個方式都存在很多的誤解,我們發現了其中的一些反例觸發了一些奇怪的bug。在本次版本中我們修復了它,並讓derived state更加可預測,所以我們能更容易地注意到濫用的結果。

本文的反例既包含老的componentWillReceiveProps也包含新的getDerivedStateFromProps方法

什麼時候去使用派生狀態(Derived State)

getDerivedStateFromProps只為了一個目的存在。它使得一個元件能夠響應props的變化來更新自己內部的state。比如我們之前提到的根據變化的offset屬性記錄目前的滾動方向或者根據source屬性載入額外的資料

我們提供了許多了例項,因為一般來說,派生狀態應該被謹慎地使用。我們見過的所有關於派生狀態的問題最後都可以被歸為兩種:(1)從props那裡無條件地更新state(2)當props和state不匹配的時候更新state(我們在下面會深入探討)

  • 如果你使用派生狀態來記憶基於當前props的運算結果,你並不需要它。參見下面的關於快取記憶(memoization)
  • 如果你是第二種情況,那麼你的元件可能重置得太頻繁了。繼續讀下去獲得更多內容。

使用派生狀態的常見Bug

"受控""非受控"通常指代表單的輸入控制元件,但是它還可以用於描述元件的資料所處位置。通過props傳入的資料可被稱為受控的(因為父元件控制這資料)。只存在內部state的資料被稱作非受控的(因為父元件不能直接改變它)。

最常見的錯誤是將兩者搞混了。當一個派生狀態同時被setState更新的時候,資料就失去了單一的事實來源。上面提到的載入資料的例子看上去是類似的,但在一些關鍵的地方是有區別的。在例子中,每當source屬性變化,loading狀態必定會被覆蓋。反過來,狀態要麼在props變化的時候被覆蓋,要麼由元件自己管理。(譯註:可理解為同時只有單一的真實來源)

當任何一個限制被改變的時候就會發生問題,下面舉了兩個典型的例子。

反例:無條件地複製props到state

一個常見的誤解是getDerivedStateFromPropscomponentWillReceiveProps只會在props“改變”的時候呼叫。這些生命週期會在任何父元件發生render的時候呼叫,不管props是否真的改變。因此,使用這些週期去無條件地覆蓋state是不安全的。這樣做會使得state丟失更新

我們來演示一下這個問題。這是一個郵件輸入元件,它“對映”了email屬性到state:

class EmailInput extends Component {
  state = { email: this.props.email };

  render() {
    return <input onChange={this.handleChange} value={this.state.email} />;
  }

  handleChange = event => {
    this.setState({ email: event.target.value });
  };

  componentWillReceiveProps(nextProps) {
    // 這樣會抹去所有的內部狀態更新!
    // 不要這樣做.
    this.setState({ email: nextProps.email });
  }
}
複製程式碼

看上去這個元件好像沒問題。state被props中的值初始化,然後隨著<input>的輸入而更新。但是如果父元件發生render,我們在<input>中輸入的東西都會消失!(參見這裡的demo)即使我們在重置前比較nextProps.email !== this.state.email也會如此。

在這個簡單的例子中,新增shouldComponentUpdate去限制只在props中的email發生改變時才去重新render能解決這個問題。但在實際中,元件通常會接受很多的props。你無法避免其他的屬性發生改變。函式和物件屬性通常是內聯建立的,這讓我們很難去實現判斷是否發生了實質性的變化。這裡有一個demo來說明。因此,shouldComponentUpdate最好只是用來優化效能,而不是去確保派生狀態的正確性。

希望現在我們能弄清楚為什麼無條件地複製props到state是壞主意。在檢視可能的解決方案前,我們先來看一個相關的例子:如果我們只在email屬性發生改變的時候更新state呢?

反例:props改變時覆蓋state

繼續上面的例子,我們可以通過只在props.email改變時更新state來避免意外地覆蓋已有的state。

class EmailInput extends Component {
  state = {
    email: this.props.email
  };

  componentWillReceiveProps(nextProps) {
    // 只要props.email改變, 更新state.
    if (nextProps.email !== this.props.email) {
      this.setState({
        email: nextProps.email
      });
    }
  }
  
  // ...
}
複製程式碼

儘管上面的例子中使用的是componentWillReceiveProps,但這個反例同樣的也對getDerivedStateFromProps適用

我們剛邁進了一大步。現在我們的元件只會在props真正改變的時候覆蓋掉我們輸入的東西了。

當然這裡還是有一個微妙的問題。想象一個密碼管理app使用瞭如上的輸入元件。當切換兩個不同的賬號的時候,如果這兩個賬號的郵箱相同,那麼我們的重置就會失效。因為對於這兩個賬戶傳入的email屬性是一樣的。(譯註:好比你切換了一份資料來源,但是這兩份資料中的email是相等的,於是預期應該被重置的輸入框沒有被重置)檢視demo

這個設計從根本上是有缺陷的,但卻是很容易犯的錯誤。(我自己也曾犯過)幸運的是有兩個更好的可選方案。關鍵在於對於任何資料,你需要選擇一個作為其真實來源的元件,並且避免在其他元件中複製它。我們來看看下面的解決方案。

更好的方案

推薦:完全受控元件

一個解決上述問題的方案是完全移除我們元件中的state。如果email僅作為屬性存在,我們就不需要擔心和state的衝突。我們甚至可以把EmailInput作為一個更輕量級的函式元件:

function EmailInput(props) {
  return <input onChange={props.onChange} value={props.email} />;
}
複製程式碼

此舉簡化了我們元件的實現,但是如果我們仍然想儲存一份輸入的草稿值呢,這時候需要父元件手動來實現了,請看demo

推薦:用key標識的完全不受控元件

另一個可選方案是我們的元件完全控制eamil的“草稿”狀態。在這裡,我們的元件仍然接受props中的屬性用來初始值,但是它會忽略後續屬性的變化。

class EmailInput extends Component {
  state = { email: this.props.defaultEmail };

  handleChange = event => {
    this.setState({ email: event.target.value });
  };

  render() {
    return <input onChange={this.handleChange} value={this.state.email} />;
  }
}
複製程式碼

為了在切換不同內容時重置輸入值(像上面提到的密碼管理器),我們可以使用React的key屬性。當key改變,React會重新建立一個新的元件而不是更新它。Key一般用在動態列表,但是在這也是很有用的。這裡當選擇一個新使用者的時候,我們用使用者ID去重新建立這個email輸入元件:

<EmailInput
  defaultEmail={this.props.user.email}
  key={this.props.user.id}
/>
複製程式碼

每當ID改變,EmailInput會被重新建立然後state會被重置為最新的defaultEmail值。(點這檢視demo)其實你無需為每一個輸入框新增一個key。對整個表單新增一個key會顯得更有用。每當key改變,表單的所有元件都會被重建且被賦上乾淨的初始值。

大多數情況,這是重置state最好的辦法。

重新建立元件聽上去會很慢,但其實對效能的影響微乎其微。如果元件具有很多更新上的邏輯,則使用key甚至可以更快,因為該子樹的差異得以被繞過。

替代方案1:通過ID屬性重置非受控元件

如果因為某些原因無法使用key(比如元件初始化的代價很高),一個可行但笨重的辦法是在getDerivedStateFromProps監聽“userID”的改變。

class EmailInput extends Component {
  state = {
    email: this.props.defaultEmail,
    prevPropsUserID: this.props.userID
  };

  static getDerivedStateFromProps(props, state) {
    // Any time the current user changes,
    // Reset any parts of state that are tied to that user.
    // In this simple example, that's just the email.
    if (props.userID !== state.prevPropsUserID) {
      return {
        prevPropsUserID: props.userID,
        email: props.defaultEmail
      };
    }
    return null;
  }

  // ...
}
複製程式碼

如果我們這樣選擇,這也提供了僅重置部件的內部狀態的靈活性(這裡檢視demo)

上面的例子對於componentWillReceiveProps也是一樣的

替代方案2:使用例項方法重置非受控元件

在極少情況,你可能需要在沒有合適的ID作為key的情況下重置state。一個辦法是把key設為隨機值或者遞增的值,在你想要重置的時候改變它。另一個可行方案是暴露一個例項方法命令式地去改變內部的state:

class EmailInput extends Component {
  state = {
    email: this.props.defaultEmail
  };

  resetEmailForNewUser(newEmail) {
    this.setState({ email: newEmail });
  }

  // ...
}
複製程式碼

父表單元件就可以通過ref去呼叫這個方法,(點選這裡檢視demo

Ref這這類場景中挺有用,但我們推薦你儘量謹慎地去使用。即使在這個例子中這種方法也是不理想的,因為會造觸發兩次render。

關於快取記憶(memoization)

我們也見到過用派生狀態來確保render中計算量較大的值僅在輸入改變的時候重新計算。這個技術叫做memoization

使用它作快取記憶不一定是不好的,但通常不是最佳解決方案。管理派生狀態有一定的複雜度,這個複雜度還會隨著額外的屬性而增加。比如,如果我們給元件新增了第二個派生欄位,那麼我們需要分別跟蹤這兩個欄位的變化。

我們來看一個例子,我們把一個列表傳入這個元件,然後它需要按使用者的輸入篩選顯示出匹配的項。我們可以使用派生狀態去儲存篩選後的列表。

class Example extends Component {
  state = {
    filterText: "",
  };

  // *******************************************************
  // NOTE: 這個例子不是我們推薦的做法
  // 推薦的方法參見下面的例子.
  // *******************************************************

  static getDerivedStateFromProps(props, state) {
    // 每當列表陣列或關鍵字變化時篩選列表.
    // 注意到我們需要儲存prevPropsList和prevFilterText來監聽變化.
    if (
      props.list !== state.prevPropsList ||
      state.prevFilterText !== state.filterText
    ) {
      return {
        prevPropsList: props.list,
        prevFilterText: state.filterText,
        filteredList: props.list.filter(item => item.text.includes(state.filterText))
      };
    }
    return null;
  }

  handleChange = event => {
    this.setState({ filterText: event.target.value });
  };

  render() {
    return (
      <Fragment>
        <input onChange={this.handleChange} value={this.state.filterText} />
        <ul>{this.state.filteredList.map(item => <li key={item.id}>{item.text}</li>)}</ul>
      </Fragment>
    );
  }
}
複製程式碼

這裡的實現避免了filteredList不必要的重新計算。但其實過於複雜了,因為它需要分別跟蹤和監聽props和state的變化來正確地更新篩選列表。這個例子中,我們可以使用PureComponent來簡化操作然後把篩選操作放到render方法中去:

// PureComponents只會在至少state和props中有一個屬性發生變化時渲染.
// 變化是通過引用比較來判斷的.
class Example extends PureComponent {
  // State only needs to hold the current filter text value:
  state = {
    filterText: ""
  };

  handleChange = event => {
    this.setState({ filterText: event.target.value });
  };

  render() {
    // 只有props.list 或 state.filterText 改變時才會呼叫.
    const filteredList = this.props.list.filter(
      item => item.text.includes(this.state.filterText)
    )

    return (
      <Fragment>
        <input onChange={this.handleChange} value={this.state.filterText} />
        <ul>{filteredList.map(item => <li key={item.id}>{item.text}</li>)}</ul>
      </Fragment>
    );
  }
}
複製程式碼

上面的方法比派生狀態的版本更簡單幹淨。然而這也不是個好方法,因為對於長列表來說可能比較慢,而且PureComponent無法阻止其他屬性改變造成的render。為了應對這種情況我們引入了一個memoization輔助器來避免多餘的篩選。

import memoize from "memoize-one";

class Example extends Component {
  // State只需要去維護目前的篩選關鍵字:
  state = { filterText: "" };

  // 當列表陣列或關鍵字變化時重新篩選
  filter = memoize(
    (list, filterText) => list.filter(item => item.text.includes(filterText))
  );

  handleChange = event => {
    this.setState({ filterText: event.target.value });
  };

  render() {
    // 計算渲染列表時,如果引數同上次計算沒有改變,`memoize-one`會複用上次返回的結果
    const filteredList = this.filter(this.props.list, this.state.filterText);

    return (
      <Fragment>
        <input onChange={this.handleChange} value={this.state.filterText} />
        <ul>{filteredList.map(item => <li key={item.id}>{item.text}</li>)}</ul>
      </Fragment>
    );
  }
}
複製程式碼

這樣更簡單地實現了和派生狀態一樣的功能!

總結

在實際應用中,元件通常既包含受控與非受控的元素。這沒問題,如果每個資料都有清晰的真實來源,你就可以避開上面提到的反例。

getDerivedStateFromProps的用法值得被重新思考,因為他是一個擁有一定複雜度的高階特性,我們應該謹慎地使用。

原文連結:You Probably Don't Need Derived State

相關文章