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

LucasHC發表於2017-08-30

從 setState 那個眾所周知的小祕密說起...

在 React 元件中,呼叫 this.setState() 是最基本的場景。這個方法描述了 state 的變化、觸發了元件 re-rendering。但是,也許看似平常的 this.setState() 裡面卻也許蘊含了很多鮮為人知的設計和討論。

相信很多開發者已經意識到,setState 方法“或許”是非同步的。也許你覺得,看上去更新 state 是如此輕而易舉的操作,這並沒有什麼可非同步處理的。但是要意識到,因為 state 的更新會觸發 re-rendering,而 re-rendering 代價昂貴,短時間內反覆進行渲染在效能上肯定是不可取的。所以,React 採用 batching 思想,它會 batches 一系列連續的 state 更新,而只觸發一次 re-render。

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

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

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。不信你自己試試~

讓 setState 連續更新的幾個 hack

如果想讓 count 一次性加3,應該如何優雅地處理潛在的非同步操作,規避上述問題呢?

以下提供幾種解決方案:

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

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

    setState(updater, [callback])

  • 方法三:把需要在 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倉庫知乎問答連結
歡迎各種形式交流。

相關文章