原文連結: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
一個常見的誤解就是以為getDerivedStateFromProps
和componentWillReceivedProps
會只在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
的時候,需要滿足一些條件:
- 在大多數情況下,你會把記憶體化函式新增到一個元件例項上。這會防止該元件的多個例項重置每一個記憶體化屬性。
- 通常你使用一個帶有有限快取大小的記憶體化工具,為的是防止時間累計下來的記憶體洩露。(在上述例子中,我們使用
memoize-one
因為它僅僅會快取最近的引數和結果)。 - 這一節裡,如果每次父元件渲染的時候
props.list
重新生成的話,上述實現會失效。但是在多數情況下,上述實現是合適的。
結束語
在實際應用中,元件經常混合著受控和不受控的行為。理所應當。如果每個值都有明確源,你就可以避免上買呢反模式。
重申一下,由於比較複雜,getDerivedStateFromProps
(還有 derived state)是一項高階特性,而且應該用少點。如果你使用的時候遇到麻煩,請在 GitHub 或者 Twitter 上聯絡我們。