解读 Redux
更多细节:Redux 源码专精
参数归一化
createStore 可谓是整个 Redux 的灵魂。基本上,Redux 的核心功能已经全部被囊括在 createStore 及 createStore 方法最终生成的 store 中。下面让我们了解一下 createStore 究竟是怎么工作的。
首先,看看 createStore 的函数签名:
export default function createStore(reducer, initialState, enhancer) {
// ...
}
enhancer 在 createStore 中扮演了什么角色呢?
事实上,createStore 的第三个参数是在 Redux 3.1.0 之后才加入的:
// createStore.js 第 30 行起
if (typeof initialState === 'function' && typeof enhancer === 'undefined') {
enhancer = initialState
initialState = undefined
}
if (typeof enhancer !== 'undefined') {
if (typeof enhancer !== 'function') {
throw new Error('Expected the enhancer to be a function.')
}
return enhancer(createStore)(reducer, initialState)
}
从上述代码中可以看出,createStore 中的第二个参数不仅扮演着 initialState 的角色。如果我们传入的第二个参数是函数类型,那么 createStore 会认为你忽略了 initialState 而传入了一个 enhancer。
如果我们传入了一个有效的 enhancer,createStore 会返回 enhancer(createStore)(reducer, initialState) 的调用结果,这是常见的高阶函数调用方法。在这个调用中,enhancer 接受 createStore 作为参数,对 createStore 的能力进行增强,并返回增强后的 createStore。然后再将 reducer 和 initialState 作为参数传给增强后的 createStore,最终得到生成的 store。
典型使用案例是 redux-devtools-extension,它将 Redux DevTools 做成浏览器插件。
初始状态及 getState
在完成基本的参数校验之后,在 createStore 中声明如下变量及 getState 方法:
var currentReducer = reducer
var currentState = initialState
var listeners = []
var isDispatching = false
/**
* Reads the state tree managed by the store.
*
* @returns {any} The current state tree of your application.
*/
function getState() {
return currentState
}
从上面的代码中可以看到,我们定义了 4 个本地变量。
- currentReducer:当前的 reducer,支持通过 store.replaceReducer 方式动态替换 reducer,为代码热替换提供了可能。
- currentState:应用的当前状态,默认为初始化时的状态。
- listeners:当前监听 store 变化的监听器。
- isDispatching:某个 action 是否处于分发的处理过程中。
而 getState 方法用于返回当前状态。
subscribe
getState 之后,我们定义了 store 的另一个方法 subscribe:
function subscribe(listener) {
listeners.push(listener)
var isSubscribed = true
return function unsubscribe() {
if (!isSubscribed) {
return
}
isSubscribed = false
var index = listeners.indexOf(listener)
listeners.splice(index, 1)
}
}
React Redux 中的 connect 方法隐式地使用 store.subscribe 方法。
dispatch
接下来,要说到的就是 store 非常核心的一个方法,也是我们在应用中经常直接(store.dispatch({ type: 'SOME_ACTION' }))或间接(使用 connect 将 action creator 与 dispatch 关联)使用的方法——dispatch:
function dispatch(action) {
if (!isPlainObject(action)) {
throw new Error('Actions must be plain objects. ' + 'Use custom middleware for async actions.')
}
if (typeof action.type === 'undefined') {
throw new Error('Actions may not have an undefined "type" property. ' + 'Have you misspelled a constant?')
}
if (isDispatching) {
throw new Error('Reducers may not dispatch actions.')
}
try {
isDispatching = true
currentState = currentReducer(currentState, action)
} finally {
isDispatching = false
}
listeners.slice().forEach(listener => listener())
return action
}
首先,我们校验了 action 是否为一个原生 JavaScript 对象,若不是,则抛出错误。接着,我们校验了 action 对象是否包含 type 字段,这段检查更大程度上是为了帮助粗心的开发者发现拼错 type 常数的情况。
接下来判断当前是否处于某个 action 的分发过程中,这个检查主要是为了避免在 reducer 中分发 action 的情况,因为这样做可能导致分发死循环,同时也增加了数据流动的复杂度。
确认当前不属于分发过程中后,先设定标志位,然后将当前的状态和 action 传给当前的 reducer,用于生成最新的 state。这看起来一点都不复杂,这也是我们反复强调的 reducer 工作过程——纯函数、接受状态和 action 作为参数,返回一个新的状态。
在得到新的状态后,依次调用所有的监听器,通知状态的变更。需要注意的是,我们在通知监听器变更发生时,并没有将最新的状态作为参数传递给这些监听器。这是因为在监听器中,我们可以直接调用 store.getState() 方法拿到最新的状态。
最终,处理之后的 action 会被 dispatch 方法返回。
replaceReducer
这个方法主要用于 reducer 的热替换,在开发过程中我们一般不会直接使用这个 API:
function replaceReducer(nextReducer) {
currentReducer = nextReducer
dispatch({ type: ActionTypes.INIT })
}
完成上述方法的声明后,我们分发了 Redux 应用的第一个 action:
dispatch({ type: ActionTypes.INIT })
这是为了拿到所有 reducer 中的初始状态(你是否还记得在定义 reducer 时,第一个参数为 previousState,如果该参数为空,我们提供默认的 initialState)。只有所有的初始状态都成功获取后,Redux 应用才能有条不紊地开始运作。
现在我们对 Redux 的实现原理有了一个完整的认识。相比 Flux,Redux 的设计有非常多值得推敲的地方,我们也因此领略了不同编程思想碰撞的火花。Redux 本身是一个通用思想,现在已经有其他框架对 Redux 进行变化使用的案例,如 Vuex 等。
解读 react-redux
react-redux 为我们提供了 React 与 Redux 之间的绑定,也就是我们在例子中使用的 Provider 和 connect 方法。在本节中,我们将从源代码层面详细解读 react-redux 的设计思路以及实现原理。
Provider
Provider 的源码:
export default class Provider extends Component {
getChildContext() {
return { store: this.store }
}
constructor(props, context) {
super(props, context)
this.store = props.store
}
render() {
const { children } = this.props
return Children.only(children)
}
}
以上是 react-redux 中 Provider 的部分源代码。可以看到,其实 Provider 的实现非常简单。在 constructor 中,拿到 props 中的 store,并挂载在当前实例上。同时定义了 getChildContext 方法,该方法定义了自动沿组件传递的特殊 props。
除了 context,Provider 的源代码中还有如下几行特殊的定义:
if (process.env.NODE_ENV !== 'production') {
Provider.prototype.componentWillReceiveProps = function (nextProps) {
const { store } = this
const { store: nextStore } = nextProps
if (store !== nextStore) {
warnAboutReceivingStore()
}
}
}
如果当前不是生产环境, Provider 中额外定义了一个 componentWillReceiveProps 的生命周期。在这个生命周期中,如果发现 props 中的 store 发生了变化,则执行 warnAboutReceivingStore:
let didWarnAboutReceivingStore = false
function warnAboutReceivingStore() {
if (didWarnAboutReceivingStore) {
return
}
didWarnAboutReceivingStore = true
warning(
'<Provider> does not support changing `store` on the fly. ' +
'It is most likely that you see this error because you updated to ' +
'Redux 2.x and React Redux 2.x which no longer hot reload reducers ' +
'automatically. See https://github.com/reactjs/react-redux/releases/' +
'tag/v2.0.0 for the migration instructions.'
)
}
实际上,warnAboutReceivingStore 是一个为了方便开发者升级的警示方法,并没有任何实际的作用。
connect
import hoistStatics from 'hoist-non-react-statics'
export default function connect(mapStateToProps, mapDispatchToProps, mergeProps, options = {}) {
// ...
return function wrapWithConnect(WrappedComponent) {
// ...
class Connect extends Component {
// ...
render() {
// ...
if (withRef) {
this.renderedElement = createElement(WrappedComponent, {
...this.mergedProps,
ref: 'wrappedInstance'
})
} else {
this.renderedElement = createElement(WrappedComponent, this.mergedProps)
}
return this.renderedElement
}
}
// ...
return hoistStatcis(Connect, WrappedComponent)
}
}
可以看出,connect 函数本身返回名为 wrapWithConnect 的函数,而这个函数才是真正用来装饰 React 组件的。而在我们装饰一个 React 组件时,其实就是把组件在 Connect 类的 render 方法中进行渲染,并获取 connect 中传入的各种额外数据。
接下来,让我们依次对 connect 函数的 4 个参数做深度了解。
- mapStateToProps
connect 的第一个参数定义了我们需要从 Redux 状态树中提取哪些部分当作 props 传给当前组件。
export default function connect(mapStateToProps, mapDispatchToProps, mergeProps, options = {}) {
const shouldSubscribe = Boolean(mapStateToProps)
// ...
class Connect extends Component {
// ...
trySubscribe() {
if (shouldSubscribe && !this.unsubscribe) {
this.unsubscribe = this.store.subscribe(this.handleChange.bind(this))
this.handleChange()
}
}
// ...
}
}
因此,如果尝试使用 connect 让组件与 Redux 状态树产生关联,第一个参数 mapStat eToProps 可以说是必传的。
那么,我们传入的 mapStateToProps 是怎么生效的呢?看看 Connect 类中定义的 configureFinalMapState 方法就能略知一二:
const mapState = mapStateToProps || defaultMapStateToProps
// ...
class Connect extends Component {
configureFinalMapState(store, props) {
const mappedState = mapState(store.getState(), props)
const isFactory = typeof mappedState === 'function'
this.finalMapStateToProps = isFactory ? mappedState : mapState
this.doStatePropsDependOnOwnProps = this.finalMapStateToProps.length !== 1
if (isFactory) {
return this.computeStateProps(store, props)
}
if (process.env.NODE_ENV !== 'production') {
checkStateShape(mappedState, 'mapStateToProps')
}
return mappedState
}
computeStateProps(store, props) {
if (!this.finalMapStateToProps) {
return this.configureFinalMapState(store, props)
}
const state = store.getState()
const stateProps = this.doStatePropsDependOnOwnProps
? this.finalMapStateToProps(state, props)
: this.finalMapStateToProps(state)
if (process.env.NODE_ENV !== 'production') {
checkStateShape(stateProps, 'mapStateToProps')
}
return stateProps
}
}
首先,我们对 connect 中传入的 mapStateToProps 参数做了默认参数校验,若没有传入,则使用 defaultMapStateToProps。defaultMapStateToProps 只是一个返回空对象的方法而已。
在最终渲染被 connect 装饰过的组件时,会调用 this.computeStateProps 计算出最终从 Redux 状态树中提取出了哪些值作为当前组件的 props。
而在计算之前,又会校验当前组件是否有定义 finalMapStateToProps,若没有,则返回 this.configureFinalMapState 的调用结果。那么 configureFinalMapState 里又做了什么处理呢?
首先,将当前的 store 和 props 作为参数传给 mapState,得到执行的结果。根据 react-redux 文档中的说明,一般情况下,传给 connect 的 mapStateToProps 函数必须返回一个对象。但是在某些特殊情况下,比如需要针对个别组件进行极致优化的时候,mapStateToProps 也可以返回一个函数。这也是为什么在源代码中需要判断返回的值是否为函数。
接下来,如果 mapState 返回的是函数,那么当前组件最终的 mapStateToProps 方法就是我们传入的第一个参数执行后返回的那个函数,否则就还是原先定义的 mapState 函数。
我们可能会疑惑为什么传给 connect 的第一个参数本身是一个函数,react-redux 还允许这个函数的返回值也是一个函数呢? 简单地说,这样设计可以允许我们在 connect 的第一个参数里利用函数闭包进行一些复杂计算的缓存,从而实现效率优化的目的。更多关于这方面优化的内容。
- mapDispatchToProps
说完了 mapStateToProps,让我们来看看 mapDispatchToProps 方法,这也是 connect 方法接受的第二个参数。它接受 store 的 dispatch 作为第一个参数,同时接受 this.props 作为可选的第二个参数。利用这个方法,我们可以在 connect 中方便地将 actionCreator 与 dispatch 绑定在一起(利用 bindActionCreators 方法),最终绑定好的方法也会作为 props 传给当前组件。
具体设计上与 mapStateToProps 的思路基本一致,除了 mapDispatchToProps 接受的第一个参数是 store.dispatch 而不是 store.getState()。
- mergeProps
根据文档中的定义,mergeProps 参数也是一个函数,接受 stateProps、dispatchProps 和ownProps 作为参数。实际上,stateProps 就是我们传给 connect 的第一个参数 mapStateToProps 最终返回的 props。同理,dispatchProps 是第二个参数的最终产物,而 ownProps 则是组件自己的props。这个方法更大程度上只是为了方便对三种来源的 props 进行更好的分类、命名和重组。
- options
connect 参数接受的最后一个参数是 options,其中包含了两个配置项。 pure:布尔值,默认为 true。当该配置为 true 时,Connect 中会定义 shouldComponentUpdate 方法并使用浅对比判断前后两次 props 是否发生了变化,以此来减少不必要的刷新。如果 应用严格按照 Redux 的方式进行架构,该配置保持默认即可。 withRef:布尔值,默认为 false。如果设置为 true,在装饰传入的 React 组件时,Connect 会保存一个对该组件的 refs 引用,你可以通过 getWrappedInstance 方法来获得该 refs, 并最终获得原始的 DOM 节点
代码热替换
很多第一次接触 Redux 的开发者都是被它的代码热替换功能吸引住的眼球。不少人知道实现热替换是 Redux 的 store 提供了 replaceReducer 的功能支持。事实上,如果不是 react-redux 中的connect 方法也添加了相关的支持,代码热替换功能不可能在 Redux 应用中那么轻而易举地实现:
if (process.env.NODE_ENV !== 'production') {
Connect.prototype.componentWillUpdate = function componentWillUpdate() {
if (this.version === version) {
return
}
this.version = version
this.trySubscribe()
this.clearCache()
}
}
代码热替换功能肯定发生在应用开发过程中,因此首先最外层有一个对当前环境的判断。若在开发环境,则为 connect 额外定义一个 componentWillUpdate 的生命周期方法,判断当前组件的version 是否与全局的 version 不同,若不同,则更新 version 并重新执行订阅等操作。
那么,这个 version 是如何定义的呢?让我们再次回到 connect 的源代码中寻找答案:
// 帮助追踪热重载
let nextVersion = 0
export default function connect(mapStateToProps, mapDispatchToProps, mergeProps, options = {}) {
// ...
// 帮助追踪热重载
const version = nextVersion++
return function wrapWithConnect(WrappedComponent) {
// ...
class Connect extends Component {
constructor(props, context) {
// ...
this.version = version
}
}
}
}
在每次 connect 执行的时候,nextVersion 都会加 1,而 version 则被赋为当前的版本号。同时在 Connect 类初始化进行构造时,会将全局的 version 设为自己实例的 version。这样,connect下次执行的时候,version 发生了变化,因而在额外定义的 componentWillUpdate中,当前示例的version 与全局 version 不相同,最终触发了 Redux 的重新订阅及缓存清空。
需要额外说明的是,为了让使用 connect 与 Redux 进行绑定的组件能够尽可能避免不必要的更新,connect 中还定义了一系列的判断当前组件是否需要更新的逻辑。这些逻辑主要是根据当前的配置进行 state 的前后对比,可以想象成一个建议的 shouldComponentUpdate 实现。