原文地址 March 27, 2018 by Brian Vaughn
一年多了,React小組致力于實現(xiàn)異步rendering。在上個月的JSConf Iceland上,Dan公布了一些關(guān)于異步rendering的令人興奮的新可能性解鎖了。
現(xiàn)在我們想分享在工作中使用這些特性,已經(jīng)學(xué)到的一些經(jīng)驗和方法,以幫助你的組件為異步rendering發(fā)布做好準(zhǔn)備。
我們學(xué)到的一個最大的經(jīng)驗是,一些舊的組件生命周期會變成不安全的代碼實踐。它們是:
- componentWillMount
- componentWillReceiveProps
- componentWillUpdate
這些生命周期方法經(jīng)常被誤解和巧妙地濫用;而且,這些可能的濫用在異步rendering中會更有問題。因此,我們將在最近的版本中為這些生命周期加上“UNSAFE_”前綴。(“unsafe”不是指安全性,而是說使用這些生命周期在將來的React版本中更容易有bugs,尤其是一旦啟用了異步rendering)
漸進的遷移(升級)路線
React遵循semantic versioning,因此此改變是漸進的。我們目前計劃如下:
- 16.3:介紹不安全生命周期的別名:UNSAFE_componentWillMount, UNSAFE_componentWillReceiveProps 和 UNSAFE_componentWillUpdate。(此版本舊名稱和新別名都可以使用)
- 某個16.x版本:開啟棄用警告:componentWillMount, componentWillReceiveProps 和 componentWillUpdate。(此版本舊名稱和新別名可以同時使用,但是舊名稱會在開發(fā)模式打印警告)
- 17.0:移除componentWillMount,componentWillReceiveProps 和 componentWillUpdate。(這以后,只有新的“UNSAFE_”生命周期名字可以使用)
注意如果你是個React應(yīng)用開發(fā)者,你目前不需要為舊方法做什么。即將到來的16.3版本的主要目的是,允許開源項目維護人員借助棄用警告提前升級它們的庫。這些警告直到將來某個16.x版本才會啟用。
我們在Facebook維護超過50000個React組件,并且我們不打算全部立即重寫。我們知道升級需要時間。我們將與React社區(qū)的成員采用漸進的升級路線。
遷移舊的生命周期
如果你想開始使用React 16.3中新的組件APIs(或者你是個維護人員,想提前升級你的庫),這里有少許例子希望幫到你,開始以不同的方式思考組件。以后,我們會在文檔中繼續(xù)添加其他“食譜”,展示如何避免使用問題生命周期。
在進入正題之前,看下16.3版本的生命周期變動概覽:
- 添加以下生命周期別名:UNSAFE_componentWillMount, UNSAFE_componentWillReceiveProps 和 UNSAFE_componentWillUpdate。(舊生命周期和新別名都可使用)
- 兩個新生命周期,靜態(tài)getDerivedStateFromProps和getSnapshotBeforeUpdate
新生命周期:getDerivedStateFromProps
class Example extends React.Component {
static getDerivedStateFromProps(props, state) {
// ...
}
}
新的靜態(tài)生命周期getDerivedStateFromProps,在組件實例化后和每次re-rendered之前執(zhí)行。它可以返回一個對象來更新state,或者返回null表示不需要更新state。
通過和componentDidUpdate一起使用,這個新生命周期應(yīng)該涵蓋了所有舊的componentWillReceiveProps使用情況。
Note:
舊componentWillReceiveProps和新getDerivedStateFromProps方法都增加組件復(fù)雜性。這經(jīng)常引起bugs。請參考derived state簡單替代方案,讓組件可預(yù)見可維護。
新生命周期:getSnapshotBeforeUpdate
class Example extends React.Component {
getSnapshotBeforeUpdate(prevProps, prevState) {
// ...
}
}
新生命周期getSnapshotBeforeUpdate在制造突變之前被調(diào)用(例如DOM被更新之前)。此生命周期返回值會作為componentDidUpdate的第三個參數(shù)。(這個生命周期不經(jīng)常使用,但是在某些情況很有用,譬如在rerenders時候手動保存滾動位置)
通過和componentDidUpdate一起使用,這個新生命周期應(yīng)該會涵蓋所有舊的componentWillUpdate使用情況。
你可以在這個要點中找到他們的類型簽名。
我們來看看下面的例子中是如何使用這倆生命周期的。
例子
- Initializing state
- Fetching external data
- Adding event listeners (or subscriptions)
- Updating state based on props
- Invoking external callbacks
- Side effects on props change
- Fetching external data when props change
- Reading DOM properties before an update
Note
簡單起見,下面的例子們使用了實驗性質(zhì)的class,但不用也是一樣的遷移策略。
初始化state
這個例子顯示了一個在componentWillMount中調(diào)用setState的組件
// Before
class ExampleComponent extends React.Component {
state = {};
componentWillMount() {
this.setState({
currentColor: this.props.defaultColor,
palette: 'rgb',
});
}
}
對這種組件最簡單的重構(gòu)是將state初始化移動到constructor或?qū)傩猿跏蓟?,例如?/p>
// After
class ExampleComponent extends React.Component {
state = {
currentColor: this.props.defaultColor,
palette: 'rgb',
};
}
獲取外部數(shù)據(jù)
這是一個組件使用componentWillMount來獲取外部數(shù)據(jù)的例子:
// Before
class ExampleComponent extends React.Component {
state = {
externalData: null,
};
componentWillMount() {
this._asyncRequest = loadMyAsyncData().then(
externalData => {
this._asyncRequest = null;
this.setState({externalData});
}
);
}
componentWillUnmount() {
if (this._asyncRequest) {
this._asyncRequest.cancel();
}
}
render() {
if (this.state.externalData === null) {
// Render loading state ...
} else {
// Render real UI ...
}
}
}
上面的例子是有問題的:對服務(wù)器rendering(外部數(shù)據(jù)不會被使用的);對即將到來的異步rendering(請求可能被發(fā)起兩次)。
大多數(shù)情況下,推薦把獲取數(shù)據(jù)移到componentDidMount。
// After
class ExampleComponent extends React.Component {
state = {
externalData: null,
};
componentDidMount() {
this._asyncRequest = loadMyAsyncData().then(
externalData => {
this._asyncRequest = null;
this.setState({externalData});
}
);
}
componentWillUnmount() {
if (this._asyncRequest) {
this._asyncRequest.cancel();
}
}
render() {
if (this.state.externalData === null) {
// Render loading state ...
} else {
// Render real UI ...
}
}
}
一種常見的誤解是在componentWillMount中獲取可以避免第一次rendering空state。實際上這觀點永遠(yuǎn)是錯的,因為React一直都是在componentWillMount后直接執(zhí)行render。如果在componentWillMount執(zhí)行時候沒有數(shù)據(jù),第一次render將仍然顯示loading state,才不會管你在哪發(fā)起的獲取動作。所以大多數(shù)情況,把獲取數(shù)據(jù)動作移到componentDidMount沒啥影響。
Note:
一些高級用法(譬如Realy之類的庫)想更快預(yù)獲取異步數(shù)據(jù)。這里有一個如何實現(xiàn)這一點的示例。
長遠(yuǎn)來看,React組件獲取數(shù)據(jù)的規(guī)范方式是基于JSConf Iceland上介紹的“suspense”API。無論是簡單的數(shù)據(jù)獲取,還是Apollo和Relay這樣的庫,都可以在底層使用它。它比上述任何一種解決方案都要簡單,但是可能不會在16.3版本中實現(xiàn)。
如果是服務(wù)器端rendering,目前來看提供同步數(shù)據(jù)是必要的,componentWillMount經(jīng)常被用于這個目的,但是也可以使用constructor。即將到來的suspense APIs將完全有可能使客戶端和服務(wù)器端redndering都可以異步獲取數(shù)據(jù)。
添加事件監(jiān)聽(或訂閱)
這個組件掛載時候訂閱了一個外部的事件分發(fā)
// Before
class ExampleComponent extends React.Component {
componentWillMount() {
this.setState({
subscribedValue: this.props.dataSource.value,
});
// This is not safe; it can leak!
this.props.dataSource.subscribe(
this.handleSubscriptionChange
);
}
componentWillUnmount() {
this.props.dataSource.unsubscribe(
this.handleSubscriptionChange
);
}
handleSubscriptionChange = dataSource => {
this.setState({
subscribedValue: dataSource.value,
});
};
}
很不幸的是:服務(wù)器端渲染的話,這樣可能引起內(nèi)存泄漏(因為componentWillUnmount永遠(yuǎn)不會被調(diào)用);而且異步rendering也會(因為rendering可能在結(jié)束前被打斷,導(dǎo)致componentWillUnmount不會被調(diào)用)。
大家經(jīng)常認(rèn)為componentWillMount和componentWillUnmount是成對出現(xiàn),但其實不一定。只有componentDidMount已經(jīng)調(diào)用了,React才保證以后會調(diào)用componentWillUnmount,你才可以使用它來清理東西。
因此,建議使用componentDidMount生命周期添加監(jiān)聽/訂閱。
// After
class ExampleComponent extends React.Component {
state = {
subscribedValue: this.props.dataSource.value,
};
componentDidMount() {
// Event listeners are only safe to add after mount,
// So they won't leak if mount is interrupted or errors.
this.props.dataSource.subscribe(
this.handleSubscriptionChange
);
// External values could change between render and mount,
// In some cases it may be important to handle this case.
if (
this.state.subscribedValue !==
this.props.dataSource.value
) {
this.setState({
subscribedValue: this.props.dataSource.value,
});
}
}
componentWillUnmount() {
this.props.dataSource.unsubscribe(
this.handleSubscriptionChange
);
}
handleSubscriptionChange = dataSource => {
this.setState({
subscribedValue: dataSource.value,
});
};
}
有時為了響應(yīng)屬性變化,更新訂閱器很重要。如果你使用了類似Redux或者MobX的庫,其容器組件可能幫你處理了。對于應(yīng)用作者,我們創(chuàng)建了一個小庫create-subscription來幫助你。它將和React 16.3一起發(fā)布。
我們可以通過create-subscription傳遞訂閱值,而不是像上面例子那樣傳遞數(shù)據(jù)源訂閱屬性。
import {createSubscription} from 'create-subscription';
const Subscription = createSubscription({
getCurrentValue(sourceProp) {
// Return the current value of the subscription (sourceProp).
return sourceProp.value;
},
subscribe(sourceProp, callback) {
function handleSubscriptionChange() {
callback(sourceProp.value);
}
// Subscribe (e.g. add an event listener) to the subscription (sourceProp).
// Call callback(newValue) whenever a subscription changes.
sourceProp.subscribe(handleSubscriptionChange);
// Return an unsubscribe method.
return function unsubscribe() {
sourceProp.unsubscribe(handleSubscriptionChange);
};
},
});
// Rather than passing the subscribable source to our ExampleComponent,
// We could just pass the subscribed value directly:
<Subscription source={dataSource}>
{value => <ExampleComponent subscribedValue={value} />}
</Subscription>;
注意
像Realy/Apollo之類的庫,應(yīng)該各自使用了和create-subscription同樣的技巧在底層管理訂閱(參考這里)。
基于props更新state
注意
舊的componentWillReceiveProps和新的getDerivedStateFromProps方法都會給組件帶來復(fù)雜性。經(jīng)常導(dǎo)致bugs。請考慮簡單的替代方案,讓組件可預(yù)見可維護。
這個組件使用了舊的componentWillReceiveProps生命周期,來基于新props值更新state。
// Before
class ExampleComponent extends React.Component {
state = {
isScrollingDown: false,
};
componentWillReceiveProps(nextProps) {
if (this.props.currentRow !== nextProps.currentRow) {
this.setState({
isScrollingDown:
nextProps.currentRow > this.props.currentRow,
});
}
}
}
即使上面的代碼本身沒什么問題,但componentWillReceiveProps生命周期經(jīng)常被錯誤使用,帶來問題。因此,這個方法將被棄用。
基于16.3版本,響應(yīng)props變化更新state的推薦方式是使用:新靜態(tài)getDerivedStateFromProps生命周期。(這個生命周期在組件創(chuàng)建后和接受新props時候被調(diào)用)(譯者:還有更新state,forUpdate等時候)
// After
class ExampleComponent extends React.Component {
// Initialize state in constructor,
// Or with a property initializer.
state = {
isScrollingDown: false,
lastRow: null,
};
static getDerivedStateFromProps(props, state) {
if (props.currentRow !== state.lastRow) {
return {
isScrollingDown: props.currentRow > state.lastRow,
lastRow: props.currentRow,
};
}
// Return null to indicate no change to state.
return null;
}
}
你可能注意到上面例子中props.currentRow被映射到state中(state.lastRow)。這允許getDerivedStateFromProps像componentWillReceiveProps一樣讀取上個props值。
你可能會想為什么我們不直接把上一個props當(dāng)成參數(shù)傳給getDerivedStateFromProps。我們設(shè)計這個API時候考慮到了,但是最終決定反對這樣,因為兩個原因:
- 一個prevProps參數(shù)在第一次getDerivedStateFromProps被調(diào)用時候可能是null(實例化后),需要在prevProps存取時候添加if-not-null檢查。
- 不傳遞上一個props給這個函數(shù)是為了將來:在未來的React版本中釋放內(nèi)存。(如果React不需要傳遞上一個props給生命周期,那么他不需要保存上一個props對象在內(nèi)存中)
注意
如果你在寫一個共享組件,react-lifecycles-compat polyfill 允許在舊版React中使用getDerivedStateFromProps生命周期。后面有如何使用。
執(zhí)行外部回調(diào)
當(dāng)內(nèi)部state變化,此組件調(diào)用了一個外部函數(shù)
// Before
class ExampleComponent extends React.Component {
componentWillUpdate(nextProps, nextState) {
if (
this.state.someStatefulValue !==
nextState.someStatefulValue
) {
nextProps.onChange(nextState.someStatefulValue);
}
}
}
有時候人們把componentWillUpdate用錯地方,是因為怕隨著componentDidUpdate觸發(fā),更新其他組件state“太晚了”。不是這樣的。React保證任何一個 componentDidMount 和 componentDidUpdate 中的setState調(diào)用,在用戶看到更新后的UI前,都會被排放(flushed)。一般來說,避免這樣串聯(lián)升級會更好,但是有些情況是必須的(例如,如果你需要在測量rendered DOM元素后定位一個提示)。
無論哪種方式,在異步模式下用componentWillUpdate都是不安全的,因為一次更新中外部調(diào)用可能會被調(diào)用兩次。相反,應(yīng)該使用componentDidUpdate生命周期,因為可以保證一次更新只被調(diào)用一次。
(我沒看懂這部分,在用戶看到更新后的UI前,都會被排放(flushed)是什么意思?一次更新中外部調(diào)用可能會被調(diào)用兩次怎么復(fù)現(xiàn)?)
/ After
class ExampleComponent extends React.Component {
componentDidUpdate(prevProps, prevState) {
if (
this.state.someStatefulValue !==
prevState.someStatefulValue
) {
this.props.onChange(this.state.someStatefulValue);
}
}
}
有副作用的props改變
類似上面的例子,有時候props改變,會帶來副作用。
// Before
class ExampleComponent extends React.Component {
componentWillReceiveProps(nextProps) {
if (this.props.isVisible !== nextProps.isVisible) {
logVisibleChange(nextProps.isVisible);
}
}
}
像componentWillUpdate,componentWillReceiveProps可能在一次更新中被調(diào)用多次。因此,避免在這兩個方法中有副作用很重要。相反,應(yīng)該使用componentDidUpdate:因為它可以保證一次更新中只被調(diào)用一次。
(同上,上面看不懂,這個肯定就不理解了)
// After
class ExampleComponent extends React.Component {
componentDidUpdate(prevProps, prevState) {
if (this.props.isVisible !== prevProps.isVisible) {
logVisibleChange(this.props.isVisible);
}
}
}
當(dāng)props更新時候獲取外部數(shù)據(jù)
此組件在props改變時候獲取外部數(shù)據(jù)
// Before
class ExampleComponent extends React.Component {
state = {
externalData: null,
};
componentDidMount() {
this._loadAsyncData(this.props.id);
}
componentWillReceiveProps(nextProps) {
if (nextProps.id !== this.props.id) {
this.setState({externalData: null});
this._loadAsyncData(nextProps.id);
}
}
componentWillUnmount() {
if (this._asyncRequest) {
this._asyncRequest.cancel();
}
}
render() {
if (this.state.externalData === null) {
// Render loading state ...
} else {
// Render real UI ...
}
}
_loadAsyncData(id) {
this._asyncRequest = loadMyAsyncData(id).then(
externalData => {
this._asyncRequest = null;
this.setState({externalData});
}
);
}
}
建議將數(shù)據(jù)更新移到componentDidUpdate。你也可以使用新的getDerivedStateFromProps生命周期在rendering新props之前清除舊數(shù)據(jù)。
// After
class ExampleComponent extends React.Component {
state = {
externalData: null,
};
static getDerivedStateFromProps(props, state) {
// Store prevId in state so we can compare when props change.
// Clear out previously-loaded data (so we don't render stale stuff).
if (props.id !== state.prevId) {
return {
externalData: null,
prevId: props.id,
};
}
// No state update necessary
return null;
}
componentDidMount() {
this._loadAsyncData(this.props.id);
}
componentDidUpdate(prevProps, prevState) {
if (this.state.externalData === null) {
this._loadAsyncData(this.props.id);
}
}
componentWillUnmount() {
if (this._asyncRequest) {
this._asyncRequest.cancel();
}
}
render() {
if (this.state.externalData === null) {
// Render loading state ...
} else {
// Render real UI ...
}
}
_loadAsyncData(id) {
this._asyncRequest = loadMyAsyncData(id).then(
externalData => {
this._asyncRequest = null;
this.setState({externalData});
}
);
}
}
注意
如果你使用一個支持取消動作的HTTP庫,例如axios,那么在卸載時候取消一個正在進行的請求很容易。對于原生Promises,你可以使用這個方法
更新前讀取DOM屬性
此組件在更新前讀取DOM屬性,為了在列表中保持滾動位置。
class ScrollingList extends React.Component {
listRef = null;
previousScrollOffset = null;
componentWillUpdate(nextProps, nextState) {
// Are we adding new items to the list?
// Capture the scroll position so we can adjust scroll later.
if (this.props.list.length < nextProps.list.length) {
this.previousScrollOffset =
this.listRef.scrollHeight - this.listRef.scrollTop;
}
}
componentDidUpdate(prevProps, prevState) {
// If previousScrollOffset is set, we've just added new items.
// Adjust scroll so these new items don't push the old ones out of view.
if (this.previousScrollOffset !== null) {
this.listRef.scrollTop =
this.listRef.scrollHeight -
this.previousScrollOffset;
this.previousScrollOffset = null;
}
}
render() {
return (
<div ref={this.setListRef}>
{/* ...contents... */}
</div>
);
}
setListRef = ref => {
this.listRef = ref;
};
}
在上例中,componentWillUpdate用來讀取DOM屬性。然而異步rendering,可能在“render”階段生命周期(例如componentWillUpdate 和 render)和“commit”階段生命周期(例如componentDidUpdate)之間有延遲。如果用戶在這期間做了一些操作例如改變窗口,componentWillUpdate中讀取的scrollHeight值就過時了。
(不是很理解這部分,沒遇到實際場景)
這個問題的解決方案是使用新的“commit”階段生命周期,getSnapshotBeforeUpdate。這個方法在制造突變前(例如DOM更新前)會被直接調(diào)用。它可以返回一個值給React,作為突變后被直接調(diào)用的componentDidUpdate的一個參數(shù)。
這倆生命周期可以這樣一起使用:
class ScrollingList extends React.Component {
listRef = null;
getSnapshotBeforeUpdate(prevProps, prevState) {
// Are we adding new items to the list?
// Capture the scroll position so we can adjust scroll later.
if (prevProps.list.length < this.props.list.length) {
return (
this.listRef.scrollHeight - this.listRef.scrollTop
);
}
return null;
}
componentDidUpdate(prevProps, prevState, snapshot) {
// If we have a snapshot value, we've just added new items.
// Adjust scroll so these new items don't push the old ones out of view.
// (snapshot here is the value returned from getSnapshotBeforeUpdate)
if (snapshot !== null) {
this.listRef.scrollTop =
this.listRef.scrollHeight - snapshot;
}
}
render() {
return (
<div ref={this.setListRef}>
{/* ...contents... */}
</div>
);
}
setListRef = ref => {
this.listRef = ref;
};
}
注意
如果你在寫一個共享組件,react-lifecycles-compat polyfill允許舊版React使用新的getSnapshotBeforeUpdate生命周期。后面有個例子
其他場景
我們在本文努力覆蓋最常見的使用情況,我們認(rèn)識到我們可能會遺漏一些。如果你用到了componentWillMount, componentWillUpdate, or componentWillReceiveProps的其他情況,并且不確定如何遷移這些舊生命周期,請file a new issue against our documentation提供你的代碼和盡可能多的背景信息。當(dāng)有新的替代模式時候,我們會更新這個文檔。
開源項目維護人員
開源項目維護人員可能會想這些改動對共享組件意味什么。如果你實現(xiàn)上面的建議,有新靜態(tài)getDerivedStateFromProps生命周期的組件會發(fā)生什么?你是否必須發(fā)布一個新主版并且放棄對16.2及更老版本的兼容?
幸運的是,不需要。
當(dāng)React 16.3發(fā)布,我們同時發(fā)布一個新npm包,react-lifecycles-compat。這個polyfill可以讓老版本React使用新 getDerivedStateFromProps 和 getSnapshotBeforeUpdate生命周期(0.14.9+)。
為了使用這個polyfill,首先添加一個依賴:
# Yarn
yarn add react-lifecycles-compat
# NPM
npm install react-lifecycles-compat --save
下一步,升級你的組件使用新的生命周期(像上面的描述)。
最后,使用polyfill兼容舊版React。
import React from 'react';
import {polyfill} from 'react-lifecycles-compat';
class ExampleComponent extends React.Component {
static getDerivedStateFromProps(props, state) {
// Your state update logic here ...
}
}
// Polyfill your component to work with older versions of React:
polyfill(ExampleComponent);
export default ExampleComponent;