從源碼全面剖析React 組件更新機制

blank

從源碼全面剖析React 組件更新機制

React 把組件看作狀態機(有限狀態機), 使用state來控製本地狀態, 使用props來傳遞狀態. 前面我們探討了React 如何映射狀態到UI 上(初始渲染), 那麼接下來我們談談React 時如何同步狀態到UI 上的, 也就是:

React 是如何更新組件的?

React 是如何對比出頁面變化最小的部分?

這篇文章會為你解答這些問題.

在這之前

你已經了解了React (15-stable版本)內部的一些基本概念,包括不同類型的組件實例、mount過程、事務、批量更新的大致過程(還沒有?不用擔心,為你準備好了從源碼看組件初始渲染接著從源碼看組件初始渲染);

準備一個demo ,調試源碼,以便更好理解;

Keep calm and make a big deal !

React 是如何更新組件的?

TL;DR

  • 依靠事務進行批量更新;
  • 一次batch(批量)的生命週期就是從ReactDefaultBatchingStrategy事務perform之前(調用ReactUpdates.batchUpdates)到這個事務的最後一個close方法調用後結束;
  • 事務啟動後,遇到setState則將partial state存到組件實例的_pendingStateQueue上,然後將這個組件存到dirtyComponents數組中,等到ReactDefaultBatchingStrategy事務結束時調用runBatchedUpdates批量更新所有組件;
  • 組件的更新是遞歸的,三種不同類型的組件都有自己的updateComponent方法來決定自己的組件如何更新,其中ReactDOMComponent會採用diff算法對比子元素中最小的變化,再批量處理.

這個更新過程像是一套流程, 無論你通過setState(或者replaceState)還是新的props去更新一個組件, 都會起作用.

那麼具體是什麼?

讓我們從這套更新流程的開始部分講起...

調用setState 之前

首先,開始一次batch的入口是在ReactDefaultBatchingStrategy裡,調用裡面的batchedUpdates便可以開啟一次batch:

//批處理策略varReactDefaultBatchingStrategy={isBatchingUpdates:false,batchedUpdates:function(callback,a,b,c,d,e){varalreadyBatchingUpdates=ReactDefaultBatchingStrategy.isBatchingUpdates;ReactDefaultBatchingStrategy.isBatchingUpdates=true;//開啟一次batchif(alreadyBatchingUpdates){returncallback(a,b,c,d,e);}else{//啟動事務,將callback放進事務裡執行returntransaction.perform(callback,null,a,b,c,d,e);}},};

在React中,調用batchedUpdates有很多地方,與更新流程相關的如下

// ReactMount.js ReactUpdates . batchedUpdates ( batchedMountComponentIntoNode , // 负责初始渲染componentInstance , container , shouldReuseMarkup , context , ); // ReactEventListener.js dispatchEvent : function ( topLevelType , nativeEvent ) { ... try { ReactUpdates . batchedUpdates ( handleTopLevelImpl , bookKeeping ); // 处理事件} finally { TopLevelCallbackBookKeeping . release ( bookKeeping ); } },

第一種情況, React在首次渲染組件的時候會調用batchedUpdates ,然後開始渲染組件.那麼為什麼要在這個時候啟動一次batch呢?不是因為要批量插入,因為插入過程是遞歸的,而是因為組件在渲染的過程中,會依順序調用各種生命週期函數,開發者很可能在生命週期函數中(如componentWillMount或者componentDidMount )調用setState .因此,開啟一次batch就是要存儲更新(放入dirtyComponents),然後在事務結束時批量更新.這樣以來,在初始渲染流程中,任何setState都會生效,用戶看到的始終是最新的狀態.

第二種情況,如果你在HTML元素上或者組件上綁定了事件,那麼你有可能在事件的監聽函數中調用setState ,因此,同樣為了存儲更新(放入dirtyComponents),需要啟動批量更新策略.在回調函數被調用之前, React事件系統中的dispatchEvent函數負責事件的分發,在dispatchEvent中啟動了事務,開啟了一次batch,隨後調用了回調函數.這樣一來,在事件的監聽函數中調用的setState就會生效.

