You Probably Dont Need Derived State

南賜發表於2018-07-27

原文連結:https://reactjs.org/blog/2018…
React 16.4包含了一個getDerivedStateFromProps的 bug 修復:曾帶來一些 React 元件頻繁複現的 已有bug。如果你的應用曾經採用某種反模式寫法,但是在這次修復之後沒有被覆蓋到你的情況,我們對於該 bug 深感抱歉。在下文,我們會闡述一些常見的,derived state相關的反模式,還有我們的建議寫法。

很長一段時間,componentWillReceiveProps是響應props 改變,不會帶來額外重新渲染,更新 state 的唯一方式。在16.3版本中,我們引入了一個生命週期方法getDerivedStateFromProps,為的是以一種更安全的方式來解決同樣的問題。同時,我們意識到人們對於這兩個鉤子函式的使用有許多誤解,也發現了一些造成這些晦澀 bug 的反模式。getDerivedStateFromProps的16.4版本修復使得 derived state更穩定,濫用情況會減少一些。

注意事項

本文提及的所有反模式案例面向舊鉤子函式componentWillReceiveProps和新鉤子函式getDerivedStateFromProps

本文會涵蓋下面討論:

  • 什麼時候去使用 derived state
  • 一些 derived state 的常見 bug

    • 反模式:無條件地拷貝props 到state
    • 反模式:當 props 改變的時候清除 state
  • 建議解決方案
  • 記憶體化

什麼時候去使用Derived State

getDerivedStateFromProps存在的唯一目的是使得元件在 props 改變時能都更新好內在state。我們之前的博文有過一些例子,比如基於一個變化著的偏移 prop 來記錄當前滾動方向或者根據一個來源 prop 來載入外部資料。

我們沒有給出許多例子,因為總體原則上來講,derived state 應該用少點。我們見過的所有derived state 的問題大多數可以歸結為,要麼沒有任何前提條件的從 props 更新state,要麼 props,state 不匹配的任何時候去更新 state。(我們將在下面談及更多細節)

  • 如果你正在使用 derived state 來進行一些基於當前 props 的記憶體化計算,那麼你不需要 derived state。memoization 小節會細細道來。
  • 如果你在無條件地更新 derived state或者 props,state 不匹配的時候去更新它,你的元件很可能太頻繁地重置 state,繼續閱讀可見分曉。

derived state 的常見 bug

受控,不受控概念通常針對表單輸入,但是也可以用來描述元件的資料活動。props 傳遞進來的資料可以看成受控的(因為父元件控制了資料來源)。元件內部狀態的資料可以看成不受控的(因為元件能直接改變他)。

最常見的derived state錯誤 就是混淆兩者(受控,不受控資料);當一個 state 的變更欄位也可以通過 setState 呼叫來更新的時候,就沒有一個單一的(真相)資料來源。上面談及的載入外部資料的例子可能聽起來情況類似,但是一些重要方面還是不一樣的。在載入例子中,source 屬性和 loading 狀態有著一個清晰資料來源。當source prop改變的時候,loading 狀態總是被重寫。相反,loading 狀態只會在 prop 改變的時候被重寫,其他情況下就是被元件管控著。

問題就是在這些約束變化的時候出現的。最典型的兩種形式如下,我們來瞧瞧:

反模式: 無條件的從 props 拷貝至 state

一個常見的誤解就是以為getDerivedStateFromPropscomponentWillReceivedProps會只在props 改變的時候被呼叫。實際上這兩個鉤子函式可能在父元件渲染的任何時候被呼叫,不管 props 是不是和以前不同。因此,用這兩個鉤子函式來無條件消除 state 是不安全的。這樣做會使得 state 更新丟失。

我們看看一個範例,這是一個郵箱輸入元件,映象了一個 email prop 到 state:

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

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

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

  componentWillReceiveProps(nextProps) {
    // This will erase any local state updates!
    // Do not do this.
    this.setState({ email: nextProps.email })
  }
}

剛開始,該元件可能看起來 Okay。State 依靠 props 來進行值初始化,我們輸入的時候也會更新 State。但是如果父元件重新渲染的時候,我們敲入的任何字元都會被忽略。就算我們在 鉤子函式setState 之前進行了nextProps.email !== this.state.email的比較,也無濟於事。

在這個簡單例子中,我們可以通過增加shouldComponentUpdate,使得只在 email prop改變的時候重新渲染。但是實踐表明,元件通常會有多個 prop,另一個 prop的改變仍舊可能造成重新渲染還是有不正確的重置。函式和物件型別的 prop 經常行內生成。使得shouldComponentUpdate只允許在一種情形發生時返回 true很難實現。這兒有個直觀例子。所以,shouldComponentUpdate是效能優化的最佳手段,不要想著確保 derived state 的正確使用。

