前言
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://*.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
不 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://*.com/questions/47567429/this-props-children-not-re-rendered-on-parent-state-change
- 代码例子: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-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
代码:
// 被 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-9k4rb
fix: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
- 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 机制参考:https://github.com/facebook/react/issues/3226
- 子组件不 re-render 原因:https://*.com/questions/47567429/this-props-children-not-re-rendered-on-parent-state-change
- 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 数据流分析:https://github.com/reduxjs/react-redux/issues/1177#issuecomment-468959889
- 优化 useContext 性能:https://github.com/facebook/react/issues/15156