從 setState promise 化的探討 體會 React 團隊設計思想

從 setState 那個衆所周知的小祕密提及...

在 React 組件中,調用 this.setState() 是最基本的場景。這個方法描述了 state 的變化、觸發了組件 re-rendering。可是,也許看似日常的 this.setState() 裏面卻也許蘊含了不少不爲人知的設計和討論。javascript

相信不少開發者已經意識到,setState 方法「或許」是異步的。也許你以爲,看上去更新 state 是如此垂手可得的操做,這並無什麼可異步處理的。可是要意識到,由於 state 的更新會觸發 re-rendering,而 re-rendering 代價昂貴,短期內反覆進行渲染在性能上確定是不可取的。因此,React 採用 batching 思想,它會 batches 一系列連續的 state 更新,而只觸發一次 re-render。前端

關於這些內容,若是你還不清楚,推薦參考@程墨的系列文章:setState:這個API設計到底怎麼樣;英語好的話,能夠直接關注長髮飄飄的 Eric Elliott 著名的引發系列口水戰的吐槽文:setState() Gatejava

或者,直接看下面的一個小例子。
好比,最簡單的一個場景是:react

function incrementMultiple() {
  this.setState({count: this.state.count + 1});
  this.setState({count: this.state.count + 1});
  this.setState({count: this.state.count + 1});
}複製代碼

直觀上來看,當上面的 incrementMultiple 函數被調用時,組件狀態的
count 值被增長了3次,每次增長1,那最後 count 被增長了3。可是,實際上的結果只給 state 增長了1。不信你本身試試~git

讓 setState 連續更新的幾個 hack

若是想讓 count 一次性加3,應該如何優雅地處理潛在的異步操做,規避上述問題呢?github

如下提供幾種解決方案:redux

  • 方法一:常見的一種作法即是將一個回調函數傳入 setState 方法中。即 setState 著名的函數式用法。這樣能保證即使在更新被 batched 時,也能訪問到預期的 state 或 props。(後面會解釋這麼作的原理)後端

  • 方法二:另一個常見的作法是須要在 setState 更新以後進行的邏輯(好比上述的連續第二次 count + 1),封裝到一個函數中,並做爲第二個參數傳給 setState。這段函數邏輯將會在更新後由 React 代理執行。即:promise

    setState(updater, [callback])bash

  • 方法三:把須要在 setState 更新以後進行的邏輯放在一個合適的生命週期 hook 函數中,好比 componentDidMount 或者 componentDidUpdate 也固然能夠解決問題。也就是說 count 第一次 +1 以後,出發 componentDidUpdate 生命週期 hook,第二次 count +1 操做直接放在 componentDidUpdate 函數裏面就好啦。

一個引發普遍討論的 Issue

這些內容貌似已經再也不新鮮,不少 React 資深開發者其實都是瞭解的,或能很快理解。

但是,你想過這個問題嗎:
現代 javascript 處理異步流程,很流行的一個作法是使用 promises,那麼咱們可否應用這個思路解決呢?

說具體一些,就是調用 setState 方法以後,返回一個 promise,狀態更新完畢後咱們在調用 promise.then 進行下一步處理。

答案是確定的,可是卻被官方否決了。

我是如何得出「答案是確定的,可是是不被官方建議的。」這個結論,喜歡刨根問底的讀者請繼續往下閱讀,相信你必定會有所啓發,也能更充分理解 React 團隊的設計思想。

第 2642 Issue 解讀和深刻分析

我是一步一步在 Facebook 開源 React 的官方 Github倉庫上,找到了線索。

整個過程跟下來,相信在各路大神的 comments 之間,你會對 React 的設計理念以及 javascript 解決問題的思路有一個更清晰的認識。

一切的探究始於 React 第 #2642 號 issue: Make setState return a promise,上面關於 count 連續 +3 你們已經有所瞭解。接下來我舉一個真正在生產開發中的例子,方便你們理解討論。

