翻譯這篇文章的起因是因為在一次需求迭代中錯誤的使用了getDerivedStateFromProps這個生命週期導致子元件的state被迴圈重置,於是翻到了這篇文章,然後就開啟的翻譯之旅。
在很長一段時間,生命週期componentWillReceiveProps
是用來響應props更新來改變state並不需要額外渲染的唯一方法。在16.3版本中,我們提供了getDerivedStateFromProps
這個更安全生命週期來解決相同的用例。同時,我們發現人們對於如何使用這兩種方式有很多誤解,並且我們發現了一些造成微妙和令人混淆的反模式。在16.4中的getDerivedStateFromProps
的bug修復使得派生狀態更加可預測,且更容易讓人注意到錯誤使用它的結果。
什麼時候去使用派生狀態
getDerivedStateFromProps的存在只有一個目的。它可以使元件根據props的改變來更新內部的state。我們之間的部落格提供了一些例子:通過改變offset的prop來改變當前的滾動方向和載入通過source props所指定的外部資料。
我們沒有提供更多的例子,因為作為一個基本的規則,派生狀態應該被謹慎的使用。所有派生狀態導致的問題無異於兩種:(1)無條件的根據props來更新state(2)無論props和state是否匹配來更新state。
- 如果僅用派生狀態來記錄一些基於當前props的計算,則不需要派生狀態;
- 如果你無條件的更新派生狀態,或者無論props和state是否匹配來更新state,你的元件將會過於頻繁的去重置狀態;
使用派生狀態的常見問題
“受控的”和“不受控的”通常用來指表單的輸入,但它也同樣可以表示任何元件資料所在的位置。資料通過props傳來被認為是“受控的”(因為父元件在控制著這個資料)。資料僅存在其內部的state中被認為是“不受控的”(因為其父元件不能直接的改變這它)。
派生狀態最常見的錯誤就是將這兩者混和在一起。當一個派生狀態的值同樣通過setState的呼叫來更新時,這就無法保證資料有單一的真實來源。這也許和上面提到的外部資料載入的例子很相似,但他們在一些重要的方面上是不同的。在載入的例子中,”source“的props和”loading“的state都有一個明確的真實來源。當source props改變的時候,應該總是覆蓋loading state。相反,只有props改變且由元件管理的時候,才去重寫state。
當這些約束中的任何一個被改變時將會出現問題。通常有兩種形式,讓我們接下來看一下這兩種形式。
反模式:無條件的從prop複製狀態到state
一個常見的誤解是getDerivedStateFromProps
和componentWillReceiveProps
只有在props改變的時候會被呼叫。這兩個生命週期將會在父元件重新渲染的任何時間被呼叫,而不管props是否與之前不同。因此,在使用這兩個生命週期時,無條件的覆蓋state總是不安全的,將會導致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) {
// 這裡將會覆蓋任何本地state的更新。
this.setState({ email: nextProps.email });
}
}
複製程式碼
這個元件看起來可能沒有問題,state由prop傳來的資料初始化,且當我們改變input的值時state被更新。但是當我們的父元件重新渲染的時候,任何我們在input中輸入的狀態都將丟失,即使我們去比較nextProps.email !== this.state.email
也是如此。
在這個例子中,只有當email的prop的改變的時候新增shouldComponentUpdate
來重新渲染可以解決這個問題,但是實際上,元件通常會接收多個prop,另一個prop的改變任然會造成元件的重新渲染和不正確的重置。此外函式和物件的prop通常也會是內聯建立的,這也會使shouldComponentUpdate
正確的返回true變得困難。這裡有一個例子。因此shouldComponentUpdate
通常被用於效能優化而不是來判斷派生狀態的正確性。
希望到現在大家清楚為什麼不要無條件的複製props到state。在我們找到可能的解決方案之前,讓我們去看一個與之相關的問題:如果只在props.email改變的時候去更新state會怎樣?
反模式:props改變的時候清除state
繼續上面的例子,我們可以避免在props.email更改時意外的清除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
});
}
}
// ...
}
複製程式碼
我們取得了很大的進步,現在我們的元件只有在props真正改變的時候才會清除state。
還有一個微妙的問題,想象一下使用以上的元件來構建密碼管理應用。當使用同一個email在兩個賬戶的詳情頁導航時,input將會無法重置,這是因為傳遞給元件的props相對於兩個賬號來說時相同的。這對使用者來說將會是一個驚喜,因為對一個賬戶的未儲存更改會錯誤的影響到另一個賬戶。檢視演示。
這種設計從本質上來說是錯誤的,但卻是一個很容易犯的錯誤,幸運的是,有兩種更好的選擇,這兩者的關鍵在於,對於任何資料片段,你都需要選擇一個將它作為資料來源的元件,而避免在其它元件重複使用。
首選方案
推薦:完全受控元件
避免上述問題的一個方案是完全移除組建中的state,如果email僅作為props存在,那我們將不必擔心它和state衝突,我們甚至可以講EmailInput元件變為更輕量級的function元件:
function EmailInput(props) {
return <input onChange={props.onChange} value={props.email} />;
}
複製程式碼
這種方法簡化了元件的實現,但如果你仍需要儲存一個草稿值,那麼父表單元件現在需要手動執行該操作。檢視演示。
推薦:帶有key的完全不受控元件
另一個方法是讓我們的元件完全擁有“草稿”email的state,這時我們的元件仍然可以接收props來作為初始值,但是它會忽略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
通常被用在動態的list但是同樣可以在這裡使用。
<EmailInput
defaultEmail={this.props.user.email}
key={this.props.user.id}
/>
複製程式碼
每當id改變的時候,EmailInput元件將會被重新建立,它的state將會被重置為最後一次的defaultEmail的值。檢視演示。使用此方法,你講不用在每一個input上新增key
,把一個key
放在整個form上更有意義,每當key
改變的時候,表單中的input都會重置到其初始狀態。
替代方案1:通過ID prop來重置不受控元件
如果key
在某些場合不適用(也許初始化對於元件來說是昂貴的),一個可行但繁瑣的方式是在getDerivedStateFromProps
中去監測userID:
class EmailInput extends Component {
state = {
email: this.props.defaultEmail,
prevPropsUserID: this.props.userID
};
static getDerivedStateFromProps(props, state) {
if (props.userID !== state.prevPropsUserID) {
return {
prevPropsUserID: props.userID,
email: props.defaultEmail
};
}
return null;
}
// ...
}
複製程式碼
這也提供了靈活性,如果我們選擇,只重置元件內的部分state。檢視演示。
替代方案2:通過例項方法來重置不受控元件
如果沒有合適的id來作為key
但是又要重置狀態,一種解決方案是為元件生成一個隨機數或者自動遞增值來作為key
,另一種方案是通過例項的方法來強制重置元件的state。
class EmailInput extends Component {
state = {
email: this.props.defaultEmail
};
resetEmailForNewUser(newEmail) {
this.setState({ email: newEmail });
}
// ...
}
複製程式碼
父元件將通過ref
拿到元件的例項從而呼叫該方法。檢視演示。
在某些場景下ref
會很有用,但是我們建議你謹慎的使用它,即使在demo中,這個方法也是最不理想的,因為將會造成兩次渲染而不是一個。
總結
總而言之,當設計一個元件時,一個重要的方面是它的資料是可控的還是不可控的。
儘量避免在state中去“映象”一個props值,使這個元件成為受控元件,在父元件的state中去合併這兩個state。例如,與其在元件中去接受一個committed的props並且跟蹤一個draft的state,不如讓父元件去同時管理這個state.draftValue和state.committedValue並直接控制子元件,這將使元件更加的明確和可預測。
對於一個不受控元件,如果你想根據一個props的改變來重置state,你需要遵循以下幾點:
- 首選:要重置全部內部state,使用
key
屬性; - 備選1:如果只重置部分state,監測props中屬性的變化;
- 備選2:還可以考慮通過
ref
呼叫實力的方法;
memoization怎樣?
我們還看到了派生狀態用於確保渲染中使用的昂貴值僅在輸入發生變化時才會重新計算,這種技術叫做memoization
使用派生狀態來做memoization不一定是壞事,但通常不是最好的解決辦法。派生狀態的管理存在一定的複雜性,並且這種複雜性隨著屬性的增加而增加。例如,如果我們向元件的state新增第二個派生欄位,那麼我們的實現將需要分別跟蹤對兩個欄位的更改。
讓我們看一個元件的示例,該元件使用一個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。但是他比我們需要的更加的複雜,因為它需要分別的跟蹤和檢查我們的props和state以便能夠正確的更新列表。在下面這個例子中,我們通過PureComponent
並將filter操作放到render中來簡化操作:
// PureComponents只有在至少一個state或者prop改變的時候才會重新渲染
// 通過對state和props的keys的淺比較來確認改變。
class Example extends PureComponent {
state = {
filterText: ""
};
handleChange = event => {
this.setState({ filterText: event.target.value });
};
render() {
// 只有props.list 或 state.filterText 改變的時候PureComponent的render才會呼叫
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>
);
}
}
複製程式碼
上述例子比派生狀態的版本更加的乾淨和簡潔,但是有些時候這可能還不夠好,例如對於大型列表來說,過濾可能很慢,且如果有其他的props改變PureComponent
也不會阻止其重新渲染。為了解決這兩個問題,我們可以新增一個memoization,以避免不必要地重新過濾我們的列表:
import memoize from "memoize-one";
class Example extends Component {
state = { filterText: "" };
filter = memoize(
(list, filterText) => list.filter(item => item.text.includes(filterText))
);
handleChange = event => {
this.setState({ filterText: event.target.value });
};
render() {
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。
- 通常情況下,您需要使用具有有限快取大小的memoization,以防止記憶體洩漏。(在上面的例子中,我們使用了memoize-one,因為它只快取最近的引數和結果。)
- 如果每次父元件呈現時重新建立props.list,本節中顯示的實現都不會起作用。但在大多數情況下,這種設定是合適的。
最後
在實際應用中,元件通常包含受控和不受控制行為混合。沒關係,如果每個值都有明確的來源,則可以避免上面提到的反模式。
值得重新思考的是,getDerivedStateFromProps
(以及通常的派生狀態)是一種高階功能,應該謹慎使用。