深入理解 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

下方是 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 节点。

参考文献

上一篇:Linux系统:centos7下搭建Rocketmq4.3中间件,配置监控台


下一篇:Javascript 中的 this