很長一段時間,componentWillReceiveProps
生命週期是在不進行額外render的前提下,響應props中的改變並更新state的唯一方式。在16.3版本中,我們介紹了一個新的替代生命週期getDerivedStateFromProps去更安全地解決相同的問題。同時,我們意識到人們對這兩個方式都存在很多的誤解,我們發現了其中的一些反例觸發了一些奇怪的bug。在本次版本中我們修復了它,並讓derived state更加可預測,所以我們能更容易地注意到濫用的結果。
本文的反例既包含老的componentWillReceiveProps也包含新的getDerivedStateFromProps方法
什麼時候去使用派生狀態(Derived State)
getDerivedStateFromProps
只為了一個目的存在。它使得一個元件能夠響應props的變化來更新自己內部的state。比如我們之前提到的根據變化的offset屬性記錄目前的滾動方向或者根據source屬性載入額外的資料。
我們提供了許多了例項,因為一般來說,派生狀態應該被謹慎地使用。我們見過的所有關於派生狀態的問題最後都可以被歸為兩種:(1)從props那裡無條件地更新state(2)當props和state不匹配的時候更新state(我們在下面會深入探討)
- 如果你使用派生狀態來記憶基於當前props的運算結果,你並不需要它。參見下面的
關於快取記憶(memoization)
。 - 如果你是第二種情況,那麼你的元件可能重置得太頻繁了。繼續讀下去獲得更多內容。
使用派生狀態的常見Bug
"受控"和"非受控"通常指代表單的輸入控制元件,但是它還可以用於描述元件的資料所處位置。通過props傳入的資料可被稱為受控的(因為父元件控制這資料)。只存在內部state的資料被稱作非受控的(因為父元件不能直接改變它)。
最常見的錯誤是將兩者搞混了。當一個派生狀態同時被setState
更新的時候,資料就失去了單一的事實來源。上面提到的載入資料的例子看上去是類似的,但在一些關鍵的地方是有區別的。在例子中,每當source屬性變化,loading狀態必定會被覆蓋。反過來,狀態要麼在props變化的時候被覆蓋,要麼由元件自己管理。(譯註:可理解為同時只有單一的真實來源)
當任何一個限制被改變的時候就會發生問題,下面舉了兩個典型的例子。
反例:無條件地複製props到state
一個常見的誤解是getDerivedStateFromProps
和componentWillReceiveProps
只會在props“改變”的時候呼叫。這些生命週期會在任何父元件發生render的時候呼叫,不管props是否真的改變。因此,使用這些週期去無條件地覆蓋state是不安全的。這樣做會使得state丟失更新。
我們來演示一下這個問題。這是一個郵件輸入
元件,它“對映”了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.setState({ email: nextProps.email });
}
}
複製程式碼
看上去這個元件好像沒問題。state被props中的值初始化,然後隨著<input>
的輸入而更新。但是如果父元件發生render,我們在<input>
中輸入的東西都會消失!(參見這裡的demo)即使我們在重置前比較nextProps.email !== this.state.email
也會如此。
在這個簡單的例子中,新增shouldComponentUpdate
去限制只在props中的email發生改變時才去重新render能解決這個問題。但在實際中,元件通常會接受很多的props。你無法避免其他的屬性發生改變。函式和物件屬性通常是內聯建立的,這讓我們很難去實現判斷是否發生了實質性的變化。這裡有一個demo來說明。因此,shouldComponentUpdate
最好只是用來優化效能,而不是去確保派生狀態的正確性。
希望現在我們能弄清楚為什麼無條件地複製props到state是壞主意。在檢視可能的解決方案前,我們先來看一個相關的例子:如果我們只在email屬性發生改變的時候更新state呢?
反例:props改變時覆蓋state
繼續上面的例子,我們可以通過只在props.email
改變時更新state來避免意外地覆蓋已有的state。
class EmailInput extends Component {
state = {
email: this.props.email
};
componentWillReceiveProps(nextProps) {
// 只要props.email改變, 更新state.
if (nextProps.email !== this.props.email) {
this.setState({
email: nextProps.email
});
}
}
// ...
}
複製程式碼
儘管上面的例子中使用的是
componentWillReceiveProps
,但這個反例同樣的也對getDerivedStateFromProps
適用
我們剛邁進了一大步。現在我們的元件只會在props真正改變的時候覆蓋掉我們輸入的東西了。
當然這裡還是有一個微妙的問題。想象一個密碼管理app使用瞭如上的輸入元件。當切換兩個不同的賬號的時候,如果這兩個賬號的郵箱相同,那麼我們的重置就會失效。因為對於這兩個賬戶傳入的email屬性是一樣的。(譯註:好比你切換了一份資料來源,但是這兩份資料中的email是相等的,於是預期應該被重置的輸入框沒有被重置)檢視demo
這個設計從根本上是有缺陷的,但卻是很容易犯的錯誤。(我自己也曾犯過)幸運的是有兩個更好的可選方案。關鍵在於對於任何資料,你需要選擇一個作為其真實來源的元件,並且避免在其他元件中複製它。我們來看看下面的解決方案。
更好的方案
推薦:完全受控元件
一個解決上述問題的方案是完全移除我們元件中的state。如果email僅作為屬性存在,我們就不需要擔心和state的衝突。我們甚至可以把EmailInput
作為一個更輕量級的函式元件:
function EmailInput(props) {
return <input onChange={props.onChange} value={props.email} />;
}
複製程式碼
此舉簡化了我們元件的實現,但是如果我們仍然想儲存一份輸入的草稿值呢,這時候需要父元件手動來實現了,請看demo
推薦:用key標識的完全不受控元件
另一個可選方案是我們的元件完全控制eamil的“草稿”狀態。在這裡,我們的元件仍然接受props中的屬性用來初始值,但是它會忽略後續屬性的變化。
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會重新建立一個新的元件而不是更新它。Key一般用在動態列表,但是在這也是很有用的。這裡當選擇一個新使用者的時候,我們用使用者ID去重新建立這個email輸入元件:
<EmailInput
defaultEmail={this.props.user.email}
key={this.props.user.id}
/>
複製程式碼
每當ID改變,EmailInput
會被重新建立然後state會被重置為最新的defaultEmail
值。(點這檢視demo)其實你無需為每一個輸入框新增一個key。對整個表單新增一個key
會顯得更有用。每當key改變,表單的所有元件都會被重建且被賦上乾淨的初始值。
大多數情況,這是重置state最好的辦法。
重新建立元件聽上去會很慢,但其實對效能的影響微乎其微。如果元件具有很多更新上的邏輯,則使用key甚至可以更快,因為該子樹的差異得以被繞過。
替代方案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;
}
// ...
}
複製程式碼
如果我們這樣選擇,這也提供了僅重置部件的內部狀態的靈活性(這裡檢視demo)
上面的例子對於componentWillReceiveProps也是一樣的
替代方案2:使用例項方法重置非受控元件
在極少情況,你可能需要在沒有合適的ID作為key的情況下重置state。一個辦法是把key設為隨機值或者遞增的值,在你想要重置的時候改變它。另一個可行方案是暴露一個例項方法命令式地去改變內部的state:
class EmailInput extends Component {
state = {
email: this.props.defaultEmail
};
resetEmailForNewUser(newEmail) {
this.setState({ email: newEmail });
}
// ...
}
複製程式碼
父表單元件就可以通過ref去呼叫這個方法,(點選這裡檢視demo)
Ref這這類場景中挺有用,但我們推薦你儘量謹慎地去使用。即使在這個例子中這種方法也是不理想的,因為會造觸發兩次render。
關於快取記憶(memoization)
我們也見到過用派生狀態來確保render
中計算量較大的值僅在輸入改變的時候重新計算。這個技術叫做memoization。
使用它作快取記憶不一定是不好的,但通常不是最佳解決方案。管理派生狀態有一定的複雜度,這個複雜度還會隨著額外的屬性而增加。比如,如果我們給元件新增了第二個派生欄位,那麼我們需要分別跟蹤這兩個欄位的變化。
我們來看一個例子,我們把一個列表傳入這個元件,然後它需要按使用者的輸入篩選顯示出匹配的項。我們可以使用派生狀態去儲存篩選後的列表。
class Example extends Component {
state = {
filterText: "",
};
// *******************************************************
// NOTE: 這個例子不是我們推薦的做法
// 推薦的方法參見下面的例子.
// *******************************************************
static getDerivedStateFromProps(props, state) {
// 每當列表陣列或關鍵字變化時篩選列表.
// 注意到我們需要儲存prevPropsList和prevFilterText來監聽變化.
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只會在至少state和props中有一個屬性發生變化時渲染.
// 變化是通過引用比較來判斷的.
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() {
// 只有props.list 或 state.filterText 改變時才會呼叫.
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
無法阻止其他屬性改變造成的render。為了應對這種情況我們引入了一個memoization輔助器來避免多餘的篩選。
import memoize from "memoize-one";
class Example extends Component {
// State只需要去維護目前的篩選關鍵字:
state = { filterText: "" };
// 當列表陣列或關鍵字變化時重新篩選
filter = memoize(
(list, filterText) => list.filter(item => item.text.includes(filterText))
);
handleChange = event => {
this.setState({ filterText: event.target.value });
};
render() {
// 計算渲染列表時,如果引數同上次計算沒有改變,`memoize-one`會複用上次返回的結果
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>
);
}
}
複製程式碼
這樣更簡單地實現了和派生狀態一樣的功能!
總結
在實際應用中,元件通常既包含受控與非受控的元素。這沒問題,如果每個資料都有清晰的真實來源,你就可以避開上面提到的反例。
getDerivedStateFromProps
的用法值得被重新思考,因為他是一個擁有一定複雜度的高階特性,我們應該謹慎地使用。