從 setState 那個(gè)眾所周知的小秘密說(shuō)起...
在 React 組件中,調(diào)用 this.setState() 是最基本的場(chǎng)景。這個(gè)方法描述了 state 的變化、觸發(fā)了組件 re-rendering。但是,也許看似平常的 this.setState() 里面卻也許蘊(yùn)含了很多鮮為人知的設(shè)計(jì)和討論。
相信很多開(kāi)發(fā)者已經(jīng)意識(shí)到,setState 方法“或許”是異步的。也許你覺(jué)得,看上去更新 state 是如此輕而易舉的操作,這并沒(méi)有什么可異步處理的。但是要意識(shí)到,因?yàn)?state 的更新會(huì)觸發(fā) re-rendering,而 re-rendering 代價(jià)昂貴,短時(shí)間內(nèi)反復(fù)進(jìn)行渲染在性能上肯定是不可取的。所以,React 采用 batching 思想,它會(huì) batches 一系列連續(xù)的 state 更新,而只觸發(fā)一次 re-render。
關(guān)于這些內(nèi)容,如果你還不清楚,推薦參考@程墨的系列文章:setState:這個(gè)API設(shè)計(jì)到底怎么樣;英語(yǔ)好的話,可以直接關(guān)注長(zhǎng)發(fā)飄飄的 Eric Elliott 著名的引起系列口水戰(zhàn)的吐槽文:setState() Gate。
或者,直接看下面的一個(gè)小例子。
比如,最簡(jiǎn)單的一個(gè)場(chǎng)景是:
function incrementMultiple() {
this.setState({count: this.state.count + 1});
this.setState({count: this.state.count + 1});
this.setState({count: this.state.count + 1});
}
直觀上來(lái)看,當(dāng)上面的 incrementMultiple 函數(shù)被調(diào)用時(shí),組件狀態(tài)的
count 值被增加了3次,每次增加1,那最后 count 被增加了3。但是,實(shí)際上的結(jié)果只給 state 增加了1。不信你自己試試~
讓 setState 連續(xù)更新的幾個(gè) hack
如果想讓 count 一次性加3,應(yīng)該如何優(yōu)雅地處理潛在的異步操作,規(guī)避上述問(wèn)題呢?
以下提供幾種解決方案:
方法一:常見(jiàn)的一種做法便是將一個(gè)回調(diào)函數(shù)傳入 setState 方法中。即 setState 著名的函數(shù)式用法。這樣能保證即便在更新被 batched 時(shí),也能訪問(wèn)到預(yù)期的 state 或 props。(后面會(huì)解釋這么做的原理)
-
方法二:另外一個(gè)常見(jiàn)的做法是需要在 setState 更新之后進(jìn)行的邏輯(比如上述的連續(xù)第二次 count + 1),封裝到一個(gè)函數(shù)中,并作為第二個(gè)參數(shù)傳給 setState。這段函數(shù)邏輯將會(huì)在更新后由 React 代理執(zhí)行。即:
setState(updater, [callback])
方法三:把需要在 setState 更新之后進(jìn)行的邏輯放在一個(gè)合適的生命周期 hook 函數(shù)中,比如 componentDidMount 或者 componentDidUpdate 也當(dāng)然可以解決問(wèn)題。也就是說(shuō) count 第一次 +1 之后,出發(fā) componentDidUpdate 生命周期 hook,第二次 count +1 操作直接放在 componentDidUpdate 函數(shù)里面就好啦。
一個(gè)引起廣泛討論的 Issue
這些內(nèi)容貌似已經(jīng)不再新鮮,很多 React 資深開(kāi)發(fā)者其實(shí)都是了解的,或能很快理解。
可是,你想過(guò)這個(gè)問(wèn)題嗎:
現(xiàn)代 javascript 處理異步流程,很流行的一個(gè)做法是使用 promises,那么我們能否應(yīng)用這個(gè)思路解決呢?
說(shuō)具體一些,就是調(diào)用 setState 方法之后,返回一個(gè) promise,狀態(tài)更新完畢后我們?cè)谡{(diào)用 promise.then 進(jìn)行下一步處理。
答案是肯定的,但是卻被官方否決了。
我是如何得出“答案是肯定的,但是是不被官方建議的。”這個(gè)結(jié)論,喜歡刨根問(wèn)底的讀者請(qǐng)繼續(xù)往下閱讀,相信你一定會(huì)有所啟發(fā),也能更充分理解 React 團(tuán)隊(duì)的設(shè)計(jì)思想。
第 2642 Issue 解讀和深入分析
我是一步一步在 Facebook 開(kāi)源 React 的官方 Github倉(cāng)庫(kù)上,找到了線索。
整個(gè)過(guò)程跟下來(lái),相信在各路大神的 comments 之間,你會(huì)對(duì) React 的設(shè)計(jì)理念以及 javascript 解決問(wèn)題的思路有一個(gè)更清晰的認(rèn)識(shí)。
一切的探究始于 React 第 #2642 號(hào) issue: Make setState return a promise,上面關(guān)于 count 連續(xù) +3 大家已經(jīng)有所了解。接下來(lái)我舉一個(gè)真正在生產(chǎn)開(kāi)發(fā)中的例子,方便大家理解討論。
我們現(xiàn)在開(kāi)發(fā)一個(gè)可編輯的 table,需求是:當(dāng)用戶敲下“回車”,光標(biāo)將會(huì)進(jìn)入下一行(調(diào)用 setState 進(jìn)行光標(biāo)移動(dòng));如果用戶當(dāng)前已經(jīng)在最后一行,那么敲下回車時(shí),第一步將先創(chuàng)建一個(gè)新行(調(diào)用 setState 創(chuàng)建新的最后一行),在新行創(chuàng)建之后,再去新的最后一行進(jìn)行光標(biāo)聚焦(調(diào)用 setState 進(jìn)行光標(biāo)移動(dòng))。
常見(jiàn)且錯(cuò)誤的處理在于:
this.setState({
selected: input
// 創(chuàng)建新行
}.bind(this));
this.props.didSelect(this.state.selected);
因?yàn)榈谝粋€(gè) this.setState 是異步進(jìn)行的話,下一處 didSelect 方法執(zhí)行 this.setState 時(shí),所處理的參數(shù) this.state.selected 可能還不是預(yù)期的下一行。很明顯,這就是 this.setState 的異步性帶來(lái)的問(wèn)題。
為了解決這個(gè)完成這樣的邏輯,想到了 setState 第二個(gè)參數(shù)解決方案,用代碼簡(jiǎn)單表述就是:
this.setState({
selected: input
// 創(chuàng)建新行
}, function() {
this.props.didSelect(this.state.selected);
}).bind(this));
這種解決方案是使用嵌套的 setState 方法。但這無(wú)疑潛在地會(huì)帶來(lái)嵌套地獄的問(wèn)題。
Promise 化方案登場(chǎng)
這一切是不是像極了傳統(tǒng) Javascript 處理異步老套路?解決回調(diào)地獄,你是不是應(yīng)激性地想到了 promise?
如果 setState 方法返回的是一個(gè) promises,自然會(huì)更加優(yōu)雅:
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 風(fēng)格解決問(wèn)題的話,無(wú)非就是:
this.setState({
selected: input
}).then(function() {
this.props.didSelect(this.state.selected);
}.bind(this));
看上去沒(méi)什么問(wèn)題,一個(gè)很時(shí)髦的設(shè)計(jì)。但是,我們進(jìn)一步想:如果想讓 React 支持這樣的特性,采用提出 pull request 的方式,我們?cè)撊绾稳ジ脑创a呢?
探索 React 源碼,完成 setState promise 化的改造
首先找到源碼中關(guān)于 setState 定義的地方,它在 react/src/isomorphic/modern/class/ReactBaseClasses.js 這個(gè)目錄下:
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 第二個(gè)參數(shù)傳入處理回調(diào)的基礎(chǔ)。
另外,從注釋中我們還找到:
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 方法直接傳入一個(gè)函數(shù)的基礎(chǔ)。
言歸正傳,如何改動(dòng)源碼,使得 setState promise 化呢?
其實(shí)很簡(jiǎn)單,我直接上代碼:
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;
+ }
};
我用 “+” 標(biāo)注了對(duì)源碼所做的更改。如果開(kāi)發(fā)者調(diào)用 setState 方法時(shí),傳入的是一個(gè) javascript 對(duì)象的話,那么會(huì)返回一個(gè) promise,這個(gè) promise 將會(huì)在 state 更新完畢后 resolve。
如果您看不懂的話,建議補(bǔ)充一下相關(guān)的基礎(chǔ)知識(shí),或者留言與我討論。
解決方案有了,可是 React 官方會(huì)接受這個(gè) PR 嗎?
很遺憾,答案是否定的。我們來(lái)從 React 設(shè)計(jì)思想上,和 React 官方團(tuán)隊(duì)的回應(yīng)上,了解一下否決理由。
sebmarkbage(Facebook 工程師,React 核心開(kāi)發(fā)者)認(rèn)為:解決異步帶來(lái)的困擾方案其實(shí)很多。比如,我們可以在合適的生命周期 hook 函數(shù)中完成相關(guān)邏輯。在這個(gè)場(chǎng)景里,就是在行組件的 componentDidMount 里調(diào)用 focus,自然就完成了自動(dòng)聚焦。
此外,還有一個(gè)方法:新的 refs 接口設(shè)計(jì)支持接收一個(gè)回調(diào)函數(shù),當(dāng)其子組件掛載時(shí),這個(gè)回調(diào)函數(shù)就會(huì)相應(yīng)觸發(fā)。
所有上述模式都可以完全取代之前的問(wèn)題方案,即使不能也不意味著要接受 promises 化這個(gè)PR。
為此,sebmarkbage 說(shuō)了一段很扎心的話:
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.
問(wèn)題的根源在于現(xiàn)有的 batching 策略,實(shí)話實(shí)說(shuō),這個(gè)策略帶來(lái)了一系列問(wèn)題。也許這個(gè)在后期后有調(diào)整,在 batching 策略是否調(diào)整之前,盲目的擴(kuò)充 setState 接口只會(huì)是一個(gè)短視的行為。
對(duì)此,Redux 原作者 Dan Abramov 也發(fā)表了自己的看法。他認(rèn)為,以他的經(jīng)驗(yàn)來(lái)看,任何需要使用 setState 第二個(gè)參數(shù) callback 的場(chǎng)景,都可以使用生命周期函數(shù) componentDidUpdate (and/or componentDidMount) 來(lái)復(fù)寫。
In my experience, whenever I'm tempted to use setState callback, I can achieve the same by overriding componentDidUpdate (and/or componentDidMount).
另外,在一些極端場(chǎng)景下,如果開(kāi)發(fā)者確實(shí)需要同步的處理方式,比如如果我想在某 DOM 元素掛載到屏幕之前做一些操作,promises 這種方案便不可行。因?yàn)?Promises 總是異步的。反過(guò)來(lái),如果 setState 支持這兩種不同的方式,那么似乎也是完全沒(méi)有必要而多余的。
在社區(qū),確實(shí)很多第三方庫(kù)漸漸地接受使用 promises 風(fēng)格,但是這些庫(kù)解決的問(wèn)題往往都是強(qiáng)異步性的,比如文件讀取、網(wǎng)絡(luò)操作等等。 React 似乎沒(méi)有必要增加這么一個(gè) confusing 的特性。
另外,如果每個(gè) setState 都返回一個(gè) promises,也會(huì)帶來(lái)性能影響:對(duì)于 React 來(lái)說(shuō),setState 將必然產(chǎn)生一個(gè) callback,這些 callbacks 需要合理儲(chǔ)存,以便在合適時(shí)間來(lái)觸發(fā)。
總結(jié)一下,解決 setState 異步帶來(lái)的問(wèn)題,有很多方式能夠完美優(yōu)雅地解決。在這種情況下,直接讓 setState 返回 promise 是畫蛇添足的。另外,這樣也會(huì)引起性能問(wèn)題等等。
我個(gè)人認(rèn)為,這樣的思路很好,但是難免有些 Overengineering。
這一次為自己瘋狂,我和我的倔強(qiáng)
怎么樣,是否說(shuō)服你了呢?如果沒(méi)有,在不能更改 React 源碼情況下,你就是想用 promise 化的 setState,怎么辦呢?
這里提供一個(gè)“反模式”的方案:我們不改變?cè)创a,自己也可以進(jìn)行改造,原理上就是直接對(duì) this.setState 進(jìn)行攔截,進(jìn)而進(jìn)行 promise 化,再封裝一個(gè)新的接口出來(lái)。
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});
});
當(dāng)然,也可以使用原聲的 promises:
function setStatePromise(that, newState) {
return new Promise((resolve) => {
that.setState(newState, () => {
resolve();
});
});
}
甚至...我們還可以腦洞大開(kāi)使用 async/await。
最后,所有這種做法非常的 dirty,我是不建議這么使用的。
總結(jié)
其實(shí)研究一下 React Issue,深入源碼學(xué)習(xí),收獲確實(shí)很多。總結(jié)也沒(méi)有更多想說(shuō)的了,無(wú)恥滴做個(gè)廣告吧:
我的其他關(guān)于 React 文章:
解析Twitter前端架構(gòu) 學(xué)習(xí)復(fù)雜場(chǎng)景數(shù)據(jù)設(shè)計(jì)
React+Redux打造“NEWS EARLY”單頁(yè)應(yīng)用 一個(gè)項(xiàng)目理解最前沿技術(shù)棧真諦
Happy Coding!
PS:
作者Github倉(cāng)庫(kù) 和 知乎問(wèn)答鏈接
歡迎各種形式交流。