可能是后端弄得多了,上手了一个新东西后,就开始考虑怎么样去封装、模块化了。考虑到使用了Redux之后的组件的封装,我首先想到的是将Redux绑定好的组件和reducer函数打包起来,使用时可以根据组件的某个属性(比如id属性)来唯一确定它在state tree中的位置,从而封装和隔离。但实践起来却碰到下面这样一个问题。

reducer函数传递的state是一个相对路径下的state,而mapStateToProps函数传递得到的却是Redux中存储的整个state。这样就会产生一个问题:为了获取state特定路径下的某个状态,必须要hard code一个路径,这与(组件)代码复用是相矛盾的。那么如何在mapStateToProps中也同样得到一个相对路径下的state呢?

举个例子来说,我有下面这样一个结构的state:

let state = {
  out: "QWE",
  inside: {
    firstname: "abc",
    lastname: "def",
  }
}

上面的每一个状态都对应了不同id(比如分别为’inside-firstname’, ‘inside-lastname’和’out’)的相同组件:

const mapStateToProps = (state, ownProps) => {
    return {
        value: getRelativeState(state)[ownProps.id].value,
    }
};

const mapDispatchToProps = (dispatch, ownProps) => {
    return {
        handleValueChange: (event) => {
            dispatch(createAction(ownProps.id, event.target.value));
        },
    }
};

const Input = connect(mapStateToProps, mapDispatchToProps)(BaseInput);

问题是上面的getRelativeState该如何实现?或者是否有更好地实现方式?

react-redux源码剖析

上面的问题放一放,先看一看为什么Redux在这两个函数中传递的state会不一样。首先,reducer函数是通过combineReducers函数来结合在一起的,combineReducer函数在内部帮我们做了一个映射。看看combineReducer的源码就明白了:

export default function combineReducers(reducers) {
  var reducerKeys = Object.keys(reducers)
  var finalReducers = {}
  var finalReducerKeys = Object.keys(finalReducers)
  // 省略若干行state和类型检查的代码
  return function combination(state = {}, action) {
    // 省略若干行state和类型检查的代码
    var hasChanged = false
    var nextState = {}
    for (var i = 0; i < finalReducerKeys.length; i++) {
      var key = finalReducerKeys[i]
      var reducer = finalReducers[key]
      var previousStateForKey = state[key]
      var nextStateForKey = reducer(previousStateForKey, action)
      if (typeof nextStateForKey === 'undefined') {
        var errorMessage = getUndefinedStateErrorMessage(key, action)
        throw new Error(errorMessage)
      }
      nextState[key] = nextStateForKey
      hasChanged = hasChanged || nextStateForKey !== previousStateForKey
    }
    return hasChanged ? nextState : state
  }
}

以上,很多东西就比较清楚了:

  • combineReducers返回的仍然是一个reducer函数(输入为state和action);
  • 对于combineReducers输入的每一个reducer函数都会在action出现时被依次调用,并用来更新总的state;
  • reducer函数返回的状态如果是undefined的话是会报错的;
  • 如果一个action下导致的所有的子reducer返回的状态都没变,那么总的state对象是不变的(包括对象的内存地址),否则得到的是一个全新创建的对象(这里的hasChanged变量的作用)。

mapStateToProps函数则是通过connect函数来和React组件绑定的,因此看一看connect是怎样实现的:

export 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
    })
  }
}

export default createConnect()

以上,需要注意这里用到的JavaScript的一个技巧:即在函数的输入中进行变量的赋值。比如下面这个例子:

function func({a = 'a', b = 'b'} = {}) {
  console.log(a);  // a
}

和这样子是一样的(待确认):

function func() {
  let a = 'a', b = 'b';
  console.log(a);  // a
}

因此,这里关键的部分是connectHOC函数,也就是connectAdvanced函数,这个函数输入是一个React组件,输出是一个包裹了原组件的React组件。这里截取这个输出的组件的部分函数,可以看到react-redux渲染时是在原组件的基础上添加了额外的一些属性再把原组件渲染出来:

class Connect extends Component {
  // 省略若干函数
  onStateChange() {
    this.selector.run(this.props)

    if (!this.selector.shouldComponentUpdate) {
      this.notifyNestedSubs()
    } else {
      this.componentDidUpdate = this.notifyNestedSubsOnComponentDidUpdate
      this.setState(dummyState)
    }
  }
  
  render() {
    const selector = this.selector
    selector.shouldComponentUpdate = false

    if (selector.error) {
      throw selector.error
    } else {
      return createElement(WrappedComponent, this.addExtraProps(selector.props))
    }
  }
}

那我们的mapStateToPropsmapDispatchToProps在什么地方被调用的呢?其实上面的selector.run正是调用它们的地方。selector对象主要做的事情是通过调用我们定义的这些映射函数,把得到的属性合并在一起并输出出来,也就是selector.props啦。功能很简单,但实现起来还是“一坨一坨的”:

