寫在前面
react-redux 作為膠水一樣的東西,似乎沒有深入了解的必要,但實際上,作為數據層(redux)與 UI 層(react)的連接處,其實現細節對整體性能有著決定性的影響。組件樹胡亂 update 的成本,要比多跑幾遍 reducer 樹的成本高得多,所以有必要了解其實現細節。
仔細了解 react-redux 的好處之一是可以對性能有基本的認識,考慮一個問題:
dispatch({type: 'UPDATE_MY_DATA', payload: myData})
組件樹中某個角落的這行代碼,帶來的性能影響是什麼?幾個子問題:
-
1.導致哪些 reducer 被重新計算了?
-
2.引發的視圖更新從哪個組件開始?
-
3.哪些組件的 render 被調用了?
-
4.每個葉子組件都被 diff 波及了嗎?為什麼?
如果無法準確回答這幾個問題,對性能肯定是心裡沒底的。
一.作用
首先,明確 redux 只是一個數據層,而 react 只是一個 UI 層,二者之間是沒有聯繫的。
如果左右手分別拿著 redux 和 react,那麼實際情況應該是這樣的:
-
redux 把數據結構(state)及各字段的計算方式(reducer)都定好了
-
react 根據視圖描述(Component)把初始頁面渲染出來
可能是這個樣子:
redux | react
myUniversalState | myGreatUI
human | noOneIsHere
soldier |
arm |
littleGirl |
toy |
ape | noOneIsHere
hoho |
tree | someTrees
mountain | someMountains
snow | flyingSnow
左邊 redux 裡什麼都有,但是 react 不知道,只顯示了默認元素(沒有沒有數據),有一些組件局部 state 和零散的 props 傳遞,頁面就像一幀靜態圖,組件樹看起來只是由一些管道連接起來的大架子。
現在我們考慮把 react-redux 加進來,那麼就會變成這個樣子:
react-redux
redux -+- react
myUniversalState | myGreatUI
HumanContainer
human -+- humans
soldier | soldiers
ArmContainer
arm -+- arm
littleGirl | littleGirl
toy | toy
ApeContainer
ape -+- apes
hoho | hoho
SceneContainer
tree -+- Scene
mountain | someTrees
snow | someMountains
flyingSnow
注意,Arm 交互比較複雜,不適合由上層(HumanContainer)控制,所以出現了嵌套 Container。
Container 把 redux 手裡的 state 交給 react,這樣初始數據就有了,那麼如果要更新視圖呢?
Arm.dispatch({type: 'FIRST_BLOOD', payload: warData})
有人打響了第一槍,導致 soldier 掛了一個(state change),那麼這些部分要發生變化:
react-redux
redux -+- react
myNewUniversalState | myUpdatedGreatUI
HumanContainer
human -+- humans
soldier | soldiers
| diedSoldier
ArmContainer
arm -+- arm
| inactiveArm
頁面上出現一個掛掉的 soldier 和一支掉在地上的 arm(update view),其它部分(ape, scene)一切安好。
上面描述的就是 react-redux 的作用:
-
把 state 從 redux 傳遞到 react
-
並負責在 redux state change 後 update react view
那麼猜也知道,實現分為 3 部分:
-
給管道連接起來的大架子添上一個個小水源(通過 Container 把 state 作為 props 注入下方 view)
-
讓小水源冒水(監聽 state change,通過 Container 的 setState 來更新下方 view)
-
不小水源不要亂冒(內置性能優化,對比緩存的 state, props 看有沒有必要更新)
二.關鍵實現
原始碼關鍵部分如下:
// from: src/components/connectAdvanced/Connect.onStateChange
onStateChange() {
// state change 時重新計算 props
this.selector.run(this.props)
// 當前組件不用更新的話,通知下方 container 檢查更新
// 要更新的話,setState 空對象強制更新,延後通知到 didUpdate
if (!this.selector.shouldComponentUpdate) {
this.notifyNestedSubs()
} else {
this.componentDidUpdate = this.notifyNestedSubsOnComponentDidUpdate
// 通知 Container 下方的 view 更新
//!!! 這裡是把 redux 與 react 連接起來的關鍵
this.setState(dummyState)
}
}
最重要的那個setState就在這裡,dispatch action 後視圖更新的秘密是這樣:
1.dispatch action
2.redux 計算 reducer 得到 newState
3.redux 觸發 state change(調用之前通過 store.subscribe 註冊的 state 變化監聽器)
4.react-redux 頂層 Container 的 onStateChange 觸發
1.重新計算 props
2.比較新值和緩存值,看 props 變了沒,要不要更新
3.要的話通過 setState({}) 強制 react 更新
4.通知下方的 subscription,觸發下方關注 state change 的 Container 的 onStateChange,檢查是否需要更新 view
第 3 步裡,react-redux 向 redux 註冊 store change 監聽的動作發生在connect()(myComponent)時,事實上 react-redux 只對頂層 Container 直接監聽了 redux 的 state change,下層 Container 都是內部傳遞通知的,如下:
// from: src/utils/Subscription/Subscription.trySubscribe
trySubscribe() {
if (!this.unsubscribe) {
// 沒有父級觀察者的話,直接監聽 store change
// 有的話,添到父級下面,由父級傳遞變化
this.unsubscribe = this.parentSub
? this.parentSub.addNestedSub(this.onStateChange)
: this.store.subscribe(this.onStateChange)
}
}
這���不直接監聽 redux 的 state change,而非要自己維護 Container 的 state change listener,是為了實現次序可控,例如上面提到的:
// 要更新的話,延後通知到 didUpdate
this.componentDidUpdate = this.notifyNestedSubsOnComponentDidUpdate
這樣保證了 listener 觸發順序是按照組件樹層級順序的,先通知大子樹更新,大子樹更新完畢後,再通知小子樹更新。
更新的整個過程就是這樣,至於「通過 Container 把 state 作為 props 注入下方 view」這一步,沒什麼好說的,如下:
// from: src/components/connectAdvanced/Connect.render
render() {
return createElement(WrappedComponent, this.addExtraProps(selector.props))
}
根據 WrappedComponent 需要的 state 字段,造一份 props,通過React.createElement注入進去。ContainerInstance.setState({})時,這個render函數被重新調用,新的 props 被注入到 view,view will receive props...視圖更新就真正開始了。
三.技巧
讓純函數擁有狀態
function makeSelectorStateful(sourceSelector, store) {
// wrap the selector in an object that tracks its results between runs.
const selector = {
run: function runComponentSelector(props) {
try {
const nextProps = sourceSelector(store.getState(), props)
if (nextProps !== selector.props || selector.error) {
selector.shouldComponentUpdate = true
selector.props = nextProps
selector.error = null
}
} catch (error) {
selector.shouldComponentUpdate = true
selector.error = error
}
}
}
return selector
}
把純函數用對象包起來,就可以有局部狀態了,作用和 new Class Instance 類似。這樣就把純的部分與不純的部分分離開了,純的依然純,不純的在外面,class 不如這個乾淨。
默認參數與對象解構
function connectAdvanced(
selectorFactory,
// options object:
{
getDisplayName = name => `ConnectAdvanced(${name})`,
methodName = 'connectAdvanced',
renderCountProp = undefined,
shouldHandleStateChanges = true,
storeKey = 'store',
withRef = false,
// additional options are passed through to the selectorFactory
...connectOptions
} = {}
) {
const selectorFactoryOptions = {
// 展開 還原回去
...connectOptions,
getDisplayName,
methodName,
renderCountProp,
shouldHandleStateChanges,
storeKey,
withRef,
displayName,
wrappedComponentName,
WrappedComponent
}
}
可以簡化成這樣:
function f({a = 'a', b = 'b', ...others} = {}) {
console.log(a, b, others);
const newOpts = {
...others,
a,
b,
s: 's'
};
console.log(newOpts);
}
// test
f({a: 1, c: 2, f: 0});
// 輸出
// 1 "b" {c: 2, f: 0}
// {c: 2, f: 0, a: 1, b: "b", s: "s"}
這裡用到 3 個 es6+ 小技巧:
-
默認參數。防止解構時右邊 undefined 報錯
-
對象解構。把剩餘屬性都包進 others 對象裡
-
展開運算符。把 others 展開,屬性 merge 到目標對象上
默認參數是 es6 特性,沒什麼好說的。對象解構是 Stage 3 proposal,...others是其基本用法。展開運算符把對象展開,merge 到目標對象上,也不複雜。
比較有意思的是這裡把對象解構和展開運算符配合使用,實現了這種需要對參數做打包 - 還原的場景,如果不用這 2 個特性,可能需要這樣做:
function connectAdvanced(
selectorFactory,
connectOpts,
otherOpts
) {
const selectorFactoryOptions = extend({},
otherOpts,
getDisplayName,
methodName,
renderCountProp,
shouldHandleStateChanges,
storeKey,
withRef,
displayName,
wrappedComponentName,
WrappedComponent
)
}
需要清楚地區分connectOpts和otherOpts,實現上會麻煩一些,組合運用這些技巧的話,代碼相當簡練。
另外還有 1 個 es6+ 小技巧:
addExtraProps(props) {
//! 技巧 淺拷貝保證最少知識
//! 淺拷貝 props,不把別人不需要的東西傳遞出去,否則影響 GC
const withExtras = { ...props }
}
多一份引用就多一份記憶體洩漏的風險,不需要的不應該給(最少知識)。
參數模式匹配
function match(arg, factories, name) {
for (let i = factories.length - 1; i >= 0; i--) {
const result = factories[i](arg)
if (result) return result
}
return (dispatch, options) => {
throw new Error(`Invalid value of type ${typeof arg} for ${name} argument when connecting component ${options.wrappedComponentName}.`)
}
}
其中factories是這樣:
// mapDispatchToProps
[
whenMapDispatchToPropsIsFunction,
whenMapDispatchToPropsIsMissing,
whenMapDispatchToPropsIsObject
]
// mapStateToProps
[
whenMapStateToPropsIsFunction,
whenMapStateToPropsIsMissing
]
針對參數的各種情況建立一系列 case 函數,然後讓參數依次流經所有 case,匹配任意一個就返回其結果,都不匹配就進入錯誤 case。
類似於 switch-case,用來對參數做模式匹配,這樣各種 case 都被分解出去了,各自職責明確(各 case 函數的命名非常準確)。
懶參數
function wrapMapToPropsFunc() {
// 猜完立即算一遍 props
let props = proxy(stateOrDispatch, ownProps)
// mapToProps 支持返回 function,再猜一次
if (typeof props === 'function') {
proxy.mapToProps = props
proxy.dependsOnOwnProps = getDependsOnOwnProps(props)
props = proxy(stateOrDispatch, ownProps)
}
}
其中,懶參數是指:
// 把返回值作為參數,再算一遍 props
if (typeof props === 'function') {
proxy.mapToProps = props
proxy.dependsOnOwnProps = getDependsOnOwnProps(props)
props = proxy(stateOrDispatch, ownProps)
}
這樣實現和 react-redux 面臨的場景有關,支持返回 function 主要是為了支持組件實例級(默認是組件級)的細粒度 mapToProps 控制。這樣就能針對不同組件實例,給不同的 mapToProps,支持進一步提升性能。
從實現上來看,相當於把實際參數延後了,支持傳入一個參數工廠作為參數,第一次把外部環境傳遞給工廠,工廠再根據環境造出實際參數。添了工廠這個環節,就把控制粒度細化了一層(組件級的細化到了組件實例級,外部環境即組件實例信息)。
P.S.關於懶參數的相關討論見https://github.com/reactjs/react-redux/pull/279
四.疑問
1.默認的 props.dispatch 哪裡來的?
connect()(MyComponent)
不給 connect 傳任何參數,MyComponent 實例也能拿到一個 prop 叫dispatch,是在哪裡偷偷掛上的?
function whenMapDispatchToPropsIsMissing(mapDispatchToProps) {
return (!mapDispatchToProps)
// 就是這裡掛上去的,沒傳 mapDispatchToProps 的話,默認把 dispatch 掛到 props 上
? wrapMapToPropsConstant(dispatch => ({ dispatch }))
: undefined
}
默認內置了一個mapDispatchToProps = dispatch => ({ dispatch }),所以組件 props 身上有dispatch,如果指定了mapDispatchToProps,就不給掛了。
2.多級 Container 會不會面臨性能問題?
考慮這種場景:
App
HomeContainer
HomePage
HomePageHeader
UserContainer
UserPanel
LoginContainer
LoginButton
出現了嵌套的 container,那麼在 HomeContainer 關注的 state 發生變化時,會不會走很多遍視圖更新?比如:
HomeContainer update-didUpdate
UserContainer update-didUpdate
LoginContainer update-didUpdate
如果是這樣,輕輕一發 dispatch,導致 3 個子樹更新,感覺性能要炸了。
實際上不是這樣。對於多級 Container,走兩遍的情況確實存在,只是這裡的走兩遍不是指視圖更新,而是說 state change 通知。
上層 Container 在 didUpdate 後會通知下方 Container 檢查更新,可能會在小子樹再走一遍。但在大子樹更新的過程中,走到下方 Container 時,小子樹在這個時機就開始更新了,大子樹 didUpdate 後的通知只會讓下方 Container 空走一遍檢查,不會有實際更新。
檢查的具體成本是分別對 state 和 props 做===比較和淺層引用比較(也是先===比較),發現沒變就結束了,所以每個下層 Container 的性能成本是兩個===比較,不要緊。也就是說,不用擔心使用嵌套 Container 帶來的性能開銷。
五.原始碼分析
Github 地址:https://github.com/ayqy/react-redux-5.0.6
P.S.註釋依然足夠詳盡。
暫無評論,快來發表你的看法吧