探尋 Redux useSelector 更新機制
(給前端大全加星標(biāo),提升前端技能)
一個似乎無法實現(xiàn)的 hook 的內(nèi)部工作原理深入研究
當(dāng)一個 react context 更新的時候,所有使用到該 context 的組件也會更新。但如果每次 redux store 一有變化,所有用到 react-redux 的useSelector的組件就重新 render,這就會導(dǎo)致一個很大的性能問題。那么useSelector是如何做到的呢?
我是 React context API 的忠實粉絲,但是當(dāng) context 中的數(shù)據(jù)由兩部分組成,其中一個部分更新頻繁,而另一部分不常更新時,性能問題就出現(xiàn)了。即使我的組件只用到了不常更新的那部分數(shù)據(jù),組件還是會在另一部分數(shù)據(jù)更新的時候進行重新 render。
react-redux 的useSelector hook 就沒有這樣的問題——組件只會在其所選擇到的(selected)數(shù)據(jù)更新的情況下才會重新 render,即使在 store 中的其他數(shù)據(jù)被更新的情況下也是這樣。那么這是什么原理呢?它是以某種方式繞過了 context 的規(guī)則?
當(dāng)然,答案是否定的,但是useSelector 用到了一些有創(chuàng)意的手段達到了效果。在我們開始之前,讓我們先進步看下問題。這是一個有點牽強的例子,context 中的數(shù)據(jù)由兩部分組成,clicks 和 time :
const?initialState?=?{
??clicks:?0,
??time:?0
};
//?Create?our?context
const?MyContext?=?React.createContext(initialState);
//?Increment?either?the?click?counter?or?the?timer
const?reducer?=?(state,?action)?=>?{
??switch?(action.type)?{
????case?"CLICK":
??????return?{
????????...state,
????????clicks:?state.clicks?+?1
??????};
????case?"TIME":
??????return?{
????????...state,
????????time:?state.time?+?1
??????};
????default:
??????return?state;
??}
};
const?MyProvider?=?({?children?})?=>?{
??const?[state,?dispatch]?=?useReducer(reducer,?initialState);
??//?Kick?off?a?setInterval?that?increments?our?timer?each?second
??useEffect(()?=>?{
????const?interval?=?setInterval(()?=>?dispatch({?type:?"TIME"?}),?1000);
????return?()?=>?clearInterval(interval);
??},?[]);
??//?Memoize?our?context?value
??const?value?=?useMemo(
????()?=>?({
??????...state,
??????onClick:?()?=>?dispatch({?type:?"CLICK"?})
????}),
????[state]
??);
??return?<MyContext.Provider?value={value}>{children}MyContext.Provider>;
};
一部分的state(time)追蹤 app 打開后經(jīng)過的秒數(shù),另一部分(clicks)追蹤用戶點擊了多少次 button。我們可能在 app 中這么使用它:
const?Clicker?=?()?=>?{
??console.log("render?clicker");
??const?{?clicks,?onClick?}?=?useContext(MyContext);
??return?(
????<div>
??????<span>{`Clicks:?${clicks}`}span>
??????<button?onClick={onClick}>Click?mebutton>
????div>
??);
};
const?Timer?=?()?=>?{
??console.log("render?timer");
??const?{?time?}?=?useContext(MyContext);
??return?(
????<div>
??????<span>{`Time:?${time}`}span>
????div>
??);
};
export?default?function?App()?{
??return?(
????<MyProvider>
??????<Clicker?/>
??????<Timer?/>
????MyProvider>
??);
}
timer 每秒鐘都會遞增,click 計數(shù)器只會在 button 被點擊后遞增。然而,因為 click 組件與 timer 組件都是用到了同一個 context,所以每次 timer 遞增的時候,click 組件也會重新 render,即使click狀態(tài)沒有變化!這很不好!
比較容易讓人接受的解決方法是“把狀態(tài)分割到不同的context”,但制造這種人為的邊界感覺并不好。如果我在開發(fā)一個音頻播放器,我可不想被迫的將isPlaying和currentTime拆開,只是因為currentTime常變而isPlaying不是。
現(xiàn)在我們理解了這個問題,讓我們再來看看useSelector。如果我從頭實現(xiàn)react-redux,代碼可能會長這樣:
//?Some?method?to?return?the?initial?state?of?your?redux?store
const?initialState?=?getInitialState()?
?//?Returns?your?top-level?redux?reducer?
const?myReducer?=?getCombinedReducers()
const?MyContext?=?React.createContext(initialState)
const?MyProvider?=?({?children?})?=>?{
??const?[store,?dispatch]?=?useReducer(myReducer,?initialState)
??
??//?Memoize?our?context?value.?getState?will?be?updated?each?time?the?store?updates.
??const?value?=?useMemo(()?=>?({
????getState:?()?=>?store
??}),?[store])
???
??return?(
????<MyContext.Provider?value={value}>
??????{children}
????MyContext.Provider>
??)
}
const?useSelector?=?(selector)?=>?{
??const?store?=?useContext(MyContext)
??return?selector(store.getState())
}
雖然我的useSelector能正確的返回store中的state,但它還是有著上述的問題。每次
timer更新的時候也會重新render。那么useSelector是怎么解決這個問題的呢。
我不是react/redux的作者或者專家,但在翻看了源碼后,我認為我找到了答案:react-redux的context從沒有真正的改變。
讓我澄清下。Redux有一個一直在變化的store,但我們是通過它的getState方法來獲取store中的state的。getState方法的引用沒有變化。getState()中返回的state可能改變了,但getter方法本身在整個app的生命周期中都沒有變化。
這在react領(lǐng)域中通常是不允許的--我們一般是希望我們的組件在他們所依賴的狀態(tài)發(fā)生變化的時候能夠重新render。然而,就像我們剛才所見,這會導(dǎo)致不預(yù)期的render。
那么在不能依賴其props/context的變化的情況下,一個組件如何知道該何時更新呢。另一種設(shè)計模式被使用:訂閱(subscription)。
useSelector注冊了一個訂閱,每當(dāng)redux store更新時,該訂閱就會被調(diào)用,如果這次的更新導(dǎo)致了被選擇state的改變,就會觸發(fā)一次重新render并返回一個新值。訂閱是發(fā)生在這:
subscription.onStateChange?=?checkForUpdates
See where this happens in the react-redux code on GitHub[1]
subscription會調(diào)用checkForUpdates,它會檢查對store的更新是否導(dǎo)致對選定狀態(tài)的更改。如果狀態(tài)改變了,一次重新render就會被forceRender({})觸發(fā):
See where this happens in the react-redux code on GitHub[2]
forceRender的唯一職責(zé)就是:強制重新render。他是通過遞增一個本地狀態(tài)來實現(xiàn)的,方法有些hacky但是很簡單,每次forceRender被調(diào)用,它便會遞增一個內(nèi)部計數(shù)器,計數(shù)器本身沒有被任何地方用到,但能達到預(yù)期效果。
const?[,?forceRender]?=?useReducer(s?=>?s?+?1,?0)
See where this happens in the react-redux code on GitHub[3]
這個re-render反過來會導(dǎo)致useSelector從store中選擇出合適的狀態(tài),然后將其返回到我們的組件中:
selectedState?=?selector(store.getState())
//...
return?selectedState
See where this happens in the react-redux code on GitHub[4]
總之,設(shè)置了一個每當(dāng)redux state改變時就會被觸發(fā)的訂閱。當(dāng)訂閱被觸發(fā)的時候,他會調(diào)用每一個useSelector中傳入的方法,然后判斷改方法使用(舊)store選擇出來的屬于與使用(新)store選擇出來的數(shù)據(jù)的內(nèi)部引用是否相等。如果不等,就會強制render,同時返回新的狀態(tài)。這樣對我來說感覺并不是很“reacty”,但它確實能達到效果。
你可能已經(jīng)看出了個問題。如果我的選擇器返回的是一個多部分的組合狀態(tài),那么新創(chuàng)建的對象的引用永遠不會等于上次的引用,便又會回到過多重新render的老路上去。
useSelector(state?=>?{
??//?Each?time?this?runs?it?returns?a?brand?new?object
??return?{
????thingOne:?state.thingOne
????thingTwo:?state.thingTwo
??}
})
這個問題可以通過給useSelector傳第二個參數(shù)(相等判斷方法),如來shallowEqual,來解決。參閱 該文檔。
為了更好的衡量, 這里有個從零創(chuàng)建的,只有當(dāng)它的選擇狀態(tài)改變時才會觸發(fā)更新的,useSelectorhook 的最小實現(xiàn):
https://codesandbox.io/s/peaceful-river-dj5ps 注意:雖然實現(xiàn)自己的
useSelector是個有趣的練習(xí),但我不提倡在生產(chǎn)環(huán)境中使用
備注1:我覺得訂閱者模式不“reacty”的原因是組件不在只是其“props and state“的結(jié)果。相反的,父級context必須保留每個的useSelector實例中的selector函數(shù)引用。
備注2:我一直在用Kent C. Dodds的context模式并且非常喜歡--它對我們上述討論的問題沒有幫助,但是可以讓context的使用更友好。參考[5]
參考資料
[1]See where this happens in the react-redux code on GitHub: https://github.com/reduxjs/react-redux/blob/607f1ba30417b631a4df18665dfede416c7208cf/src/hooks/useSelector.js#L72
[2]See where this happens in the react-redux code on GitHub: https://github.com/reduxjs/react-redux/blob/607f1ba30417b631a4df18665dfede416c7208cf/src/hooks/useSelector.js#L69
[3]See where this happens in the react-redux code on GitHub: https://github.com/reduxjs/react-redux/blob/607f1ba30417b631a4df18665dfede416c7208cf/src/hooks/useSelector.js#L15
[4]See where this happens in the react-redux code on GitHub: https://github.com/reduxjs/react-redux/blob/607f1ba30417b631a4df18665dfede416c7208cf/src/hooks/useSelector.js#L33
[5]參考: https://kentcdodds.com/blog/how-to-use-react-context-effectively
- EOF -
1、一步一步教你把 Redux Saga 添加到 React&Redux 程序中
覺得本文對你有幫助?請分享給更多人
推薦關(guān)注「前端大全」,提升前端技能
點贊和在看就是最大的支持??
