Redux进阶之组件的封装
可能是后端弄得多了,上手了一个新东西后,就开始考虑怎么样去封装、模块化了。考虑到使用了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))
}
}
}
那我们的mapStateToProps
和mapDispatchToProps
在什么地方被调用的呢?其实上面的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。
总结###
Redux设计的初衷就是使用全局唯一的一个store对象来管理所有状态,从而避免组件状态处理的回调地狱(每个组件都可以得到全局的状态,就不需要再去询问其他组件了),所以以上尝试也仅仅能作为一种尝试罢,真正用起来可能还是会非常艰辛。而我们最开始纠结的react-redux
在设计时可能就是希望我们将整个组件连同reducer、store等一起打包做封装,mapStateToProps
获取和reducer一样的相对路径的state,目的是只将组件和reducer打包做封装,其实也是一种反模式(anti-pattern)。既然用了别人的库,最好就按照别人的思路走吧,除非他的模式确实无法满足需求,再考虑造轮子吧,不然真的累啊。
看了一些GitHub上面的讨论,关于Redux提倡的使用全局唯一的store对象来管理整个app的状态的方式的不足,其中就有组件的复用(comment)以及多个app(store对象)的组合,这也算是Redux这个框架的一个痛点吧。当然,肯定也有不少人和我一样,会考虑怎样把常用的组件封装好使用,比如说redux-form项目,它其实是实现了只封装reducer和组件的,如果硬是要做Redux组件封装的话可以参考。
Comments