前言
react/redux 資料流已經是一個老生常談的問題了,似乎現在談已經失去了新鮮感。然而,深入了解 react/redux 資料流應該是一個專業 react 前端需要完全掌握的技能,如果未能充分了解,那麼很多情況下,你并不知道你開發的應用是如何工作的,這很容易帶來問題,進而影響項目的持續發展和可維護性。另一方面,随着 react hooks 和 react-redux 7.x 的釋出,在資料流方面又出現了一些新的知識點。react-redux 7.x 全面擁抱了 hooks,并且重新回到了基于 Subscriptions 的實作。這使得 react-redux 7.x 徹底解決了 6.x 的性能問題,甚至是所有 react-redux 版本中性能最好的。是以,是時候重新研究 react/redux 資料流,并基于其對我們應用的性能進行優化了。
react 資料流和渲染機制
基本 re-render 機制
- setState 會觸發元件 re-render,而父元件的 re-render 會導緻所有子元件 re-render(其實這裡是一般情況,下方将說明不 re-render 的情況),除非子元件用了 shouldComponentUpdate 之類的優化。但是,有一種情況例外,如果子元件是 this.props.children, 父元件 re-render 不會導緻子元件 re-render, 具體原因看下方: https://stackoverflow.com/questions/47567429/this-props-children-not-re-rendered-on-parent-state-change
- 參考: https://github.com/facebook/react/issues/3226
- 看下方代碼,實際上,this.props.children 這個 react element 的引用并沒有變化,是以它不會導緻 re-render
<div onClick={() => this.setState({})}>
{this.props.children}
</div>
// is equal to
React.createElement(
'div',
{onClick: () => this.setState({})},
this.props.children
)
- 還有一種情況,Context api 導緻的 re-render
深入了解 react/redux 資料流并基于其優化前端性能
不 re-render 的情況
If React sees the exact same element reference as last time, it bails out of re-renderin__g that child
在上方的例子中,其實已經提到了這條準則,我們來看一個 react-redux 中的實際例子:
const renderedChild = useMemo(() => {
if (shouldHandleStateChanges) {
return (
<ContextToUse.Provider value={overriddenContextValue}>
{renderedWrappedComponent}
</ContextToUse.Provider>
)
}
return renderedWrappedComponent
}, [ContextToUse, renderedWrappedComponent, overriddenContextValue])
return renderedChild
利用 useMemo,每次 store 中資料變化時,connect 了 store 的元件首先會計算 ContextToUse、renderedWrappedComponent、overriddenContextValue,看它們是否變了,如果沒變,那麼還是取上次渲染的 react element 的 reference,react 發現 react element 的 reference 沒變,那麼就不會對該元件進行 re-render。
- PureComponent
- React.memo
- shouldComponentUpdate
- React.useMemo
children 相關的 re-render 機制
元件内渲染 this.props.children 不 re-render
如前所述,如果子元件是 this.props.children, 父元件 re-render 不會導緻子元件 re-render
- 具體原因分析:
- 代碼例子: https://codesandbox.io/s/use-children-to-avoid-re-render-gyutp 上方已做分析。
children 屬性變化導緻 re-render
react 元件每次 re-render,如果它 render 中渲染的A元件包含有子元件,A元件的 children props 就會變化,進而導緻 A 元件即使用 React.memo 優化後也會 re-render,例子:
https://codesandbox.io/s/children-diff-re-render-mxx5gconst MainLayout = React.memo(props => {
console.log("------render in MainLayout------");
const prev = usePrevious(props);
if (prev.children !== props.children) {
console.log("children changed");
}
return (
<div>
<div>======== Main Layout =======</div>
{props.children}
</div>
);
});
原因,和上方解釋的原因相同,看非 jsx 的 react 代碼:
class Hello extends React.Component {
render() {
return React.createElement("div", null, [
React.createElement("div", null, "i am a div")
]);
}
}
react key 和 diff 機制
- Tree Diff 政策
- 對樹進行分層比較(dom 跨層級移動操作特别少)。
- 對于同一層級的一組節點,它們可以通過唯一 id 進行區分。
- 擁有相同類的兩個元件将生成相似的樹形結構,擁有不同類的兩個元件将生成不同的樹形結構。
React 隻會簡單的考慮同層級節點的位置變換,而對于不同層級的節點,隻有建立和删除操作。
React 官方建議不要進行 DOM 節點跨層級的操作。
在開發元件時,保持穩定的 DOM 結構會有助于性能的提升。例如,可以通過 CSS 隐藏或顯示節點,而不是真的移除或添加 DOM 節點。
- Component Diff 政策
- 如果是同一類型的元件,按照原政策繼續比較 virtual DOM tree。
- 如果不是,則将該元件判斷為 dirty component,進而替換整個元件下的所有子節點。
- 對于同一類型的元件,有可能其 Virtual DOM 沒有任何變化,如果能夠确切的知道這點那可以節省大量的 diff 運算時間,是以 React 允許使用者通過 shouldComponentUpdate() 來判斷該元件是否需要進行 diff。
- element diff 政策
- 當節點處于同一層級時,React diff 提供了三種節點操作,分别為:INSERT_MARKUP(插入)、MOVE_EXISTING(移動)和 REMOVE_NODE(删除)。
- React 通過設定唯一 key的政策,對 element diff 進行算法優化,避免頻繁的删除和插入操作
react-redux 資料流
基本資料流實作分析
- 注冊監聽,訂閱事件 (在 create store & Provider 中)
- create store -> new Subscription in Provider -> subscription.onStateChange = subscription.notifyNestedSubs -> subscription subscribe onStateChange listener to store,and set listeners (this.listeners = createListenerCollection())
- 釋出事件
- dispatch -> call store listeners -> call subscription.onStateChange (equal to subscription.notifyNestedSubs) ->
- Connect
- 訂閱到最近 connect 的祖先元件或者 store 中,當被訂閱者 notifyNestedSubs 時,将觸發 checkForUpdates
- 自上而下,基于元件層級的層層訂閱邏輯
- subscription.onStateChange = checkForUpdates
- subscription.trySubscribe()
- 注意下方的:this.parentSub.addNestedSub,将訂閱到最近的 connect 祖先元件的 subscription 中或者訂閱到 store 中
- Subscription
trySubscribe() {
if (!this.unsubscribe) {
this.unsubscribe = this.parentSub
? this.parentSub.addNestedSub(this.handleChangeWrapper)
: this.store.subscribe(this.handleChangeWrapper)
this.listeners = createListenerCollection()
}
}
Store
基本的 Store 模型:
function createStore(reducer) {
var state;
var listeners = []
function getState() {
return state
}
function subscribe(listener) {
listeners.push(listener)
return function unsubscribe() {
var index = listeners.indexOf(listener)
listeners.splice(index, 1)
}
}
function dispatch(action) {
state = reducer(state, action)
listeners.forEach(listener => listener())
}
dispatch({})
return { dispatch, subscribe, getState }
}
通過 subscirbe 方法注冊監聽,在 dispatch 時,直接觸發所有監聽器。處理元件是否真的需要更新的邏輯不在 store 中。
Provider
function Provider({ store, context, children }) {
const contextValue = useMemo(() => {
const subscription = new Subscription(store)
subscription.onStateChange = subscription.notifyNestedSubs
return {
store,
subscription
}
}, [store])
const previousState = useMemo(() => store.getState(), [store])
useEffect(() => {
const { subscription } = contextValue
subscription.trySubscribe()
if (previousState !== store.getState()) {
subscription.notifyNestedSubs()
}
return () => {
subscription.tryUnsubscribe()
subscription.onStateChange = null
}
}, [contextValue, previousState])
const Context = context || ReactReduxContext
return <Context.Provider value={contextValue}>{children}</Context.Provider>
}
從以上代碼可以看到,Provider 是對 react Context.Provider 的封裝。Context.Provider 中的值 contextValue 為:
{
store,
subscription,
}
可以看到,當 store 中的 state change 的時候,将觸發 notifyNestedSubs。但是,這些 nestedSubs 從哪裡來呢?答案是下層元件在 Connect 時,在最近 connect 的祖先元件的 subscription 或者 store 中 addNestedSub。這裡的邏輯即是上方提到的自上而下,基于元件層級的層層訂閱邏輯。
下方是 Connect 實作的僞代碼,并非真實實作:
function connect(mapStateToProps, mapDispatchToProps) {
// It lets us inject component as the last step so people can use it as a decorator.
// Generally you don't need to worry about it.
return function (WrappedComponent) {
// It returns a component
return class extends React.Component {
render() {
return (
// that renders your component
<WrappedComponent
{/* with its props */}
{...this.props}
{/* and additional props calculated from Redux store */}
{...mapStateToProps(store.getState(), this.props)}
{...mapDispatchToProps(store.dispatch, this.props)}
/>
)
}
componentDidMount() {
// it remembers to subscribe to the store so it doesn't miss updates
this.unsubscribe = store.subscribe(this.handleChange.bind(this))
}
componentWillUnmount() {
// and unsubscribe later
this.unsubscribe()
}
handleChange() {
// and whenever the store state changes, it re-renders.
this.forceUpdate()
}
}
}
}
子在上方的簡單模型中,在 store 中注冊監聽到 store 變化的回調函數,當 store 值變化時,将觸發注冊元件的 forceUpdate 方法,進而 re-render 元件。實際的實作中比這要複雜得多,也不是這種實作方式。不過,最基本的模型是如此的。這裡涉及到一個問題,如何将 store 傳遞給對應的元件,前面的代碼中其實已經看到,可以通過 Context 将 store 傳遞過去。
在實際實作中,要複雜得多,下方是實際 connect 函數,而 connect 大部分實作邏輯都封裝在了 connectAdvanced 中。
function createConnect({
connectHOC = connectAdvanced,
mapStateToPropsFactories = defaultMapStateToPropsFactories,
mapDispatchToPropsFactories = defaultMapDispatchToPropsFactories,
mergePropsFactories = defaultMergePropsFactories,
selectorFactory = defaultSelectorFactory
} = {}) {
return function connect(
mapStateToProps,
mapDispatchToProps,
mergeProps,
{
pure = true,
areStatesEqual = strictEqual,
areOwnPropsEqual = shallowEqual,
areStatePropsEqual = shallowEqual,
areMergedPropsEqual = shallowEqual,
...extraOptions
} = {}
) {
const initMapStateToProps = match(
mapStateToProps,
mapStateToPropsFactories,
'mapStateToProps'
)
const initMapDispatchToProps = match(
mapDispatchToProps,
mapDispatchToPropsFactories,
'mapDispatchToProps'
)
const initMergeProps = match(mergeProps, mergePropsFactories, 'mergeProps')
return connectHOC(selectorFactory, {
// used in error messages
methodName: 'connect',
// used to compute Connect's displayName from the wrapped component's displayName.
getDisplayName: name => `Connect(${name})`,
// if mapStateToProps is falsy, the Connect component doesn't subscribe to store state changes
shouldHandleStateChanges: Boolean(mapStateToProps),
// passed through to selectorFactory
initMapStateToProps,
initMapDispatchToProps,
initMergeProps,
pure,
areStatesEqual,
areOwnPropsEqual,
areStatePropsEqual,
areMergedPropsEqual,
// any extra options args can override defaults of connect or connectAdvanced
...extraOptions
})
}
}
性能優化實作
性能優化實作是 react-redux 的關鍵部分。我們都可以自己實作一個類似于 eventEmitter 的釋出訂閱類,并基于其解決 react 應用的釋出訂閱更新元件邏輯。然而,如果我們都自己寫,那麼需要自己解決性能、開發規範、各種邊界情況等等各種問題。而 react-redux 存在的意義就是把這些問題統一解決了,它是社群開發者們花了大量時間的結晶,讓我們無需再去擔心那些問題,而是可以放心地使用其提供的 API。在其實作中,下面3個問題是我們需要關注的。
1. 每次 store
變動,都會觸發根元件 setState
進而導緻 re-render
。我們知道當父元件 re-render
後一定會導緻子元件 re-render
。然而,引入 react-redux 并沒有這個副作用,這是如何處理的?
store
setState
re-render
re-render
re-render
其實在 react-redux v6 中,需要關心這個問題,在 v6 中,每次 store 的變化會觸發根元件的 re-render。但是根元件的子元件不應該 re-render。其實是用到了我們上文中提到的 this.props.children。避免了子元件 re-render。在 v7 中,其實不存在該問題,store 中值的變化不會觸發根元件 Provider 的 re-render。
2. 不同的子元件,需要的隻是 store
上的一部分資料,如何在 store
發生變化後,僅僅影響那些用到 store 變化部分 state 的元件?
store
store
首先,我們抛開 store,對于父元件傳入的 props,基于 React.memo,隻在 connect 後的元件被傳入的 props 真的改變後才會重新擷取元件,否則直接使用 memo 的元元件,無需任何 react 相關的渲染計算:
const Connect = pure ? React.memo(ConnectFunction) : ConnectFunction
ConnectFunction 是原元件被 connect 後傳回的新元件,它的 props 來自于父元件的傳入。是以,如果父元件傳入的 props 沒有變,那麼 connect 後的元件将不會被父元件觸發 re-render。相當于是 PureComponent。
那麼 dispatch 導緻的 store 中 state 的變化是如何影響 connect 後的元件的 re-render 的呢?被 connect 的元件最終是否要 re-render,取決于被 connect 元件被傳入的 props 是否變了。是以,關鍵點在于計算被 connect 元件最終被傳入的 props。關鍵函數為:
function createChildSelector(store) {
return selectorFactory(store.dispatch, selectorFactoryOptions)
}
const childPropsSelector = useMemo(() => {
return createChildSelector(store)
}, [store])
// 非 store 更新導緻的重新計算
const actualChildProps = usePureOnlyMemo(() => {
if (
childPropsFromStoreUpdate.current &&
wrapperProps === lastWrapperProps.current
) {
return childPropsFromStoreUpdate.current
}
return childPropsSelector(store.getState(), wrapperProps)
}, [store, previousStateUpdateResult, wrapperProps])
// store 更新導緻的重新計算
newChildProps = childPropsSelector(
latestStoreState,
lastWrapperProps.current
)
關鍵點就在于 selectorFactory 的實作:
export function pureFinalPropsSelectorFactory(
mapStateToProps,
mapDispatchToProps,
mergeProps,
dispatch,
{ areStatesEqual, areOwnPropsEqual, areStatePropsEqual }
) {
let hasRunAtLeastOnce = false
let state
let ownProps
let stateProps
let dispatchProps
let mergedProps
function handleFirstCall(firstState, firstOwnProps) {...}
function handleNewPropsAndNewState() {
stateProps = mapStateToProps(state, ownProps)
if (mapDispatchToProps.dependsOnOwnProps)
dispatchProps = mapDispatchToProps(dispatch, ownProps)
mergedProps = mergeProps(stateProps, dispatchProps, ownProps)
return mergedProps
}
function handleNewProps() {...}
function handleNewState() {...}
function handleSubsequentCalls(nextState, nextOwnProps) {
const propsChanged = !areOwnPropsEqual(nextOwnProps, ownProps)
const stateChanged = !areStatesEqual(nextState, state)
state = nextState
ownProps = nextOwnProps
if (propsChanged && stateChanged) return handleNewPropsAndNewState()
if (propsChanged) return handleNewProps()
if (stateChanged) return handleNewState()
return mergedProps
}
return function pureFinalPropsSelector(nextState, nextOwnProps) {
return hasRunAtLeastOnce
? handleSubsequentCalls(nextState, nextOwnProps)
: handleFirstCall(nextState, nextOwnProps)
}
}
export default function finalPropsSelectorFactory(
dispatch,
{ initMapStateToProps, initMapDispatchToProps, initMergeProps, ...options }
) {
const mapStateToProps = initMapStateToProps(dispatch, options)
const mapDispatchToProps = initMapDispatchToProps(dispatch, options)
const mergeProps = initMergeProps(dispatch, options)
if (process.env.NODE_ENV !== 'production') {
verifySubselectors(
mapStateToProps,
mapDispatchToProps,
mergeProps,
options.displayName
)
}
const selectorFactory = options.pure
? pureFinalPropsSelectorFactory
: impureFinalPropsSelectorFactory
return selectorFactory(
mapStateToProps,
mapDispatchToProps,
mergeProps,
dispatch,
options
)
}
可以看到,最終通過 mapStateToProps、mapDispatchToProps、mergeProps 計算得到最終的 props。
根據 props 是否變了,決定是否要觸發元件 re-render。是以,在寫 mapStateToProps 時,一定要按需從 store 的 state 中取需要的 state map 到 props 中,否則每次 dispatch 都可能導緻 re-render。
if (newChildProps === lastChildProps.current) {
// 直接通知下層 connect 的元件去執行訂閱事件,本元件不觸發 re-render
if (!renderIsScheduled.current) {
notifyNestedSubs()
}
} else {
lastChildProps.current = newChildProps
childPropsFromStoreUpdate.current = newChildProps
renderIsScheduled.current = true
// 通過 setState 來 re-render
forceComponentUpdateDispatch({
type: 'STORE_UPDATED',
payload: {
error
}
})
}
const renderedWrappedComponent = useMemo(
() => <WrappedComponent {...actualChildProps} ref={forwardedRef} />,
[forwardedRef, WrappedComponent, actualChildProps]
)
對于被 connect wrapped 的元件,需要在 forwardedRef, WrappedComponent, actualChildProps 有更新時,才會重新計算觸發 re-render,這樣又能對性能進一步優化。
const renderedChild = useMemo(() => {
if (shouldHandleStateChanges) {
return (
<ContextToUse.Provider value={overriddenContextValue}>
{renderedWrappedComponent}
</ContextToUse.Provider>
)
}
return renderedWrappedComponent
}, [ContextToUse, renderedWrappedComponent, overriddenContextValue])
return renderedChild
最後是前面提過的,最終渲染的 Child,也用 useMemo 進行性能優化。
3. 如何保障 connect 後的元件在 store 變化時,按元件層級順序渲染?
react-redux 實作了自上而下的訂閱邏輯。處于低層的元件會訂閱到最近的 connect 了 store 的父元件 Subscriptions 執行個體。而當觸發 re-render 時,父元件将先被觸發,如果需要 re-render,将先觸發父元件的 setState(useReducer),之後,當父元件渲染完成才在 useLayoutEffect 中觸發子元件的回調事件。如果父元件不需要 re-render,那麼直接觸發子元件回調事件。這樣,保證了元件是按層級順序渲染的。
Components higher in the tree always subscribe to the store before their children do

