跳到主要内容

解读 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 个参数做深度了解。

  1. 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 的第一个参数里利用函数闭包进行一些复杂计算的缓存,从而实现效率优化的目的。更多关于这方面优化的内容。

  1. mapDispatchToProps

说完了 mapStateToProps,让我们来看看 mapDispatchToProps 方法,这也是 connect 方法接受的第二个参数。它接受 store 的 dispatch 作为第一个参数,同时接受 this.props 作为可选的第二个参数。利用这个方法,我们可以在 connect 中方便地将 actionCreator 与 dispatch 绑定在一起(利用 bindActionCreators 方法),最终绑定好的方法也会作为 props 传给当前组件。

具体设计上与 mapStateToProps 的思路基本一致,除了 mapDispatchToProps 接受的第一个参数是 store.dispatch 而不是 store.getState()。

  1. mergeProps

根据文档中的定义,mergeProps 参数也是一个函数,接受 stateProps、dispatchProps 和ownProps 作为参数。实际上,stateProps 就是我们传给 connect 的第一个参数 mapStateToProps 最终返回的 props。同理,dispatchProps 是第二个参数的最终产物,而 ownProps 则是组件自己的props。这个方法更大程度上只是为了方便对三种来源的 props 进行更好的分类、命名和重组。

  1. 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 实现。