希望現在的你明白了為什麼無條件拷貝 props 到 state 是個壞主意。在總結解決方案之前,我們來看看相關反模式:如果我們指向在 email prop 改變的時候去更新 state 呢

反模式: props 改變的時候擦除 state
接著上面例子繼續,我們可以避免在 props.email改變的時候故意擦除 state:

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

  componentWillReceiveProps(nextProps) {
    // Any time props.email changes, update state.
    if (nextProps.email !== this.props.email) {
      this.setState({
        email: nextProps.email
      })
    }
  }
}

注意事項

即使上面的例子中只談到 componentWillReceiveProps, 但是也同樣適用於getDerivedStateFromProps

我們已經改善許多,現在元件會只在props 改變的時候清除我們輸入過的舊字元。

但是還有一個殘留問題。想象一下一個密碼控制元件在使用上述輸入框元件,當涉及到擁有同一郵箱的兩個帳號的細節式,輸入框無法重置。因為 傳遞給元件的prop值,對於兩個帳號而言是一樣的。這會困擾到使用者,因為一個賬號還沒儲存的變更將會影響到共享同一郵箱的其他帳號。這有demo

這是個根本性的設計失誤,但是也很容易犯錯,比如我。幸運的是有兩個更好的方案。關鍵在於,對於任何片段資料,需要用一個單獨元件來儲存資料,並且要避免在其他元件重複。我們來看看這兩個方案:

解決方案

推薦方案一:全受控元件

避免上面問題的一個辦法,就是從元件當中完全移除 state。如果我們的郵箱地址只是作為一個 prop 存在,那麼我們不用擔心和 state 的衝突。甚至可以把EmailInput轉換成一個更輕量的函式元件:

function EmailInput(props) {
  return <input onChange={props.onChange} value={props.email} />
}

這個辦法簡化了元件的實現,如果我們仍然想要儲存草稿值的話,父表單元件將需要手動處理。這有一個這種模式的demo

推薦方案二: 帶有 key 屬性的全不受控元件

另一個方案就是我們的元件需要完全控制 draft 郵箱狀態值。這樣的話,元件仍然可以接受一個prop初始值,但是會忽略該prop 的連續變化:

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

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

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

在聚焦到另一個表單項的時候為了重置郵箱值(比如密碼控制元件場景),我們可以使用React 的 key 屬性。當 key 變化時,React 會建立一個新元件例項,而不是更新當前元件。Keys 通常對於動態列表很有用,不過在這裡也很有用。在一個新使用者選中時,我們用 user ID 來重新建立一個表單輸入框:

<EmailInput
  defaultEmail={this.props.user.email}
  key={this.props.user.id}
/>

每次 ID 改變的時候,EmailInput輸入框都會重新生成,它的 state 也就會重置到最新的 defaultEmail值。栗子不能少,這個方案下,沒有必要把 key 值新增到每個輸入框。在整個form表單上 新增一個 key 屬性或許會更合理。每次 key 變化時,表單內的所有元件都會重新生成,同時初始化 state。

在大多數情況,這是處理需要重置的state的最佳辦法。

注意事項

這個辦法可能聽起來效能慢,但是實際表現上可能微不足道。如果一個元件有複雜更新邏輯的話使用key屬性可能會更快,因為diffing演算法走了彎路

  • 方案一:通過 ID 屬性重置 uncontrolled 元件

如果 key 由於某個原因不生效(有可能是元件初始化成本高),那麼一個可用但是笨拙的辦法就是在getDerivedStateFromProps裡監聽userID 的變化。

class EmailInput extends Component {
  state = {
    email: this.props.defaulEmail,
    pervPropsUserID: this.props.userID,
  }

  static getDerivedFromProps(nextProps, prevState) {
    // 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 (nextProps.userID !== prevState.prevPropsUserID) {
      return {
        prevPropsUserID: nextProps.userID,
        email: nextProps.defaultEmail,
      }
    }
    return null
  }

  // ...
}

如果這麼做的話,也給只重置元件部分內在狀態帶來了靈活性,舉個例子

注意事項

即使上面的例子中只談到 getDerivedStateFromProps, 但是也同樣適用於componentWillReceiveProps

  • 方案二:用例項方法來重置非受控元件

極少情況下,即使沒有用作 key 的合適 ID,你還是想重置 state。一個辦法是把 key重置成隨機值或者每次你想重置的時候會自動糾正。另一個選擇就是用一個例項方法用來命令式地重置內部狀態。

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

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

  // ...
}

父表單元件就可以使用一個 ref 屬性來呼叫這個方法這裡有 Demo.