也就是說, 任何可能調用setState 的地方, 在調用之前, React 都會啟動批量更新策略以提前應對可能的setState

那麼調用batchedUpdates 後發生了什麼?

React調用batchedUpdates時會傳進去一個函數, batchedUpdates會啟動ReactDefaultBatchingStrategyTransaction事務,這個函數就會被放在事務裡執行:

// ReactDefaultBatchingStrategy.js var transaction = new ReactDefaultBatchingStrategyTransaction (); // 实例化事务var ReactDefaultBatchingStrategy = { ... batchedUpdates : function ( callback , a , b , c , d , e ) { ... return transaction . perform ( callback , null , a , b , c , d , e ); // 将callback放进事务里执行... };

ReactDefaultBatchingStrategyTransaction這個事務控制了批量策略的生命週期:

// ReactDefaultBatchingStrategy.js var FLUSH_BATCHED_UPDATES = { initialize : emptyFunction , close : ReactUpdates . flushBatchedUpdates . bind ( ReactUpdates ), // 批量更新}; var RESET_BATCHED_UPDATES = { initialize : emptyFunction , close : function () { ReactDefaultBatchingStrategy . isBatchingUpdates = false ; // 结束本次batch }, }; var TRANSACTION_WRAPPERS = [ FLUSH_BATCHED_UPDATES , RESET_BATCHED_UPDATES ];

無論你傳進去的函數是什麼,無論這個函數後續會做什麼,都會在執行完後調用上面事務的close方法,先調用flushBatchedUpdates批量更新,再結束本次batch.

調用setState 後發生了什麼

// ReactBaseClasses.js :ReactComponent.prototype.setState=function(partialState,callback){this.updater.enqueueSetState(this,partialState);if(callback){this.updater.enqueueCallback(this,callback,'setState');}};// => ReactUpdateQueue.js:enqueueSetState:function(publicInstance,partialState){//根據this.setState中的this拿到內部實例,也就是組件實例varinternalInstance=getInternalInstanceReadyForUpdate(publicInstance,'setState');//取得組件實例的_pendingStateQueuevarqueue=internalInstance._pendingStateQueue||(internalInstance._pendingStateQueue=[]);//將partial state存到_pendingStateQueuequeue.push(partialState);//調用enqueueUpdateenqueueUpdate(internalInstance);}// => ReactUpdate.js:functionenqueueUpdate(component){ensureInjected();//注入默認策略//如果沒有開啟batch(或當前batch已結束)就開啟一次batch再執行,這通常發生在異步回調中調用setState //的情況if(!batchingStrategy.isBatchingUpdates){batchingStrategy.batchedUpdates(enqueueUpdate,component);return;}//如果batch已經開啟就存儲更新dirtyComponents.push(component);if(component._updateBatchNumber==null){component._updateBatchNumber=updateBatchNumber+1;}}

也就是說,調用setState會首先拿到內部組件實例,然後把要更新的partial state存到其_pendingStateQueue中,然後標記當前組件為dirtyComponent ,存到dirtyComponents數組中.然後就接著繼續做下面的事情了, 並沒有立即更新, 這是因為接下來要執行的代碼裡有可能還會調用setState, 因此只做存儲處理.

什麼時候批量更新?

首先, 一個事務在執行的時候(包括initialize、perform、close階段), 任何一階段都有可能調用一系列函數, 並且開啟了另一些事務. 那麼只有等後續開啟的事務執行完, 之前開啟的事務才繼續執行. 下圖是我們剛才所說的第一種情況, 在初始渲染組件期間setState 後, React 啟動的各種事務和執行的順序:

blank

從圖中可以看到,批量更新是在ReactDefaultBatchingStrategyTransaction事務的close階段,在flushBatchedUpdates函數中啟動了ReactUpdatesFlushTransaction事務負責批量更新.

怎麼批量更新的?

開啟批量更新事務、批量處理callback

我們接著看flushBatchedUpdates函數,在ReactUpdates.js中

