譯---React16新生命周期函數getDerivedStateFromProps的使用,你也許並不需要派生狀態


原文:You Probably Don’t Need Derived State

React16.4包含一個關於 getDerivedStateFromProps 的bugfix ,可以讓React組件中的一些現有bug以更一致的方式重現。如果這次變動導致您的應用程序使用了反模式,並且出現在修復后沒有正常工作的情況,我們對這種情況感到抱歉。在這篇文章中,我們將解釋一些具有派生狀態的常見反模式和我們首選的替代方案。

在很長一段時間,componentWillReceiveProps是在沒有附加渲染的情況下更新狀態的唯一方法。在版本16.3中,我們引入了一個全新的生命周期---getDerivedStateFromProps--用來替換componentWillReceiveProps,並用更安全的方式處理相同的場景。於此同時,我們意識到人們對如何使用這兩種方法有很多誤解,我們發現了一些反模式,這些錯誤導致了微妙而令人困惑的bug。在16.4中,有關getDerivedStateFromProps的修復使得派生狀態更加可預測,因此錯誤使用的結果更容易被注意到。

注:這篇文章中描述的所有反模式都適用於較老的componentWillReceiveProps和較新的getDerivedStateFromProps.

這篇博客文章將介紹以下主題:

何時使用派生狀態

getDerivedStateFromProps的存在只有一個目的。它使組件能夠根據changes in props的結果更新其內部狀態。

根據一般規則——謹慎使用派生狀態,我們沒有提供很多例子。我們所看到的有關派生狀態的所有問題最終都可以歸結為(1)無條件地更新狀態,或者(2)當props和state不匹配時更新state。(我們將在下面詳細討論這兩個問題。)

  • 如果你正在使用派生狀態,且目的僅僅是根據當前的props對一些計算進行記憶,那么你並不需要派生狀態。參考什么是memoization?
  • 如果您無條件地更新派生狀態,或者在props和state不匹配時更新它,那么您的組件可能會頻繁地重置它的state。

使用派生狀態遇到的常見bug

“受控”和“不受控制”的術語通常指的是表單輸入,但他們還可以描述任何組件數據的位置。作為props傳遞進組件的數據可以被認為是受控的(因為父組件控制數據)。只存在於內部狀態的數據可以被認為是不受控制的(因為父類不能直接更改它)。

派生狀態最常見的錯誤是混合這兩個;當一個派生狀態值也通過setState被更新時,數據就沒有單一的真實來源。

當這些約束被改變時,就會出現問題。這通常有兩種形式。讓我們來看看這兩種情況。

反模式:無條件得使用props對state賦值

一個常見的誤解是,當props“改變”時,getDerivedStateFromPropscomponentWillReceiveProps才會被調用。事實上,只要父組件重新渲染,這些生命周期函數就會被調用,不管這些props是否與以前“不同”。正因為如此,使用任何一個去 無條件 地覆蓋覆蓋state都是不安全的。這樣做會導致狀態更新丟失。

讓我們看個例子來說明這個問題。這是一個EmailInput組件,該組件通過props “email” 來映射state “email”:

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 will erase any local state updates!
    // Do not do this.
    this.setState({ email: nextProps.email });
  }
}

 

這個組件可能看起來不錯。state被初始化為由props指定的值,並且在輸入<input>時實時更新state。但是,如果我們的組件的父類重新渲染,我們在<input>輸入的任何東西都將丟失!(參見這個演示示例。)即使我們在重置之前比較nextProps.email !== this.state.email,也一樣。

在這個簡單的例子中,為了解決這個問題,必須通過添加shouldComponentUpdate,並判斷只有prop email發生改變時才重新渲染。然而在實際情況中,組件通常接受多個prop;任何一個prop發生改變都會導致重新運行和不正確的重置。而且對於Function和object類型的prop,shouldComponentUpdate很難判斷是否發生了實質性的變化。這里有一個演示shouldComponentUpdate最好作為性能優化使用,而不是為了確保派生狀態的正確性。

