升級異步rendering(Update on Async Rendering)

原文地址 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;
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容