原文: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“改變”時,getDerivedStateFromProps
和componentWillReceiveProps
才會被調用。事實上,只要父組件重新渲染,這些生命周期函數就會被調用,不管這些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.committedValue
和state.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時,需要注意一些約束:
- 在大多數情況下,您需要將memoized函數附加到組件實例。這可以防止組件的多個實例重置彼此的memoized key。
- 通常情況下,您需要使用具有可控緩存大小的記憶輔助程序,以防止隨着時間的推移內存泄漏。 (在上面的例子中,我們使用了
memoize-one
,因為它只緩存最近的參數和結果。) - 如果父組件每次渲染時都重新創建了“props.list”,本節中顯示的任何實現都不起作用。但在大多數情況下,這種設置是合適的。
最后
在現實世界的應用程序中,組件通常包含受控和非受控行為的混合。這沒關系!如果每個值都有明確的真相來源,則可以避免上述的反模式。
值得重新思考的是getDerivedStateFromProps(和通常的派生狀態)是一個高級特性,也因為這種復雜性,使用時務必謹慎。