希望你現在可以清楚地知道為什么無條件得使用props對state賦值是一個壞主意。在回顧可能的解決方案之前,讓我們看看一個於此相關的另外一個問題模式:如果我們只在屬性email改變時更新狀態會怎樣?

反模式:當props改變時清除state

繼續上邊的例子,我們判斷只有當props.email發生改變時才去執行更新,以此來避免狀態被清除:

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真正改變的時候,組件才會擦除我們輸入的內容。

現在出現了一個微妙的問題。想象一個使用上述輸入組件的密碼管理器應用程序。當使用相同的電子郵件在兩個帳戶的詳細信息之間導航時,輸入將無法重置。這是因為傳遞給組件的屬性值對於兩個帳戶都是相同的!這對用戶來說是一個驚喜,因為一個賬戶的未保存的變更似乎會影響到其他的帳戶,這些帳戶碰巧共享相同的電子郵件。(示例)

這種設計從根本上來說是有缺陷的,但這卻是一個極易犯的錯誤。幸運的是,有兩種替代方案可以更好地工作。兩種方案的關鍵在於——對於任何數據,您都需要確保只有一個組件作為實際的來源,並避免在其他組件中復制它。現在來看一下這兩種方案。

首選方案

推薦: 完全受控組件

避免上面提到的問題的一種方法是徹底從組件中刪除狀態。如果"郵件地址"只是作為屬性存在,那么我們就不必擔心與狀態的沖突。我們甚至可以把EmailInput轉換成輕量的函數組件:

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

 

 

這種方法簡化了組件的實現,但是如果我們仍然想要儲存一個中間值(draft value),那么父表單組件現在就只能手動完成這件事。(示例)

推薦: 有"key"的完全非受控組件

另一種方案是,讓組件完全擁有中間的email狀態(draft email state)。在這個示例中,我們的組件仍然接收一個屬性用來設置email的初始值,但是卻無法接收這個屬性之后的變化:

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

 

 

為了在移動到另一項(如密碼管理器場景)時可以重新賦值,我們可以使用“key”這個React的特殊屬性。當一個“key”發生變化時,React將創建一個新的組件實例,而不是更新當前的一個實例。“key”通常用於動態列表,但在這里也很有用。在我們的例子中,我們可以使用用戶ID在新用戶被選中時重新創建"EmailInput":

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

 

每當ID改變時,EmailInput將被重新創建,它的狀態將被重置為最新的defaultEmail值(示例)。使用這種方法,您不需要向每個輸入項添加key。把key放在整個表單上可能更有意義。每次改變時,表單中的所有組件都將用一個新初始化的狀態重新創建。

在大多數情況下,這是處理有重置要求的狀態的最好方法。

注意

這看起來似乎會變慢,不過這點性能差異通常情況是無關緊要的(原文:While this may sound slow, the performance difference is usually insignificant)。相反,如果組件具有在更新上運行的重邏輯,則使用“key”甚至可以更快,因為該子樹的diff運算被省略了。

備選 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;
  }

  // ...
}

 

這也提供了另一種靈活的處理方案,我們可以有選擇的,只重置組件內部的某些狀態。(示例)

注意

雖然上邊的示例使用的是getDerivedStateFromProps,對於componentWillReceiveProps也同樣有效

備選 2: 通過實例方法重置非受控組件

比較不常見的情況是,您需要重新設置狀態,卻沒有合適的ID作為key。一種解決方案是在每次想要重置的時候,將“key”重置為一個隨機值或自動遞增的數字。另一種可行的替代方法是公開實例方法,以強制重置內部狀態:

