Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

深入理解 react/redux 数据流并基于其优化前端性能 #36

Open
shaozj opened this issue Nov 26, 2019 · 0 comments
Open

深入理解 react/redux 数据流并基于其优化前端性能 #36

shaozj opened this issue Nov 26, 2019 · 0 comments

Comments

@shaozj
Copy link
Owner

shaozj commented Nov 26, 2019

前言

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
    image

 不 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>
  );
});
const Default = props => {
  const [key, setKey] = useState(0);
  useEffect(() => {
    console.log("one second later");
    setTimeout(() => {
      setKey(key + 1);
    }, 1000);
  }, []);

  return (
    <MainLayout>
      <h3>每次 re-render 时,MainLayout 的 children 属性都改变了</h3>
      <Child />
      <Hello />
    </MainLayout>
  );
};

原因,和上方解释的原因相同,看非 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

image

代码:

// 被 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 7 和 context 的关系

React-redux 只用 context 来传递 store,然后用 store.subscribe() 来注册监听 store 中 state 的变化。不直接用 context 传递数据能很大提升性能。因为 Context.Provider 中 value 每次变化都会导致用到 useContext 的子孙组件的 re-render。

react-redux 5、6、7 数据流对比

详细对比见 reduxjs/react-redux#1177 (comment)。这里不重复了。这个例子有助于我们理解嵌套的 connect 后的组件的 re-render 的详细流程。

dva 对 redux 数据流的封装

loading state 的添加

antd 的 CongfigProvider

image

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
    image

  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 节点。

参考文献

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant