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

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

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

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

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

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

function incrementMultiple() {n this.setState({count: this.state.count + 1});n this.setState({count: this.state.count + 1});n this.setState({count: this.state.count + 1});n }n

直觀上來看,當上面的 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])n

  • 方法三:把需要在 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({n selected: inputn // 創建新行n}.bind(this));nthis.props.didSelect(this.state.selected);n

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

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

this.setState({n selected: inputn // 創建新行n}, function() {n this.props.didSelect(this.state.selected);n}).bind(this));n

這種解決方案是使用嵌套的 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({n selected: inputn}).then(function() {n this.props.didSelect(this.state.selected);n}.bind(this));n

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

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

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

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

我們首先看到一句注釋:

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) {n invariant(n typeof partialState === object ||n typeof partialState === function ||n partialState == null,n setState(...): takes an object of state variables to update or a +n function which returns an object of state variables.,n );n + let callbackPromise;n + if (!callback) {n + class Deferred {n + constructor() {n + this.promise = new Promise((resolve, reject) => {n + this.reject = reject;n + this.resolve = resolve;n + });n + }n + }n + callbackPromise = new Deferred();n + callback = () => {n + callbackPromise.resolve();n + };n + }n this.updater.enqueueSetState(this, partialState, callback, setState);n +n + if (callbackPromise) {n + return callbackPromise.promise;n + }n }; n

我用 「+」 標註了對源碼所做的更改。如果開發者調用 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. Im hesitant to expand on its API before were sure that were 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 Im 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";nnexport default {n componentWillMount() {n this.setStateAsync = Promise.promisify(this.setState);n },n};n

之後,便可以非同步地:

this.setStateAsync({n loading: true,n}).then(this.loadSomething).then((result) => {n return this.setStateAsync({result, loading: false});n});n

當然,也可以使用原聲的 promises:

function setStatePromise(that, newState) {n return new Promise((resolve) => {n that.setState(newState, () => {n resolve();n });n });n}n

甚至...我們還可以腦洞大開使用 async/await。

最後,所有這種做法非常的 dirty,我是不建議這麼使用的。

總結

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

我的其他關於 React 文章:

  • React Redux 中間件思想遇見 Web Worker 的靈感(附demo)
  • 通過實例,學習編寫 React 組件的「最佳實踐」
  • React 組件設計和分解思考
  • 從 React 綁定 this,看 JS 語言發展和框架設計
  • React 服務端渲染如此輕鬆 從零開始構建前後端應用
  • 做出Uber移動網頁版還不夠 極致性能打造才見真章
  • 解析Twitter前端架構 學習複雜場景數據設計
  • React Conf 2017 乾貨總結1: React + ES next = ?
  • React+Redux打造「NEWS EARLY」單頁應用 一個項目理解最前沿技術棧真諦
  • 一個react+redux工程實例

Happy Coding!

PS: 作者Github倉庫 和 知乎問答鏈接 歡迎各種形式交流。


推薦閱讀:

做一枚全棧工程師
前端每周清單第2期:Vue 2.2發布,React在GitHub突破6萬star
Less的安裝,編譯以及高亮
前端開發中,多個項目使用的公共組件如何存放?
Airbnb CSS / Sass 指南

TAG:React | 前端开发 | 前端工程师 |