咱們如今開發一個可編輯的 table,需求是:當用戶敲下「回車」,光標將會進入下一行(調用 setState 進行光標移動);若是用戶當前已經在最後一行,那麼敲下回車時,第一步將先建立一個新行(調用 setState 建立新的最後一行),在新行建立以後,再去新的最後一行進行光標聚焦(調用 setState 進行光標移動)。

常見且錯誤的處理在於:

this.setState({
  selected: input
  // 建立新行
}.bind(this));
this.props.didSelect(this.state.selected);複製代碼

由於第一個 this.setState 是異步進行的話,下一處 didSelect 方法執行 this.setState 時,所處理的參數 this.state.selected 可能還不是預期的下一行。很明顯,這就是 this.setState 的異步性帶來的問題。

爲了解決這個完成這樣的邏輯,想到了 setState 第二個參數解決方案,用代碼簡單表述就是:

this.setState({
  selected: input
  // 建立新行
}, function() {
    this.props.didSelect(this.state.selected);
}).bind(this));複製代碼

這種解決方案是使用嵌套的 setState 方法。但這無疑潛在地會帶來嵌套地獄的問題。

Promise 化方案登場

這一切是否是像極了傳統 Javascript 處理異步老套路?解決回調地獄,你是否是應激性地想到了 promise?

若是 setState 方法返回的是一個 promises,天然會更加優雅:

setState() currently accepts an optional second argument for callback and returns undefined.
This results in a callback hell for a very stateful component. Having it return a promise would make it much more managable.

若是用 promise 風格解決問題的話,無非就是:

this.setState({
  selected: input
}).then(function() {
  this.props.didSelect(this.state.selected);
}.bind(this));複製代碼

看上去沒什麼問題,一個很時髦的設計。可是,咱們進一步想:若是想讓 React 支持這樣的特性,採用提出 pull request 的方式,咱們該如何去改源代碼呢?

探索 React 源碼,完成 setState promise 化的改造

首先找到源碼中關於 setState 定義的地方,它在 react/src/isomorphic/modern/class/ReactBaseClasses.js 這個目錄下:

ReactComponent.prototype.setState = function(partialState, callback) {
  invariant(
    typeof partialState === 'object' ||
      typeof partialState === 'function' ||
      partialState == null,
    'setState(...): takes an object of state variables to update or a ' +
      'function which returns an object of state variables.',
  );
  this.updater.enqueueSetState(this, partialState, callback, 'setState');
};複製代碼

咱們首先看到一句註釋:

You can provide an optional callback that will be executed when the call to setState is actually completed.

這是採用 setState 第二個參數傳入處理回調的基礎。

另外,從註釋中咱們還找到:

When a function is provided to setState, it will be called at some point in the future (not synchronously). It will be called with the up to date component arguments (state, props, context).

這是給 setState 方法直接傳入一個函數的基礎。

言歸正傳,如何改動源碼,使得 setState promise 化呢?
其實很簡單,我直接上代碼:

ReactComponent.prototype.setState = function(partialState, callback) {
   invariant(
     typeof partialState === 'object' ||
       typeof partialState === 'function' ||
       partialState == null,
      'setState(...): takes an object of state variables to update or a ' +
        'function which returns an object of state variables.',
    );
 +  let callbackPromise;
 +  if (!callback) {
 +    class Deferred {
 +      constructor() {
 +        this.promise = new Promise((resolve, reject) => {
 +          this.reject = reject;
 +          this.resolve = resolve;
 +        });
 +      }
 +    }
 +    callbackPromise = new Deferred();
 +    callback = () => {
 +      callbackPromise.resolve();
 +    };
 +  }
    this.updater.enqueueSetState(this, partialState, callback, 'setState');
 +
 +  if (callbackPromise) {
 +    return callbackPromise.promise;
 +  }
  }; 複製代碼

我用 「+」 標註了對源碼所作的更改。若是開發者調用 setState 方法時,傳入的是一個 javascript 對象的話,那麼會返回一個 promise,這個 promise 將會在 state 更新完畢後 resolve。
若是您看不懂的話,建議補充一下相關的基礎知識,或者留言與我討論。

解決方案有了,但是 React 官方會接受這個 PR 嗎?

