前言
上個月發表了一篇 React源碼學習——ReactClass,但是后來我發現,大家對這種大量貼代碼分析源碼的形式並不感冒。講道理,我自己看着也煩,還不如自己直接去翻源碼來得痛快。吸取了上一次的教訓,這次我決定:理性貼代碼!翻閱源代碼的工作還是留給各位小伙伴自己去做比較好。本來這次想准備說一說我們平時一直提到的React Virture DOM,但這可能又會造成無限貼源碼的后果,因為virture dom在React中主要就是一個對象,在ReactElement中定義的,感興趣的同學去源碼中搜索一下createElement方法,就能看到virture dom是啥東西了。對其本身是沒啥好說的,需要分析的應該是其在組件掛載和更新時的應用,因此對於ReactElement本身就不單獨拿出來講了,大家感興趣就去翻閱一下源碼吧。
進入正題
這次主要是要分析一下React中常見的setState方法,熟悉React的小伙伴應該都知道,該方法通常用於改變組件狀態並用新的state去更新組件。但是,這個方法在很多地方的表現總是與我們的預期不符,先來看幾個案例。
案例一
1 class Root extends React.Component { 2 constructor(props) { 3 super(props); 4 this.state = { 5 count: 0 6 }; 7 } 8 componentDidMount() { 9 let me = this; 10 me.setState({ 11 count: me.state.count + 1 12 }); 13 console.log(me.state.count); // 打印出0 14 me.setState({ 15 count: me.state.count + 1 16 }); 17 console.log(me.state.count); // 打印出0 18 setTimeout(function(){ 19 me.setState({ 20 count: me.state.count + 1 21 }); 22 console.log(me.state.count); // 打印出2 23 }, 0); 24 setTimeout(function(){ 25 me.setState({ 26 count: me.state.count + 1 27 }); 28 console.log(me.state.count); // 打印出3 29 }, 0); 30 } 31 render() { 32 return ( 33 <h1>{this.state.count}</h1> 34 ) 35 } 36 }
這個案例大家可能在別的地方中也見到過,結果確實讓人匪夷所思,打印出0,0,2,3。先拋出兩個問題:
- 為什么不在setTimeout中執行的兩次setState均打印出0?
- 為什么setTimeout中執行的兩次setState會打印出不同結果?
帶着兩個問題往下看。
React中的transaction(事務)
說到事務,我第一反應就是在以前使用sql server時用來處理批量操作的一個機制。當所有操作均執行成功,即可以commit transaction;若有一個操作失敗,則執行rollback。在React中,也實現了一種類似的事務機制,其他文章也有詳細的介紹。按照我個人的理解,React中一個事務其實就是按順序調用一系列函數。在React中就是調用perform方法進入一個事務,該方法中會傳入一個method參數。執行perform時先執行initializeAll方法按順序執行一系列initialize的操作,例如一些初始化操作等等,然后執行傳入的method,method執行完后就執行closeAll方法按順序執行一系列close操作,例如下面會提到的執行批量更新或者將isBatchingUpdates變回false等等,然后結束這次事務。React中內置了很多種事務,注意,同一種事務不能同時開啟,否則會拋出異常。我們還是回到我們上面的案例中來說明這個過程。
組件在調用ReactDOM.render()之后,會執行一個_renderNewRootComponent方法,大家可以去翻閱源碼看一看,該方法執行了一個ReactUpdates.batchedUpdates()。batchedUpdates是什么呢?我們看看它的代碼。
1 var transaction = new ReactDefaultBatchingStrategyTransaction(); 2 3 var ReactDefaultBatchingStrategy = { 4 isBatchingUpdates: false, 5 6 /** 7 * Call the provided function in a context within which calls to `setState` 8 * and friends are batched such that components aren't updated unnecessarily. 9 */ 10 batchedUpdates: function (callback, a, b, c, d, e) { 11 var alreadyBatchingUpdates = ReactDefaultBatchingStrategy.isBatchingUpdates; 12 13 ReactDefaultBatchingStrategy.isBatchingUpdates = true; 14 15 // The code is written this way to avoid extra allocations 16 if (alreadyBatchingUpdates) { 17 return callback(a, b, c, d, e); 18 } else { 19 return transaction.perform(callback, null, a, b, c, d, e); 20 } 21 } 22 };
從代碼中我們可以看出,這個batchedUpdates由於是第一次被調用,alreadyBatchingUpdates為false,因此會去執行transaction.perform(method),這就將進入一個事務,這個事務具體做了啥我們暫時不用管,我們只需要知道這個transaction是ReactDefaultBatchingStrategyTransaction的實例,它代表了其中一類事務的執行。然后會在該事務中調用perform中傳入的method方法,即開啟了組件的首次裝載。當裝載完畢會調用componentDidMount(注意,此時還是在執行method方法,事務還沒結束,事務只有在執行完method后執行一系列close才會結束),在該方法中,我們調用了setState,出現了一系列奇怪的現象。因此,我們再來看看setState方法,這里只貼部分代碼。
1 ReactComponent.prototype.setState = function (partialState, callback) { 2 !(typeof partialState === 'object' || typeof partialState === 'function' || partialState == null) ? "development" !== 'production' ? invariant(false, 'setState(...): takes an object of state variables to update or a function which returns an object of state variables.') : _prodInvariant('85') : void 0; 3 this.updater.enqueueSetState(this, partialState); 4 if (callback) { 5 this.updater.enqueueCallback(this, callback, 'setState'); 6 } 7 };
setState在調用時做了兩件事,第一,調用enqueueSetState。該方法將我們傳入的partialState添加到一個叫做_pendingStateQueue的隊列中去存起來,然后執行一個enqueueUpdate方法。第二,如果存在callback就調用enqueueCallback將其存入一個_pendingCallbacks隊列中存起來。然后我們來看enqueueUpdate方法。
1 function enqueueUpdate(component) { 2 ensureInjected(); 3 4 // Various parts of our code (such as ReactCompositeComponent's 5 // _renderValidatedComponent) assume that calls to render aren't nested; 6 // verify that that's the case. (This is called by each top-level update 7 // function, like setState, forceUpdate, etc.; creation and 8 // destruction of top-level components is guarded in ReactMount.) 9 10 if (!batchingStrategy.isBatchingUpdates) { 11 batchingStrategy.batchedUpdates(enqueueUpdate, component); 12 return; 13 } 14 15 dirtyComponents.push(component); 16 if (component._updateBatchNumber == null) { 17 component._updateBatchNumber = updateBatchNumber + 1; 18 } 19 }
是否看到了某些熟悉的字眼,如isBatchingUpdates和batchedUpdates。不錯,其實翻閱代碼就能明白,這個batchingStrategy就是上面的ReactDefaultBatchingStrategy,只是它通過inject的形式對其進行賦值,比較隱蔽。因此,我們當前的setState已經處於了這一類事務之中,isBatchingUpdates已經被置為true,所以將會把它添加到dirtyComponents中,在某一時刻做批量更新。因此在前兩個setState中,並沒有做任何狀態更新,以及組件更新的事,而僅僅是將新的state和該組件存在了隊列之中,因此兩次都會打印出0,我們之前的第一個問題就解決了,還有一個問題,我們接着往下走。
在setTimeout中執行的setState打印出了2和3,有了前面的鋪墊,我們大概就能得出結論,這應該就是因為這兩次setState分別執行了一次完整的事務,導致state被直接更新而造成的結果。那么問題來了,為什么setTimeout中的setState會分別執行兩次不同的事務?之前執行ReactDOM.render開啟的事務在什么時候結束了?我們來看下列代碼。
1 var RESET_BATCHED_UPDATES = { 2 initialize: emptyFunction, 3 close: function () { 4 ReactDefaultBatchingStrategy.isBatchingUpdates = false; 5 } 6 }; 7 8 var FLUSH_BATCHED_UPDATES = { 9 initialize: emptyFunction, 10 close: ReactUpdates.flushBatchedUpdates.bind(ReactUpdates) 11 }; 12 13 var TRANSACTION_WRAPPERS = [FLUSH_BATCHED_UPDATES, RESET_BATCHED_UPDATES]; 14 15 function ReactDefaultBatchingStrategyTransaction() { 16 this.reinitializeTransaction(); 17 } 18 19 _assign(ReactDefaultBatchingStrategyTransaction.prototype, Transaction, { 20 getTransactionWrappers: function () { 21 return TRANSACTION_WRAPPERS; 22 } 23 });
這段代碼也是寫在ReactDefaultBatchingStrategy這個對象中的。我們之前提到這個事務中transaction是ReactDefaultBatchingStrategyTransaction的實例,這段代碼其實就是給該事務添加了兩個在事務結束時會被調用的close方法。即在perform中的method執行完畢后,會按照這里數組的順序[FLUSH_BATCHED_UPDATES, RESET_BATCHED_UPDATES]依次調用其close方法。FLUSH_BATCHED_UPDATES是執行批量更新操作。RESET_BATCHED_UPDATES我們可以看到將isBatchingUpdates變回false,即意味着事務結束。接下來再調用setState時,enqueueUpdate不會再將其添加到dirtyComponents中,而是執行batchingStrategy.batchedUpdates(enqueueUpdate, component)開啟一個新事務。但是需要注意,這里傳入的參數是enqueueUpdate,即perform中執行的method為enqueueUpdate,而再次調用該enqueueUpdate方法會去執行dirtyComponents那一步。這就可以理解為,處於單獨事務的setState也是通過將組件添加到dirtyComponents來完成更新的,只不過這里是在enqueueUpdate執行完畢后立即執行相應的close方法完成更新,而前面兩個setState需在整個組件裝載完成之后,即在componentDidMount執行完畢后才會去調用close完成更新。總結一下4個setState執行的過程就是:先執行兩次console.log,然后執行批量更新,再執行setState直接更新,執行console.log,最后再執行setState直接更新,再執行console.log,所以就會得出0,0,2,3。
案例二
如下兩種相似的寫法,得出不同的結果。
class Root extends React.Component { constructor(props) { super(props); this.state = { count: 0 }; } componentDidMount() { let me = this; me.setState({ count: me.state.count + 1 }); me.setState({ count: me.state.count + 1 }); } render() { return ( <h1>{this.state.count}</h1> //頁面中將打印出1 ) } }
class Root extends React.Component { constructor(props) { super(props); this.state = { count: 0 }; } componentDidMount() { let me = this; me.setState(function(state, props) { return { count: state.count + 1 } }); me.setState(function(state, props) { return { count: state.count + 1 } }); } render() { return ( <h1>{this.state.count}</h1> //頁面中將打印出2 ) } }
這兩種寫法,一個是在setState中傳入了object,一個是傳入了function,卻得到了兩種不同的結果,這是什么原因造成的,這就需要我們去深入了解一下進行批量更行時都做了些什么。
批量更新
前面提到事務即將結束時,會去調用FLUSH_BATCHED_UPDATES的flushBatchedUpdates方法執行批量更新,該方法會去遍歷dirtyComponents,對每一項執行performUpdateIfNecessary方法,該方法代碼如下:
1 performUpdateIfNecessary: function (transaction) { 2 if (this._pendingElement != null) { 3 ReactReconciler.receiveComponent(this, this._pendingElement, transaction, this._context); 4 } else if (this._pendingStateQueue !== null || this._pendingForceUpdate) { 5 this.updateComponent(transaction, this._currentElement, this._currentElement, this._context, this._context); 6 } else { 7 this._updateBatchNumber = null; 8 } 9 }
在我們的setState更新中,其實只會用到第二個 this._pendingStateQueue !== null 的判斷,即如果_pendingStateQueue中還存在未處理的state,那就會執行updateComponent完成更新。那_pendingStateQueue是何時被處理的呢,繼續看!
通過翻閱updateComponent方法,我們可以知道_pendingStateQueue是在該方法中由_processPendingState(nextProps, nextContext)方法做了一些處理,該方法傳入兩個參數,新的props屬性和新的上下文環境,這個上下文環境可以先不用管。我們看看_processPendingState的具體實現。
1 _processPendingState: function (props, context) { 2 var inst = this._instance; // _instance保存了Constructor的實例,即通過ReactClass創建的組件的實例 3 var queue = this._pendingStateQueue; 4 var replace = this._pendingReplaceState; 5 this._pendingReplaceState = false; 6 this._pendingStateQueue = null; 7 8 if (!queue) { 9 return inst.state; 10 } 11 12 if (replace && queue.length === 1) { 13 return queue[0]; 14 } 15 16 var nextState = _assign({}, replace ? queue[0] : inst.state); 17 for (var i = replace ? 1 : 0; i < queue.length; i++) { 18 var partial = queue[i]; 19 _assign(nextState, typeof partial === 'function' ? partial.call(inst, nextState, props, context) : partial); 20 } 21 22 return nextState; 23 },
什么replace啊什么的都可以暫時不用看,主要先看for循環內部做的事情,replace我們暫時認為是false。for循環遍歷了_pendingStateQueue中所有保存的狀態,對於每一個狀態進行處理,處理時首先判斷保存的是function還是object。若是function,就在inst的上下文中執行該匿名函數,該函數返回一個代表新state的object,然后執行assign將其與原有的state合並;若是object,則直接與state合並。注意,傳入setState的第一個參數如果是function類型,我們可以看到,其第一個參數nextState即表示更新之前的狀態;第二個參數props代表更新之后的props,第三個context代表新的上下文環境。之后返回合並后的state。這里還需要注意一點,這一點很關鍵,代碼中出現了this._pendingStateQueue = null這么一段,這也就意味着dirtyComponents進入下一次循環時,執行performUpdateIfNecessary不會再去更新組件,這就實現了批量更新,即只做一次更新操作,React在更新組件時就是用這種方式做了優化。
好了,回來看我們的案例,當我們傳入函數作為setState的第一個參數時,我們用該函數提供給我們的state參數來訪問組件的state。該state在代碼中就對應nextState這個值,這個值在每一次for循環執行時都會對其進行合並,因此第二次執行setState,我們在函數中訪問的state就是第一次執行setState后已經合並過的值,所以會打印出2。然而直接通過this.state.count來訪問,因為在執行對_pendingStateQueue的for循環時,組件的update還未執行完,this.state還未被賦予新的值,其實了解一下updateComponent會發現,this.state的更新會在_processPendingState執行完執行。所以兩次setState取到的都是this.state.count最初的值0,這就解釋了之前的現象。其實,這也是React為了解決這種前后state依賴但是state又沒及時更新的一種方案,因此在使用時大家要根據實際情況來判斷該用哪種方式傳參。
接下來我們再來看看setState的第二個參數,回調函數,它是在什么時候執行的。
案例三
1 class Root extends React.Component { 2 constructor(props) { 3 super(props); 4 this.state = { 5 count: 0 6 }; 7 } 8 9 componentDidMount() { 10 let me = this; 11 setTimeout(function() { 12 me.setState({count: me.state.count + 1}, function() { 13 console.log('did callback'); 14 }); 15 console.log('hello'); 16 }, 0); 17 } 18 19 componentDidUpdate() { 20 console.log('did update'); 21 } 22 23 render() { 24 return <h1>{this.state.count}</h1> 25 } 26 }
這個案例控制台打印順序是怎樣的呢?不賣關子了,答案是did update,did callback,hello。這里是在一個setTimeout中執行了setState,因此其處於一個單獨的事務之中,所以hello最后打印容易理解。然后我們來看看setState執行更新時做了些啥。前面我們知道在執行完組件裝載即調用了componentDidMount之后,事務開始執行一系列close方法,這其中包括調用FLUSH_BATCHED_UPDATES中的flushBatchedUpdates,我們來看看這段代碼。
1 var flushBatchedUpdates = function () { 2 // ReactUpdatesFlushTransaction's wrappers will clear the dirtyComponents 3 // array and perform any updates enqueued by mount-ready handlers (i.e., 4 // componentDidUpdate) but we need to check here too in order to catch 5 // updates enqueued by setState callbacks and asap calls. 6 while (dirtyComponents.length || asapEnqueued) { 7 if (dirtyComponents.length) { 8 var transaction = ReactUpdatesFlushTransaction.getPooled(); 9 transaction.perform(runBatchedUpdates, null, transaction); // 處理批量更新 10 ReactUpdatesFlushTransaction.release(transaction); 11 } 12 13 if (asapEnqueued) { 14 asapEnqueued = false; 15 var queue = asapCallbackQueue; 16 asapCallbackQueue = CallbackQueue.getPooled(); 17 queue.notifyAll(); // 處理callback 18 CallbackQueue.release(queue); 19 } 20 } 21 };
可以看我做了中文標注的兩個地方,這個方法其實主要就是處理了組件的更新和callback的調用。組件的更新發生在runBatchedUpdates這個方法中,下面的queue.notifyAll內部其實就是從隊列中去除callback調用,因此應該是先執行完更新,調用componentDidUpdate方法之后,再去執行callback,就有了我們上面的結果。
總結一下
React在組件更新方面做了很多優化,這其中就包括了上述的批量更新。在componentDidMount中執行了N個setState,如果執行N次更新是件很傻的事情。React利用其獨特的事務實現,做了這些優化。正是因為這些優化,才造成了上面見到的怪現象。還有一點,再使用this.state時一定要注意組件的生命周期,很多時候在獲取state的時候,組件更新還未完成,this.state還未改變,這是很容易造成bug的一個地方,要避免這個問題,需要對組件生命周期有一定的了解。在執行setState時,我們可以通過在第一個參數傳入function的形式來避免類似的問題。如果大家發現有任何問題,都可以在評論中告訴我,感激不盡。
