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:
// 批處理策略
var ReactDefaultBatchingStrategy = {
isBatchingUpdates: false,
batchedUpdates: function(callback, a, b, c, d, e) {
var alreadyBatchingUpdates = ReactDefaultBatchingStrategy.isBatchingUpdates;
ReactDefaultBatchingStrategy.isBatchingUpdates = true; // 開啟一次batch
if (alreadyBatchingUpdates) {
return callback(a, b, c, d, e);
} else {
// 啟動事務, 將callback放進事務裡執行
return transaction.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 拿到內部例項, 也就是元件例項
var internalInstance = getInternalInstanceReadyForUpdate(publicInstance, 'setState');
// 取得元件例項的_pendingStateQueue
var queue =
internalInstance._pendingStateQueue ||
(internalInstance._pendingStateQueue = []);
// 將partial state存到_pendingStateQueue
queue.push(partialState);
// 呼叫enqueueUpdate
enqueueUpdate(internalInstance);
}
// => ReactUpdate.js:
function enqueueUpdate(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 啟動的各種事務和執行的順序:
從圖中可以看到, 批量更新是在ReactDefaultBatchingStrategyTransaction
事務的close階段, 在flushBatchedUpdates
函式中啟動了ReactUpdatesFlushTransaction
事務負責批量更新.
怎麼批量更新的?
開啟批量更新事務、批量處理callback
我們接著看flushBatchedUpdates
函式, 在ReactUpdates.js中
var flushBatchedUpdates = function () {
// 啟動批量更新事務
while (dirtyComponents.length || asapEnqueued) {
if (dirtyComponents.length) {
var transaction = ReactUpdatesFlushTransaction.getPooled();
transaction.perform(runBatchedUpdates, null, transaction);
ReactUpdatesFlushTransaction.release(transaction);
}
// 批量處理callback
if (asapEnqueued) {
asapEnqueued = false;
var queue = asapCallbackQueue;
asapCallbackQueue = CallbackQueue.getPooled();
queue.notifyAll();
CallbackQueue.release(queue);
}
}
};
複製程式碼
遍歷dirtyComponents
flushBatchedUpdates
啟動了一個更新事務, 這個事務執行了runBatchedUpdates
進行批量更新:
// ReactUpdates.js
function runBatchedUpdates(transaction) {
var len = transaction.dirtyComponentsLength;
// 排序保證父元件優先於子元件更新
dirtyComponents.sort(mountOrderComparator);
// 代表批量更新的次數, 保證每個元件只更新一次
updateBatchNumber++;
// 遍歷 dirtyComponents
for (var i = 0; i < len; i++) {
var component = dirtyComponents[i];
var callbacks = component._pendingCallbacks;
component._pendingCallbacks = null;
...
// 執行更新
ReactReconciler.performUpdateIfNecessary(
component,
transaction.reconcileTransaction,
updateBatchNumber,
);
...
// 儲存 callback以便後續按順序呼叫
if (callbacks) {
for (var j = 0; j < callbacks.length; j++) {
transaction.callbackQueue.enqueue(
callbacks[j],
component.getPublicInstance(),
);
}
}
}
}
複製程式碼
前面 setState 後將元件推入了dirtyComponents
, 現在就是要遍歷dirtyComponents
陣列進行更新了.
根據不同情況執行更新
ReactReconciler
會呼叫元件例項的performUpdateIfNecessary
. 如果接收了props, 就會呼叫此元件的receiveComponent
, 再在裡面呼叫updateComponent
更新元件; 如果沒有接受props, 但是有新的要更新的狀態(_pendingStateQueue不為空)就會直接呼叫updateComponent
來更新:
// ReactCompositeComponent.js
performUpdateIfNecessary: function (transaction) {
if (this._pendingElement != null) {
ReactReconciler.receiveComponent(this, this._pendingElement, transaction, this._context);
} else if (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 (向量圖):
updateComponent
所做的事情 :
- 呼叫此層級元件的一系列生命週期函式, 並且在合適的時機更新props、state、context;
- re-render, 與之前 render 的 element 比較, 如果兩者key && element.type 相等, 則進入下一層進行更新; 如果不等, 直接移除重新mount
對於 ReactDOMComponent:
updateComponent
所做的事情 :
- 更新這一層級DOM元素屬性;
- 更新子元素, 呼叫 ReactMultiChild 的
updateChildren
, 對比前後變化、標記變化型別、存到updates中(diff演算法主要部分); - 批量處理updates
對於 ReactDOMTextComponent :
上面只是每個元件自己更新的過程, 那麼 React 是如何一次性更新所有元件的 ? 答案是遞迴.
遞迴呼叫元件的updateComponent
觀察 ReactCompositeComponent 和 ReactDOMComponent 的更新流程, 我們發現 React 每次走到一個元件更新過程的最後部分, 都會有一個判斷 : 如果 nextELement 和 prevElement key 和 type 相等, 就會呼叫receiveComponent
. receiveComponent
和updateComponent
一樣, 每種元件都有一個, 作用就相當於updateComponent 接受了新 props 的版本. 而這裡呼叫的就是子元素的receiveComponent
, 進而進行子元素的更新, 於是就形成了遞迴更新、遞迴diff. 因此, 整個流程就像這樣(向量圖) :
這種更新完一級、diff完一級再進入下一級的過程保證 React 只遍歷一次元件樹就能完成更新, 但代價就是隻要前後 render 出元素的 type 和 key 有一個不同就刪除重造, 因此, React 建議頁面要儘量保持穩定的結構.
React 是如何對比出頁面變化最小的部分?
你可能會說 React 用 virtual DOM 表示了頁面結構, 每次更新, React 都會re-render出新的 virtual DOM, 再通過 diff 演算法對比出前後變化, 最後批量更新. 沒錯, 很好, 這就是大致過程, 但這裡存在著一些隱藏的深層問題值得探討 :
- React 是如何用 virtual DOM 表示了頁面結構, 從而使任何頁面變化都能被 diff 出來?
- React 是如何 diff 出頁面變化最小的部分?
React 如何表示頁面結構
class C extends React.Component {
render () {
return (
<div className='container'>
"dscsdcsd"
<i onClick={(e) => console.log(e)}>{this.state.val}</i>
<Children val={this.state.val}/>
</div>
)
}
}
// virtual DOM(React element)
{
$$typeof: Symbol(react.element)
key: null
props: { // props 代表元素上的所有屬性, 有children屬性, 描述子元件, 同樣是元素
children: [
""dscsdcsd"",
{$$typeof: Symbol(react.element), type: "i", key: null, ref: null, props: {…}, …},
{$$typeof: Symbol(react.element), type: class Children, props: {…}, …}
]
className: 'container'
}
ref: null
type: "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 每次只對同一層級的節點進行比對 :
上圖的數字表示遍歷更新的次序.
從父節點開始, 每一層 diff 包括兩個地方
-
element diff—— 前後 render 出來的 element 的對比, 這個對比是為了找出前後節點是不是同一節點, 會對比前後render出來的元素它們的 key 和 type. element diff 包括兩個地方, 元件頂層DOM元素對比和子元素的對比:
元件頂層DOM元素對比 :
// ReactCompositeComponent.js/updateComponent => _updateRenderedComponent _updateRenderedComponent: function(transaction, context) { // re-render 出element var nextRenderedElement = this._renderValidatedComponent(); // 對比前後變化 if (shouldUpdateReactComponent(prevRenderedElement, nextRenderedElement)) { // 如果 key && type 沒變進行下一級更新 ReactReconciler.receiveComponent(...); } else { // 如果變了移除重造 ReactReconciler.unmountComponent(prevComponentInstance, false); ... var child = this._instantiateReactComponent(...); var nextMarkup = 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 實現需求更有意義的知識.
未完待續....