class EmailInput extends Component { state = { email: this.props.defaultEmail }; resetEmailForNewUser(newEmail) { this.setState({ email: newEmail }); } // ... }

父級表單組件可以通過ref調用該方法(示例)

在某些情況下,Refs可能會很有用,但一般來說,我們建議您謹慎地使用它們。即使在示例中,這種命令式方法也是不理想的,因為要發生兩次渲染。


回顧

回顧一下,在設計組件時,最重要的是決定它的數據是否需要被控制。

與其嘗試在狀態中鏡像一個屬性值,不如讓組件被控制,並在某些父組件的狀態中合並兩個不同的值。例如,與其讓子組件既接收一個“committed”屬性又要維護一個“draft”的狀態,不如讓父級組件同時管理兩個狀態——state.committedValuestate.draftValue——直接控制子組件的值。這使得數據流更加清晰和可預測。

對於非受控的組件,如果您試圖在某特定的屬性(通常是ID)更改時重置狀態,那么您有幾個選項:

  • 推薦:如果要重置全部內部狀態,使用key特性
  • 備選 1:只重置某些特定的狀態字段,關注特定屬性的更改(例如props.userID)。
  • 備選 2:您還可以考慮使用refs調用一個命令式實例方法。

什么是memoization?

派生狀態可用於確保執行render時使用的值僅在輸入發生變化時才會重新計算。這種技術被稱為memoization

使用派生狀態進行記憶並不一定是不好的,但它通常不是最好的解決方案。管理派生狀態存在一定的復雜性,並且這種復雜性會隨着附加屬性而增加。例如,如果我們向組件狀態添加第二個派生字段,那么我們的實現將需要分別跟蹤兩者的更改。

我們來看一個例子,這個組件帶有一個prop(一個項目列表),並呈現與用戶輸入的搜索查詢匹配的項目。 我們可以使用派生狀態來存儲過濾后的列表:

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

  // *******************************************************
  // NOTE: this example is NOT the recommended approach.
  // See the examples below for our recommendations instead.
  // *******************************************************

  static getDerivedStateFromProps(props, state) {
    // Re-run the filter whenever the list array or filter text change.
    // Note we need to store prevPropsList and prevFilterText to detect changes.
    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。但我們卻做很多啰嗦的工作,因為它必須分別跟蹤和檢測道具和狀態的變化,以便正確更新過濾列表。在這個例子中,我們可以通過使用PureComponent並將過濾器操作移動到渲染方法來簡化:

// PureComponents only rerender if at least one state or prop value changes.
// Change is determined by doing a shallow comparison of state and prop keys.
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() {
    // The render method on this PureComponent is called only if
    // props.list or state.filterText has changed.
    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”不會阻止重新渲染。 為了解決這兩個問題,我們可以添加一個記憶幫助器,以避免不必要地重新過濾我們的列表:

import memoize from "memoize-one";

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

  // Re-run the filter whenever the list array or filter text changes:
  filter = memoize(
    (list, filterText) => list.filter(item => item.text.includes(filterText))
  );

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

  render() {
    // Calculate the latest filtered list. If these arguments haven't changed
    // since the last render, `memoize-one` will reuse the last return value.
    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>
    );
  }
}

 

這非常簡單,並且與派生狀態版本一樣好!

在使用memoization時,需要注意一些約束:

  1. 在大多數情況下,您需要將memoized函數附加到組件實例。這可以防止組件的多個實例重置彼此的memoized key。
  2. 通常情況下,您需要使用具有可控緩存大小的記憶輔助程序,以防止隨着時間的推移內存泄漏。 (在上面的例子中,我們使用了memoize-one,因為它只緩存最近的參數和結果。)
  3. 如果父組件每次渲染時都重新創建了“props.list”,本節中顯示的任何實現都不起作用。但在大多數情況下,這種設置是合適的。

最后

在現實世界的應用程序中,組件通常包含受控和非受控行為的混合。這沒關系!如果每個值都有明確的真相來源,則可以避免上述的反模式。

值得重新思考的是getDerivedStateFromProps(和通常的派生狀態)是一個高級特性,也因為這種復雜性,使用時務必謹慎。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM