天天看點

深入了解 react/redux 資料流并基于其優化前端性能

前言

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 機制

<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

children 屬性變化導緻 re-render

react 元件每次 re-render,如果它 render 中渲染的A元件包含有子元件,A元件的 children props 就會變化,進而導緻 A 元件即使用 React.memo 優化後也會 re-render,例子:

https://codesandbox.io/s/children-diff-re-render-mxx5g
const 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 并沒有這個副作用,這是如何處理的?

其實在 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,對于父元件傳入的 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

深入了解 react/redux 資料流并基于其優化前端性能

代碼:

// 被 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

深入了解 react/redux 資料流并基于其優化前端性能

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-9k4rb

fix:

https://codesandbox.io/s/unstated-next-with-memo-fix-stpjw

性能優化

  1. Context api 的性能其實比 redux 要差,是以 ConfigProvider 的優化也是十分要緊的,将 antd 的 ConfigProvider 往上層提,避免元件 re-render 導緻 ConfigProvider re-render 進而導緻所有訂閱了 ConfigProvider 的 antd 元件 re-render
  2. redux 很快,但是不恰當使用,将使你的應用非常的慢: https://react-redux.js.org/api/connect
    深入了解 react/redux 資料流并基于其優化前端性能
  3. connect 包裝後的元件相當于 PureComponent,此時父元件傳 props 給 connect 後的子元件時,盡量不要傳子元件不會用到的屬性,因為如果這些屬性變了也會導緻子元件的 re-render,而如果不傳不必要的屬性,則能避免這些不必要的 re-render
  4. 恰當地使用 PureComponent 或 React.memo,避免不必要的 re-render,優化性能。但是,如果你确定你的元件在每次父元件 re-render 或者元件自身調用 setState 時都要 re-render,此時可以不用 PureComponent 或 React.memo,在一些情況下 PureComponent 可能會引起不想要的結果
  5. 不需要修改全局 store 的操作,例如發送資料請求給該元件自己用,可以不調用 dispatch,徹底避免掉 redux 的工作,這裡涉及到 dva 對 dispatch 請求的封裝,會修改 store 中的 loading state,導緻訂閱了該屬性的元件被 re-render
  6. React 官方建議不要進行 DOM 節點跨層級的操作。在開發元件時,保持穩定的 DOM 結構會有助于性能的提升。例如,可以通過 CSS 隐藏或顯示節點,而不是真的移除或添加 DOM 節點。

參考文獻

繼續閱讀