很遺憾,答案是否認的。咱們來從 React 設計思想上,和 React 官方團隊的迴應上,瞭解一下否決理由。

sebmarkbage(Facebook 工程師,React 核心開發者)認爲:解決異步帶來的困擾方案其實不少。好比,咱們能夠在合適的生命週期 hook 函數中完成相關邏輯。在這個場景裏,就是在行組件的 componentDidMount 裏調用 focus,天然就完成了自動聚焦。

此外,還有一個方法:新的 refs 接口設計支持接收一個回調函數,當其子組件掛載時,這個回調函數就會相應觸發。

全部上述模式均可以徹底取代以前的問題方案,即便不能也不意味着要接受 promises 化這個PR。

爲此,sebmarkbage 說了一段很扎心的話:

Honestly, the current batching strategy comes with a set of problems right now. I'm hesitant to expand on it's API before we're sure that we're going to keep the current model. I think of it as a temporary escape until we figure out something better.

問題的根源在於現有的 batching 策略,實話實說,這個策略帶來了一系列問題。也許這個在後期後有調整,在 batching 策略是否調整以前,盲目的擴充 setState 接口只會是一個短視的行爲。

對此,Redux 原做者 Dan Abramov 也發表了本身的見解。他認爲,以他的經驗來看,任何須要使用 setState 第二個參數 callback 的場景,均可以使用生命週期函數 componentDidUpdate (and/or componentDidMount) 來複寫。

In my experience, whenever I'm tempted to use setState callback, I can achieve the same by overriding componentDidUpdate (and/or componentDidMount).

另外,在一些極端場景下,若是開發者確實須要同步的處理方式,好比若是我想在某 DOM 元素掛載到屏幕以前作一些操做,promises 這種方案便不可行。由於 Promises 老是異步的。反過來,若是 setState 支持這兩種不一樣的方式,那麼彷佛也是徹底沒有必要而多餘的。

在社區,確實不少第三方庫漸漸地接受使用 promises 風格,可是這些庫解決的問題每每都是強異步性的,好比文件讀取、網絡操做等等。 React 彷佛沒有必要增長這麼一個 confusing 的特性。

另外,若是每一個 setState 都返回一個 promises,也會帶來性能影響:對於 React 來講,setState 將必然產生一個 callback,這些 callbacks 須要合理儲存,以便在合適時間來觸發。

總結一下,解決 setState 異步帶來的問題,有不少方式可以完美優雅地解決。在這種狀況下,直接讓 setState 返回 promise 是多此一舉的。另外,這樣也會引發性能問題等等。

我我的認爲,這樣的思路很好,可是不免有些 Overengineering。

這一次爲本身瘋狂,我和個人倔強

怎麼樣,是否說服你了呢?若是沒有,在不能更改 React 源碼狀況下,你就是想用 promise 化的 setState,怎麼辦呢?

這裏提供一個「反模式」的方案:咱們不改變源碼,本身也能夠進行改造,原理上就是直接對 this.setState 進行攔截,進而進行 promise 化,再封裝一個新的接口出來。

import Promise from "bluebird";

export default {
  componentWillMount() {
    this.setStateAsync = Promise.promisify(this.setState);
  },
};複製代碼

以後,即可以異步地:

this.setStateAsync({
  loading: true,
}).then(this.loadSomething).then((result) => {
  return this.setStateAsync({result, loading: false});
});複製代碼

固然,也可使用原聲的 promises:

function setStatePromise(that, newState) {
    return new Promise((resolve) => {
        that.setState(newState, () => {
            resolve();
        });
    });
}複製代碼

甚至...咱們還能夠腦洞大開使用 async/await。

最後,全部這種作法很是的 dirty,我是不建議這麼使用的。

總結

其實研究一下 React Issue,深刻源碼學習,收穫確實不少。總結也沒有更多想說的了,無恥滴作個廣告吧:

個人其餘關於 React 文章:

Happy Coding!

PS:
做者Github倉庫知乎問答連接歡迎各類形式交流。

相關文章
相關標籤/搜索