代碼:
// 被 connect 的元件自己 new 一個 subscription
const [subscription, notifyNestedSubs] = useMemo(() => {
if (!shouldHandleStateChanges) return NO_SUBSCRIPTION_ARRAY
const subscription = new Subscription(
store,
didStoreComeFromProps ? null : contextValue.subscription
)
const notifyNestedSubs = subscription.notifyNestedSubs.bind(
subscription
)
return [subscription, notifyNestedSubs]
}, [store, didStoreComeFromProps, contextValue])
// 替換掉原先的 subscription,用該元件自己 new 出來的 subscription
const overriddenContextValue = useMemo(() => {
if (didStoreComeFromProps) {
return contextValue
}
return {
...contextValue,
subscription
}
}, [didStoreComeFromProps, contextValue, subscription])
// 如果 childProps 沒變,該元件不觸發 re-render,直接通知下層 connect 元件
if (newChildProps === lastChildProps.current) {
if (!renderIsScheduled.current) {
notifyNestedSubs()
}
} else {
lastChildProps.current = newChildProps
childPropsFromStoreUpdate.current = newChildProps
renderIsScheduled.current = true
forceComponentUpdateDispatch({
type: 'STORE_UPDATED',
payload: {
error
}
})
}
// 注冊到最近的 connect 祖先元件的 subscription 中或 store 中
subscription.onStateChange = checkForUpdates
subscription.trySubscribe()
// 在 connect 後的元件添加 ContextToUse.Provider,以及更新 context 的 value 值,
// 使得下層元件從它這裡擷取 context,進而使得下層元件的事件訂閱到該元件的 subscription 中
const renderedChild = useMemo(() => {
if (shouldHandleStateChanges) {
return (
<ContextToUse.Provider value={overriddenContextValue}>
{renderedWrappedComponent}
</ContextToUse.Provider>
)
}
return renderedWrappedComponent
}, [ContextToUse, renderedWrappedComponent, overriddenContextValue])
return renderedChild
react-redux 5、6、7 資料流對比
詳細對比見
https://github.com/reduxjs/react-redux/issues/1177#issuecomment-468959889。這裡不重複了。這個例子有助于我們了解嵌套的 connect 後的元件的 re-render 的詳細流程。
dva 對 redux 資料流的封裝
loading state 的添加
antd 的 CongfigProvider