varflushBatchedUpdates=function(){//啟動批量更新事務while(dirtyComponents.length||asapEnqueued){if(dirtyComponents.length){vartransaction=ReactUpdatesFlushTransaction.getPooled();transaction.perform(runBatchedUpdates,null,transaction);ReactUpdatesFlushTransaction.release(transaction);}//批量處理callbackif(asapEnqueued){asapEnqueued=false;varqueue=asapCallbackQueue;asapCallbackQueue=CallbackQueue.getPooled();queue.notifyAll();CallbackQueue.release(queue);}}};

遍歷dirtyComponents

flushBatchedUpdates啟動了一個更新事務,這個事務執行了runBatchedUpdates進行批量更新:

// ReactUpdates.jsfunctionrunBatchedUpdates(transaction){varlen=transaction.dirtyComponentsLength;//排序保證父組件優先於子組件更新dirtyComponents.sort(mountOrderComparator);//代表批量更新的次數,保證每個組件只更新一次updateBatchNumber++;//遍歷dirtyComponentsfor(vari=0;i<len;i++){varcomponent=dirtyComponents[i];varcallbacks=component._pendingCallbacks;component._pendingCallbacks=null;...//執行更新ReactReconciler.performUpdateIfNecessary(component,transaction.reconcileTransaction,updateBatchNumber,);...//存儲callback以便後續按順序調用if(callbacks){for(varj=0;j<callbacks.length;j++){transaction.callbackQueue.enqueue(callbacks[j],component.getPublicInstance(),);}}}}

前面setState後將組件推入了dirtyComponents ,現在就是要遍歷dirtyComponents數組進行更新了.

根據不同情況執行更新

ReactReconciler會調用組件實例的performUpdateIfNecessary .如果接收了props,就會調用此組件的receiveComponent ,再在裡面調用updateComponent更新組件;如果沒有接受props,但是有新的要更新的狀態(_pendingStateQueue不為空)就會直接調用updateComponent來更新:

// ReactCompositeComponent.jsperformUpdateIfNecessary:function(transaction){if(this._pendingElement!=null){ReactReconciler.receiveComponent(this,this._pendingElement,transaction,this._context);}elseif(this._pendingStateQueue!==null||this._pendingForceUpdate){this.updateComponent(transaction,this._currentElement,this._currentElement,this._context,this._context);}else{this._updateBatchNumber=null;}}

調用組件實例的updateComponent

接下里就是重頭戲updateComponent了,它決定了組件如果更新自己和它的後代們.需要特別注意的是, React內部三種不同的組件類型,每種組件都有自己的updateComponent ,有不同的行為.

對於ReactCompositeComponent ( 矢量圖):

blank

updateComponent所做的事情:

  • 調用此層級組件的一系列生命週期函數, 並且在合適的時機更新props、state、context;
  • re-render, 與之前render 的element 比較, 如果兩者key && element.type 相等, 則進入下一層進行更新; 如果不等, 直接移除重新mount

對於ReactDOMComponent:

blank

updateComponent所做的事情:

  • 更新這一層級DOM元素屬性;
  • 更新子元素,調用ReactMultiChild的updateChildren ,對比前後變化、標記變化類型、存到updates中(diff算法主要部分);
  • 批量處理updates

對於ReactDOMTextComponent :

blank

上面只是每個組件自己更新的過程, 那麼React 是如何一次性更新所有組件的? 答案是遞歸.

遞歸調用組件的updateComponent

觀察ReactCompositeComponent和ReactDOMComponent的更新流程,我們發現React每次走到一個組件更新過程的最後部分,都會有一個判斷:如果nextELement和prevElement key和type相等,就會調用receiveComponent . receiveComponentupdateComponent一樣,每種組件都有一個,作用就相當於updateComponent接受了新props的版本.而這裡調用的就是子元素的receiveComponent ,進而進行子元素的更新,於是就形成了遞歸更新、遞歸diff.因此,整個流程就像這樣( 矢量圖) :

blank

這種更新完一級、diff完一級再進入下一級的過程保證React 只遍歷一次組件樹就能完成更新, 但代價就是只要前後render 出元素的type 和key 有一個不同就刪除重造,因此, React 建議頁面要盡量保持穩定的結構.

React 是如何對比出頁面變化最小的部分?

你可能會說React 用virtual DOM 表示了頁面結構, 每次更新, React 都會re-render出新的virtual DOM, 再通過diff 算法對比出前後變化, 最後批量更新. 沒錯, 很好, 這就是大致過程, 但這裡存在著一些隱藏的深層問題值得探討:

  • React 是如何用virtual DOM 表示了頁面結構, 從而使任何頁面變化都能被diff 出來?
  • React 是如何diff 出頁面變化最小的部分?

React 如何表示頁面結構

classCextendsReact.Component{render(){return(<divclassName='container'>"dscsdcsd"<ionClick={(e)=>console.log(e)}>{this.state.val}</i><Childrenval={this.state.val}/></div>)}}// virtual DOM(React element){$$typeof:Symbol(react.element)key:nullprops:{// props代表元素上的所有屬性,有children屬性,描述子組件,同樣是元素children:[""dscsdcsd"",{$$typeof:Symbol(react.element),type:"i",key:null,ref:null,props:{},},{$$typeof:Symbol(react.element),type:classChildren,props:{},}]className:'container'}ref:nulltype:"div"_owner:ReactCompositeComponentWrapper{...}// class C實例化後的對象_store:{validated:false}_self:null_source:null}

每個標籤, 無論是DOM元素還是自定義組件, 都會有key、type、props、ref 等屬性.

  • key 代表元素唯一id值, 意味著只要id改變, 就算前後元素種類相同, 元素也肯定不一樣了;
  • type 代表元素種類, 有function(空的wrapper)、class(自定義類)、string(具體的DOM元素名稱)類型, 與key一樣, 只要改變, 元素肯定不一樣;
  • props 是元素的屬性, 任何寫在標籤上的屬性(如className='container')都會被存在這裡, 如果這個元素有子元素(包括文本內容), props就會有children屬性, 存儲子元素; children屬性是遞歸插入、遞歸更新的依據;

也就是說, 如果元素唯一標識符或者類別或者屬性有變化, 那麼它們re-render後對應的key、type 和props裡面的屬性也會改變, 前後一對比即可找出變化. 綜上來看, React這麼表示頁面結構確實能夠反映前後所有變化.

那麼React 是如何diff 的?

React diff 每次只對同一層級的節點進行比對:

blank

上圖的數字表示遍歷更新的次序.

從父節點開始, 每一層diff 包括兩個地方

  • element diff—— 前後render 出來的element 的對比, 這個對比是為了找出前後節點是不是同一節點, 會對比前後render出來的元素它們的key 和type. element diff 包括兩個地方, 組件頂層DOM元素對比和子元素的對比:

組件頂層DOM元素對比:

// ReactCompositeComponent.js/updateComponent => _updateRenderedComponent_updateRenderedComponent:function(transaction,context){// re-render出elementvarnextRenderedElement=this._renderValidatedComponent();//對比前後變化if(shouldUpdateReactComponent(prevRenderedElement,nextRenderedElement)){//如果key && type沒變進行下一級更新ReactReconciler.receiveComponent(...);}else{//如果變了移除重造ReactReconciler.unmountComponent(prevComponentInstance,false);...varchild=this._instantiateReactComponent(...);varnextMarkup=ReactReconciler.mountComponent(...);this._replaceNodeWithMarkup(...);}}

子元素的對比:

// ReactChildReconciler.js updateChildren: function(...) { ... for (name in nextChildren) { // 遍历re-render 出的elements ... if ( prevChild != null && shouldUpdateReactComponent(prevElement, nextElement) ) { // 如果key && type 没变进行下一级更新ReactReconciler.receiveComponent(...); nextChildren[name] = prevChild; // 更新完放入nextChildren, 注意放入的是组件实例} else { // 如果变了则移除重建if (prevChild) { removedNodes[name] = ReactReconciler.getHostNode(prevChild); ReactReconciler.unmountComponent(prevChild, false); } var nextChildInstance = instantiateReactComponent(nextElement, true); nextChildren[name] = nextChildInstance; var nextChildMountImage = ReactReconciler.mountComponent(...); mountImages.push(nextChildMountImage); } } // 再除掉prevChildren 里有, nextChildren 里没有的组件for (name in prevChildren) { if ( prevChildren.hasOwnProperty(name) && !(nextChildren && nextChildren.hasOwnProperty(name)) ) { prevChild = prevChildren[name]; removedNodes[name] = ReactReconciler.getHostNode(prevChild); ReactReconciler.unmountComponent(prevChild, false); } } },

shouldComponentUpdate 函數:

function shouldUpdateReactComponent(prevElement, nextElement) { var prevEmpty = prevElement === null || prevElement === false; var nextEmpty = nextElement === null || nextElement === false; if (prevEmpty || nextEmpty) { return prevEmpty === nextEmpty; } var prevType = typeof prevElement; var nextType = typeof nextElement; // 如果前后变化都是字符串、数字类型的则允许更新if (prevType === 'string' || prevType === 'number') { return nextType === 'string' || nextType === 'number'; } else { // 否则检查type && key return ( nextType === 'object' && prevElement.type === nextElement.type && prevElement.key === nextElement.key ); } }

element diff 檢測type && key 都沒變時會進入下一級更新, 如果變化則直接移除重造新元素, 然後遍歷同級的下一個.

  • subtree diff ——組件頂層DOM元素包裹的所有子元素(也就是props.children裡的元素)與之前版本的對比, 這個對比是為了找出同級所有子節點的變化, 包括移除、新建、同級範圍的移動;
// ReactMultiChild.js _updateChildren: function(...) { var prevChildren = this._renderedChildren; var removedNodes = {}; var mountImages = []; // 拿到更新后子组件实例var nextChildren = this._reconcilerUpdateChildren(); ... // 遍历子组件实例for (name in nextChildren) { ... var prevChild = prevChildren && prevChildren[name]; var nextChild = nextChildren[name]; // 因为子组件的更新是在原组件实例上更改的, 因此与之前的组件作引用比较即可判断if (prevChild === nextChild) { // 发生了移动updates = enqueue( updates, this.moveChild(prevChild, lastPlacedNode, nextIndex, lastIndex), ); lastIndex = Math.max(prevChild._mountIndex, lastIndex); prevChild._mountIndex = nextIndex; } else { ... // 有新的组件updates = enqueue( updates, this._mountChildAtIndex( nextChild, mountImages[nextMountIndex], lastPlacedNode, nextIndex, transaction, context, ), ); nextMountIndex++; } nextIndex++; lastPlacedNode = ReactReconciler.getHostNode(nextChild); } // Remove children that are no longer present. for (name in removedNodes) { // removedNodes 记录了所有的移除节点if (removedNodes.hasOwnProperty(name)) { updates = enqueue( updates, this._unmountChild(prevChildren[name], removedNodes[name]), ); } } if (updates) { processQueue(this, updates); // 批量处理} this._renderedChildren = nextChildren; },

React會將同一層級的變化標記,如MOVE EXISTING、REMOVE NODE、TEXT CONTENT、INSERT MARKUP等,統一放到updates數組中然後批量處理.

And that's it !

React 是一個激動人心的庫, 它給我們帶來了前所未有的開發體驗, 但當我們沉浸在使用React 快速實現需求的喜悅中時, 有必要去探究兩個問題: Why and How?

為什麼React 會如此流行, 原因是什麼? 組件化、快速、足夠簡單、all in js、容易擴展、生態豐富、社區強大...

React 反映了哪些思想/理念/思路? 狀態機、webComponents、virtual DOM、virtual stack、異步渲染、多端渲染、單向數據流、反應式更新、函數式編程...

React 這些理念/思路受什麼啟發? 怎麼想到的? 又怎麼實現的? ...

透過現像看本質, 我們能獲得比應用React 實現需求更有意義的知識.

未完待續....

What do you think?

Written by marketer

blank

基於React 的高質量坦克大戰復刻版

blank

[譯]Passport.js 文檔——一般原則