[譯] 你可能不需要 Derived State
來自專欄前端之路
React 16.4 包含了一個 getDerivedStateFromProps 的 bugfix,這個 bug 導致一些 React 組件潛在的 bug 穩定復現。這個版本暴露了個案例,當你的應用正在使用反模式構建,則將會在此次修復後可能無法工作,我們對這個改動感到抱歉。在本文中,我們將闡述一些通常的使用 Derived State 的反模式以及相應的解決替代方案。
在很長一段時間,在 props 改變時響應 state 的更新,無需額外的渲染,唯一的途徑就是 componentWillReceiveProps
這個生命周期方法。在 16.3 版本下,我們介紹了一個替代的生命周期 getDerivedStateFromProps 更加安全的方式來解決相同的用例。同時,我們意識到人們有很多關於使用這兩個方法的錯誤解讀,我們發現了這些反模式導致一些微妙且令人困惑的 bug。 getDerivedStateFromProps
在 16.4 中做了修復,使得 derived state 更加可預測,因此濫用的後果更加容易被留意到。
Note
所有關於舊的componentWillReceiveProps
和新getDerivedStateFromProps
的反模式都會在本文中闡述。
這篇文章涵蓋以下話題:
- 什麼時候使用 derived state
- 使用 derived state 時通常的 bug
- 反模式:無條件地將 prop 複製給 state
- 反模式:當 props 改變時清除 state
- 推薦的方案
- 什麼是 memoization ?
什麼時候使用 Derived State
getDerivedStateFromProps
存在只為了一個目的。它讓組件在 props 發生改變時更新它自身的內部 state。我們之前的文章提供一些例子,例如:基於 offset prop 的改變記錄當前滾動位置 或者 通過源 prop 載入外部數據。
我們沒有提供更多的例子,因為這有一個常規的準則,應該保守地使用 derived state。所有我們看到關於 derived state 的問題從根本上可以歸結成兩類:(1) 無條件的以 props 更新 state 或者 (2) 每當 props 和 state 不同時就更新 state。(我們將在下面談到更多細節。)
- 當你使用 derived state 來暫存一些僅基於當前 props 的計算結果時,你不需要 derived state。查看 什麼是 memoization ?
- 當你無條件更新 derived state 抑或是每當 props 與 state 不同時更新 state,你的組件可能會頻繁重置它的 state。
使用 Derived State 時的常見 bug
「受控的」 和 「不受控的」 這兩個術語經常涉及到 form 的 input,然而他們也能描述組件數據存在的位置。當數據作為 props 傳遞時,則數據可以被認為是受控的(因為父組件控制了這些數據)。僅存在於內部 state 的數據可以被認為是不受控的(因為父組件不能直接改變它)。
derived state 的最常見錯誤就是混合了「受控」和「不受控」兩種情況;當一個 derived state 值也使用 setState
來更新時,那它數據來源就不是唯一的。上文提到的「外部數據載入的例子」看上去好像就是這樣,但其實有本質上差別。在數據載入例子中,source prop 和 loading state 都有明確的數據來源。當 source prop 改變時, loading state 總會被覆蓋。相反地,state 當且僅當 prop 改變時才會被覆蓋,否則只能被 state 所在的組件所管理。
當這些約束被改變時問題就浮現了。這會產生兩個經典形式。讓我們看一看他們。
反模式:無條件地將 prop 複製給 state
一個常見關於 getDerivedStateFromProps
和 componentWillReceiveProps
的錯誤理解就是他們只會在 props 「變化」時調用。無論是組件重新渲染還是 props 和之前「不同」,這些生命周期方法都會被調用。基於此,這兩個生命周期方法總是被用於不安全地無條件地覆蓋 state。這樣做將導致 state 的更新發生丟失。
讓我們思考一個例子來說明這個問題。這裡有一個 EmailInput
組件「映射」了一個 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 will erase any local state updates! // Do not do this. this.setState({ email: nextProps.email }); }}
首先,這個組件看上去沒什麼問題。State 被 props 傳遞進來的值所初始化,並在我們鍵入 <input>
的時候被更新。但是如果我們的父組件重新渲染的時候,我們輸入到 input
的內容就會丟失(看這個例子)!即使我們在重置前進行比較 nextProps.email !== this.state.email
也會這樣。
在這個簡單的例子中,只有當 email 屬性被改變時加入 shouldComponentUpdate
來解決重渲染。然而在實踐中,組件總是接受多個 props;另一個 prop 改變時依然會導致重渲染和不當重置。在函數和對象屬性在內部被創建,在一個實質性的變化發生時,實現 shouldComponentUpdate
可靠地只返回 true 值變得困難。這裡有個 demo 展示發生的情況。因此, shouldComponentUpdate
作為性能優化的最好方式被使用,而不用在 derived state 中保證正確性。
至此,為何無條件地將 props 複製給 state 是一個壞想法顯而易見。在 review 可能的解決方案,讓我們來看看一個有關的問題模式:在email 屬性改變時,如果我們只更新 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 }); } } // ...}
Note
不僅在以上例子中componentWillReceiveProps
,一個的反模式也被用於getDerivedStateFromProps
中。
我們做了很大的改進。現在我們的組件在 props 實質變化時才會清楚我們輸入的內容。
但依舊存在一個微妙的問題。想像一下一個密碼管理應用使用上述輸入組件。當在兩個相同 email 的賬戶下切換時,輸入組件重置會失敗。這是因為兩個賬戶傳遞給組件的 prop 值是相同的!這使得用戶感到詫異,一個賬戶沒有保存的變更會影響另一個共享同一 email 的賬號上。(這裡看 demo)
這種設計是有本質缺陷的,但它是最容易犯的。(我就犯過!)幸運的是,以下有兩個更好的替代方案。而關鍵就是對每一片數據,你需要選一個控制數據並以其作為真實源的簡單組件,並避免副本數據存在於其他組件。讓我們來看一下這些替代方案。
優選方案
推薦:完全受控組件
一個避免上述涉及問題的途徑就是完全地移除我們組件中的 state。如果 email 地址只存在於 prop,那我們沒必要擔心 state 的衝突。我們甚至可以把 EmailInput
緩存一個更加輕量的函數式的組件:
function EmailInput(props) { return <input onChange={props.onChange} value={props.email} />;}
這個途徑簡化了我們組件的實現,但是我們如果想存儲草稿的時候,父組件還是需要手工完成這件事。(點這看這種模式的例子)
推薦:帶有 key 的完全不受控組件
另一個替代方案就是我們的組件完全的控制自己的 email state 「草稿」。在此例子中,我們的組件依然可以接收一個來自於初始值,但它將會忽略後面 prop 的改動:
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 將創建一個新的組件實例而不是更新現有的這個。Keys 經常被用於動態 list,但在這裡依然管用。在我們的案例中,我們能根據 user ID 在新用戶被選中時重新創建 email 輸入組件:
<EmailInput defaultEmail={this.props.user.email} key={this.props.user.id}/>
每當 ID 改變時, EmailInput
將會被重新創建,它的 state 將會用最新的 defaultEmail
值重置。(點這裡看這種模式的例子)使用這種途徑,你不需要為每一個輸入組件加 key
。也許在 from 中加一個 key
會來得更好。每當 key 改變時,所有在 from 里的組件都會用一個新的 initialized state 來重新創建。
更多的案例中,這是一個處理需要被重置的 state 的最佳方式。
Note
雖然這貌似會很慢,在性能差異無關緊要的時候。當組件有很重的更新邏輯時候,使用一個 key ,忽略子樹 diffing 甚至會更快。
替代方案1:使用 ID prop 重置不受控組件
如果 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, thats just the email. if (props.userID !== state.prevPropsUserID) { return { prevPropsUserID: props.userID, email: props.defaultEmail }; } return null; } // ...}
這也提供了靈活性——重置部分被我們選中的組件內部 state。(點這裡看此模式的 demo)
Note
及時以上例子展示了getDerivedStateFromProps
,同樣的技術手段也可以被用在componentWillReceiveProps
。
替代方式2:在一個實例方法中重置不受控組件
更罕見地,你可能需要重置 state 即使沒有適當的 ID 可用為 key
。一個解決方案就是每次你想重置時用一個隨機數或者自增數字重置 key。另一個可行的方案是暴露一個實例方法命令式的重置內部 state:
class EmailInput extends Component { state = { email: this.props.defaultEmail }; resetEmailForNewUser(newEmail) { this.setState({ email: newEmail }); } // ...}
父組件能用 ref 來調用這個方法。(點擊這看這個模式例子)
Refs 在這個確定的例子中是有用的,但通常上我們建議你保守使用。甚至在這個 demo 中,這個必要的方法是不理想的,因為本來一次的渲染會變成兩次。
扼要重述
重述一下,當設計一個組件的時候,決定數據是否受控或不受控是至關重要的。
讓組件變得受控,而不是試圖在 state 中複製一個 prop ,在一些父組件的 state 中聯合兩個分散的值。舉個例子,與其子組件接收一個「已提交的」 props.value
並跟蹤一個「草稿」 state.value
,不如在父組件中管理 state.draftValue
和 state.committedValue
,並控制直接控制子組件的值。這讓數據流更加明確和可預測。
對不受控組件,如果你在一個特殊的 prop (通常是 ID)改變時試圖重置 state,你有一些選擇:
- 推薦:重置所有內部 state,使用 key 屬性
- 替代方案1:僅重置確定的 state 欄位,監聽特定屬性的變化(例如:
props.userID
)。 - 替代方案2:你也可以考慮使用 refs 調用一個命令式實例方法。
什麼是 memoization ?
我們也看到,僅當輸入變化的時候,derived state 被用於確保關鍵值被用於 render
中會重新計算。這個技巧被稱之為 memoization。
使用 derived state 來完成 memoization 並不一定是壞事,但這經常不是最佳方案。管理 derived state 具有內在複雜度,這個複雜度隨著屬性的增加而提升。例如,如果我們想要加入第二個 derived feild 到我們的組件 state,那麼我們的實現將需要分別跟蹤兩者的變化。
讓我們來看一個例子——組件攜帶一個屬性(一個 item list),並渲染匹配用戶輸入的搜索查詢的 item。我們使用 derived state 存儲過濾的 list:
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
的重新計算。但這比原來的更複雜,因為他必須分開跟蹤和檢測 props 和 state 的變化,才能正確更新過濾後的列表。在這個例子中,我們能使用 PureComponent
簡化工作,移動更新操作到 render 方法中:
// 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> ); }}
這種途徑比用 derived state 更加清晰且簡單。偶爾地,這不夠好——在大列表中過濾會變得慢,如果其他 prop 變化時 PureComponent
不會阻止重渲染。為了解決這些問題,我們可以加入一個 memoization helper 來避免對 list 的不必要過濾:
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 havent 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> ); }}
這更加簡單,而且性能和 derived state 版本的一樣好!
當使用 memoization 時,記住一些約束條件:
- 在大多數案例中,你會想把 memoized 函數附加到組件實例上。這防止一個組件的多個實例重置彼此的 memoized key。
- 通常地,你會想使用一個具有緩存大小限制的 memoization helper 來避免內存泄露問題。(在以上的例子中,我們用了
memoize-one
因為它僅緩存最近的參數和結果。) - 如果
props.list
在每次父組件渲染時被重新創建,本節中展示的實現手段是無法工作的。但在多數案例中,這種設置是適當的。
結語
在實際的應用中,組件經常包含受控和不受控行為的混合。這是沒問題的!如果每一個值都有清晰的真實源,你可以避免上面提及的反模式。
同樣值得重申的是, getDerivedStateFromProps
(一般的 derived state) 是一個高級特性,由於其複雜度,應該保守的使用它。如果你使用的案例超出這些模式,請在 Github 或 Twitter 上與我們分享!
原文鏈接:You Probably Dont Need Derived State - React Blog
推薦閱讀:
※React源碼分析 - 組件初次渲染
※Android動態日誌系統Holmes
※狼叔:Node.js 源碼是如何執行的?| Live 預告
※事件捕獲和事件冒泡
※CSS 實用 Tips