export function pureFinalPropsSelectorFactory(
  mapStateToProps,
  mapDispatchToProps,
  mergeProps,
  dispatch,
  { areStatesEqual, areOwnPropsEqual, areStatePropsEqual }
) {
  // 省略若干函数
  
  function handleNewPropsAndNewState() {
    stateProps = mapStateToProps(state, ownProps)

    if (mapDispatchToProps.dependsOnOwnProps)
      dispatchProps = mapDispatchToProps(dispatch, ownProps)

    mergedProps = mergeProps(stateProps, dispatchProps, ownProps)
    return mergedProps
  }

  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)
  }

综上,combineReducers函数在内部帮我们做了一个映射,所以reducer函数中得到的state是相对路径下的state,而selector函数没有做这个事情(因为我们也没告诉它要绑定的组件的相对路径呀,或者说要绑定的组件只是和mapStateToProps绑定了,而没有和reducer函数绑定)。

绑定reducer和组件###

回到问题本身,想要去除mapStateToProps中hard code的state路径,也是有办法的:

方案一:创建一个filter函数来遍历state tree,来得到所需要的state路径(比如上面的通过组件的id属性来确定相对位置)。但这个filter函数用于过滤的规则比较难定,且这个规则是hard code进去的,还是要留给mapStateToProps函数来设定呢?

方案二:在使用combineReducers函数的同时,把每个reducer和每个mapStateToProps函数绑定起来。但问题是reducer函数和mapStateToProps函数不总是一对一的关系啊。

其实还有一些其他的方法,但要实现起来总感觉硌得慌,为什么react-redux内部不实现好了呢?

我觉得这个问题的根本矛盾点在于,每个reducer函数必定是唯一的(因为一个action没必要产生两个相同的子state啊),而同一个组件则可以复用,scope不同,因此没办法在定义组件时将两者绑定起来(定义的这个组件被用了多次就对应了多个reducer啊)。

绑定reducer、组件和store###

上面那条路看起来就很艰辛,这里我们换一条路,把封装的范围放大:即将整个React app进行打包(包含了各个组件、reducer和store)。因为store对象也跟着打包了,就没有state路径的问题了,hard code也不要紧。

例子

打包时在组件内部创建好store,并将该store和组件绑定(作为组件的一个属性存在):

export function getStore() {
    let rootReducer = combineReducers({value: inputReducer});
    return createStore(rootReducer);
}

class MyInput extends Component {
    render() {
        let store;
        'store' in this.props ? store = this.props.store : store = getStore();

        return (
            <Provider store={store}>
                <Input {...this.props}/>
            </Provider>
        );
    }
}

export default MyInput

使用时和使用其他React组件没什么两样(这里每个组件都拥有一个独立的store):

ReactDOM.render((
    <div>
        <MyInput/><br/>
        <MyInput/><br/>
    </div>),
    document.getElementById('root')
);

当然也可以让多个组件共享同一个store(并且是可以将这些store组成一个更大的store来用的):

let inputStore1 = getStore();
let inputStore2 = getStore();

ReactDOM.render(
    (
        <div>
            <MyInput store={inputStore1}/><br/>
            <MyInput store={inputStore2}/><br/>
            <MyInput store={inputStore1} disabled/><br/>
            <MyInput store={inputStore2} disabled/><br/>
        </div>
    ),
    document.getElementById('root')
);

更多实现上的细节,移步GitHub

总结###

react-redux在设计时可能就是希望我们将整个组件连同reducer、store等一起打包做封装,Redux设计的初衷就是使用全局唯一的一个store对象来管理所有状态,从而避免组件状态处理的回调地狱(每个组件都可以得到全局的状态,就不需要再去询问其他组件了),所以以上尝试也仅仅能作为一种尝试罢,真正用起来可能还是会非常艰辛。而我们最开始纠结的mapStateToProps获取和reducer一样的相对路径的state,目的是只将组件和reducer打包做封装,其实也是一种反模式(anti-pattern)。既然用了别人的库,最好就按照别人的思路走吧,除非他的模式确实无法满足需求,再考虑造轮子吧,不然真的累啊。

看了一些GitHub上面的讨论,关于Redux提倡的使用全局唯一的store对象来管理整个app的状态的方式的不足,其中就有组件的复用(comment)以及多个app(store对象)的组合,这也算是Redux这个框架的一个痛点吧。当然,肯定也有不少人和我一样,会考虑怎样把常用的组件封装好使用,比如说redux-form项目,它其实是实现了只封装reducer和组件的,如果硬是要做Redux组件封装的话可以参考。

Reference

  1. https://github.com/reactjs/react-redux/issues/411
  2. https://github.com/reactjs/react-redux/issues/278
  3. https://github.com/reactjs/redux/issues/1385
  4. https://github.com/reactjs/redux/issues/1528