antd 的 ConfigProvider 是基于 react context api 的封裝,在其實作中,每次 ConfigProvider re-render 會導緻 context provider 中的 value 變化,進而導緻訂閱了 ConfigProvider 的元件都 re-render。
context 導緻 re-render 對比例子:
bug:
https://codesandbox.io/s/unstated-next-with-memo-9k4rbfix:
https://codesandbox.io/s/unstated-next-with-memo-fix-stpjw性能優化
- Context api 的性能其實比 redux 要差,是以 ConfigProvider 的優化也是十分要緊的,将 antd 的 ConfigProvider 往上層提,避免元件 re-render 導緻 ConfigProvider re-render 進而導緻所有訂閱了 ConfigProvider 的 antd 元件 re-render
- redux 很快,但是不恰當使用,将使你的應用非常的慢: https://react-redux.js.org/api/connect
深入了解 react/redux 資料流并基于其優化前端性能 - connect 包裝後的元件相當于 PureComponent,此時父元件傳 props 給 connect 後的子元件時,盡量不要傳子元件不會用到的屬性,因為如果這些屬性變了也會導緻子元件的 re-render,而如果不傳不必要的屬性,則能避免這些不必要的 re-render
- 恰當地使用 PureComponent 或 React.memo,避免不必要的 re-render,優化性能。但是,如果你确定你的元件在每次父元件 re-render 或者元件自身調用 setState 時都要 re-render,此時可以不用 PureComponent 或 React.memo,在一些情況下 PureComponent 可能會引起不想要的結果
- 不需要修改全局 store 的操作,例如發送資料請求給該元件自己用,可以不調用 dispatch,徹底避免掉 redux 的工作,這裡涉及到 dva 對 dispatch 請求的封裝,會修改 store 中的 loading state,導緻訂閱了該屬性的元件被 re-render
- React 官方建議不要進行 DOM 節點跨層級的操作。在開發元件時,保持穩定的 DOM 結構會有助于性能的提升。例如,可以通過 CSS 隐藏或顯示節點,而不是真的移除或添加 DOM 節點。
參考文獻
- react re-render 機制參考:
- 子元件不 re-render 原因:
- react-redux 6.x 的源碼分析: https://caelumtian.github.io/2019/02/01/redux%E6%BA%90%E7%A0%81%E7%90%86%E8%A7%A32/
- react-redux-history-implementation: https://blog.isquaredsoftware.com/2018/11/react-redux-history-implementation/
- react-redux v5、v6、v7 資料流分析:
- 優化 useContext 性能: https://github.com/facebook/react/issues/15156