總結

總結一下,設計一個元件的時候,重要的是確定資料是受控還是不受控。

不要把 prop 值“映象”到 state,而是要讓元件受控,並且合併在一些父元件中的兩個分叉值。比如說,不是要讓子元件接收一個props.value,並且跟蹤一個草稿欄位state.value,而是要讓父元件管理 state.draftValue還有state.committedValue,直接控制子元件的值。會使得資料流更明顯,更穩定。

對於不受控元件,如果你想要在一個 ID 這樣的特殊 prop 變化的時候重置 state,你會有以下選項:

  • 推薦:為了重置所有內部state,使用 key 屬性
  • 方案一:為了重置某些欄位值,監聽一個props.userID這種特殊欄位的變化
  • 方案二:也可以會退到使用 refs 屬性的命令式例項方法

記憶體化

我們已經看到 derived state 為了確保一個用在 render的欄位而在輸入框變化時被重新計算。這項技術叫做記憶體化

使用 derived state 去達到記憶體化並沒有那麼糟糕,但是也不是最佳方案。管理 derived state 本身比較複雜,屬性變多時變得更復雜了。比如說,如果我們增加第二個 derived 欄位到我們的元件 state,那麼我們需要針對兩個值的變化來做追蹤。

看看一個元件例子,它有一個列表 prop,元件渲染出匹配使用者查詢輸入字元的列表選項。我們應該使用 derived state 來儲存過濾好的列表。

class Example extends Component {
  state = {
    filterText: ``,
  }

  // ********************
  // NOTE: this example is NOT the recommended approach.
  // See the examples below for our recommendations instead.
  // ********************
  staitic getDerivedStateFromProps(nextProps, prevState) {
    // Re-run the filter whenever the list array or filter text change.
    // Note we need to store prePropsList and prevFilterText to detect change.
    if ( nextProps.list !== prevState.prevPropsList || prevState.prevFilterList !== prevState.filterText) {
      return {
        prevPropsList: nextProps.list,
        prevFilterText: prevState.filterText,
        filteredList: nextProps.list.filter(item => item.text.includes(prevState.filterText))
      }
    }
    return null
  }

  handleChange = e => {
    this.setState({ filterText: e.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 的變化,為的是適當的更新過濾好的列表。這裡,我們可以使用PureCompoennt來做簡化,把過濾操作放到 render 方法裡去:

// PureCompoents only rerender if at least one stae or prop value changes.
// Change is determined by doing a shallow comparison of stae and prop keys.
class Example Extends PureComponent {
  // State only needs to hold the current filter text value:
  state = {
    filterText: ``,
  }

  handleChange = e => {
    htis.setState({ filterText: e.target.value })
  }

  render () {
    // The render method on this PureComponent is called only if
    // props.list or state.filterList has changed.
    const filteredList = this.props.list.filter(
      item => item.text.includes(this.stae.filterText)
    )

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

上面程式碼要乾淨多了而且比 derived state 版本要更簡單。只是偶爾不夠好:對於大列表的過濾有點慢,而且如果另一個 prop 要變化的話PureComponent不會防止重新渲染。基於這樣的考慮,我們增加了memoization helper來避免非必要的列表重新過濾:

import memoize from `memoize-one`

class Example extends Component {
  // State only need to hold the current filter text value:
  state = { filterText: `` }

  filter = memoize(
    (list, filterText) => list.filter(item => item.text.includes(filterText))
  )

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

  render () {
    // Calculate the latest filtered list. If these arguments havent changed
    // since the last render, ``memoize-one` will reuse the last return value.
    const filteredList = this.filter(this.props.list, this.sate.filterText)

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

這要簡單多了,而且和 derived state 版本一樣好。

當使用memoization的時候,需要滿足一些條件:

  1. 在大多數情況下,你會把記憶體化函式新增到一個元件例項上。這會防止該元件的多個例項重置每一個記憶體化屬性。
  2. 通常你使用一個帶有有限快取大小的記憶體化工具,為的是防止時間累計下來的記憶體洩露。(在上述例子中,我們使用memoize-one因為它僅僅會快取最近的引數和結果)。
  3. 這一節裡,如果每次父元件渲染的時候props.list重新生成的話,上述實現會失效。但是在多數情況下,上述實現是合適的。

結束語

在實際應用中,元件經常混合著受控和不受控的行為。理所應當。如果每個值都有明確源,你就可以避免上買呢反模式。

重申一下,由於比較複雜,getDerivedStateFromProps(還有 derived state)是一項高階特性,而且應該用少點。如果你使用的時候遇到麻煩,請在 GitHub 或者 Twitter 上聯絡我們。

相關文章