Redux 源码解读

先祭上本文的思维导图:

一、为什么讲 Redux

在项目中用 Redux 的时候,有时候就觉得会用,但是不明白为什么这样用。导致在 debug 的时候,无法快速的 debug 出原因。而且 Redux 的源码也不复杂,暴露出来的只有 5 个 API,可以作为很好的阅读源码的开端,所以在这里很开心可以和大家一起来探索 Redux。如果有些讲的不准确的地方,欢迎大家提出来;也特别希望大家积极的讨论,迸发出更多想法。

二、Redux 为什么会出现

要了解 Redux,就要从 Flux 说起。可以认为 Redux 是 Flux 思想的一种实现。那 Redux 是为什么被提出来呢?就要提一下 MVC 了。

1、MVC

说到 Flux,我们就不得不要提一下 MVC 框架。

MVC 框架将应用分为 3 个部分:

  • View:视图,展示用户界面
  • Controller:管理应用的行为和数据,响应用户的输入(经常来自 View)和更新状态的指令(经常来自 Model)
  • Model:管理数据,大部分业务逻辑也在 Model 中

用户请求先到达 Controller,然后 Controller 调用 Model 获得数据,再把数据交给 View。这个想法是很理想的想法。在实际的框架应用中,大部分都是允许 View 和 Model 直接通信的。当项目变的越来越大的时候,这种不同模块之间的依赖关系就变得“不可预测”了,所以就变成了下面这样子。

虽然这张图有夸大的嫌疑,但是也说明了 MVC 在大型项目下,容易造成数据混乱的问题。

所以,Flux 诞生了。在写这篇文章之前,我查阅很多资料,有些说 Flux 思想替代了 MVC 框架,我则不这么认为。个人觉得,Flux 思想更严格的控制了 MVC 的数据流走向。下面咱们来看看 Flux 是如何严格控制数据流的。

2、Flux

一个 Flux 应用包含四个部分:

  • Dispatcher,处理动作分发,维持 Store 之间的依赖关系
  • Store,负责存储数据和处理数据相关逻辑
  • Action,触发 Dispatcher
  • View,视图,负责显示用户界面


通过上图可以看出来,Flux 的特点就是单向数据流

  • 用户在 View 层发起一个 Action 对象给 Dispatcher
  • Dispatcher 接收到 Action 并要求 Store 做相应的更新
  • Store 做出相对应更新,然后发出一个 change 事件
  • View 接收到 change 事件后,更新页面

所以在 Flux 体系下,如果想要驱动界面,只能派发一个 Store,别无他法。在这种规矩下,如果想要追溯一个应用的逻辑就变得很轻松了。而且这种思想解决了 MVC 中无法杜绝 View 和 Model 之间的直接对话的问题。

这里就不具体讲关于 Flux 的例子了,如果想要更了解 Flux ,可以看一下阮一峰老师的 Flux 架构入门教程

4、Redux 诞生

Redux 是 Flux 的一种实现,意思就是除了“单向数据流”之外,Redux 还强调三个基本原则:

  • 唯一的 store(Single Source of Truth)
  • 保持状态只读(State is read-only)
  • 数据改变只能通过纯函数完成(Changes are made with pure functions)

a. 唯一的 store

在 Flux 中,应用可以拥有多个 Store,但是分成多个 Store 容易造成数据冗余,数据一致性不太好处理,而且 Store 之间可能还会有依赖,增加了应用的复杂度。所以 Redux 对这个问题的解决方法就是:整个应用只有一个 Store。

b. 保持状态只读

就是不能直接修改状态。如果想要修改状态,只能通过派发一个 Action 对象来完成。

c. 数据改变只能通过纯函数完成

这里说的纯函数就是 Reducer。按照 redux 作者 Dan 的说法:Redux = Reducer + Flux。

三、在 React 中应用 Redux

下面咱们根据例子来了解一下 Reudx 在 React 中的应用。

1、Redux 中的数据流动

创建一个 Redux 应用需要下面几部分:

  • Actions
  • Reducers
  • Store

他们分别是什么意思呢?下面我们来举一个例子:
比如下面是商场某品牌鞋子的展示柜:

鞋子展示柜
店长来视察,发现鞋子2放的太高了,而且这款鞋还是店里的主推款,放在这个位置不适合宣传,就让店员把鞋子 2 往下挪两排,放下去之后,店长看着舒服多了。

鞋子展示柜

其实通过上面的例子,我们现在就很好解释 Redux 了:

  • View: 鞋子摆放在鞋架上的整体效果
  • Action: 店长给店员分配的任务(往下挪鞋子)
  • Reducers: 具体任务的实施者(把鞋子往下挪两排)
  • Store: 鞋子在鞋架上的具体位置

所以整个过程可以是下面这样:

Store 决定了 View,然后用户的交互产生了 Action,Reducer 根据接收到的 Action 执行任务,从而改变 Store 中的 state,最后展示到 View 上。那么,Reducer 如何接收到动作(Action)信号的呢?伴随着这个问题,咱们来看一个例子。

2、Redux 实践

了解了 Redux 中各个部分代表的意思,下面咱们来通过一个计数器的例子进一步了解一下 Redux 的原理(具体代码可以看 GitHub)。我们想要的最终效果如下:

根据上面的思路,可以分别把 Action 和 Reducer 定义为:

  • 动作(Action): 加
  • 执行者(Reducer): 加 1

那么我们来创建 Action 和 Reducer 这两个文件:

Actions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ActionTypes.js
export const ADD = 'add'
export const SUBTRACT = 'subtract'
// Actions.js
import * as ActionTypes from './ActionTypes'
export const addAction = () => {
return {
type: ActionTypes.ADD
}
}
export const subtractAction = () => {
return {
type: ActionTypes.SUBTRACT
}
}

首先我们创建一个 ActionTypes.js 和 Actions.js 这两个文件。ActionType 代表的就是 Action 的类型,可以看到它是一个常量。在 Actions.js 中,我们定义了两个 Action 构造函数,他们返回的都是一个简单对象 (plain object),而且每个对象必须包含 type 属性。

可以看出来 Action 明确表达了我们想要做的事情(加和减)。

可能有些同学会问,在 Action 中,有时候也会 return 一个 function,不是简单对象。其实这个时候,是中间件拦截了 Action,如果是 function,就执行中间件中的方法。但是咱们这次不讲中间件,所以就先忽略这种情况。

Reducer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Reducer.js
import * as ActionTypes from './ActionTypes'
const initialState = {
total: 0
}
export default (state = initialState, action) => {
const {total} = state
switch(action.type){
case ActionTypes.ADD:
// 加
return {...state, total: total + 1}
case ActionTypes.SUBTRACT:
// 减
return {...state, total: total - 1}
default:
return state
}
}

可以看到 Reducer 是一个纯函数。它接收两个参数 state 和 Action,根据接收到的 state 和 Action 来判断自己需要对当前的 state 做哪些操作,并且返回新的 state。

在 Reducer 中我们给了 state 一个默认的值,这就是我们的初始 state。关于 redux 是如何返回初始值的,继续往下看。

Action 和 Reducer 都有了,那怎么让他们两个联系起来呢?下面咱们看一下 redux 中的精华部分 - Store。

createStore

首先我们先创建 Store:

1
2
3
4
5
6
7
8
// store.js
import {createStore} from 'redux'
import reducer from './view/Reducer'
const store = createStore(reducer)
export default store

在 store.js 中,我们把 reducer 传给 createStore 方法并且执行了它,来创建 Store。这个方法是 Redux 的精髓所在。

下面看一下 createStore 的源码部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// createStore.js
/**
* @param {Function} reducer
* @param {any} [preloadedState]
* @param {Function} [enhancer]
* @returns {Store}
*/
export default function createStore(reducer, preloadedState, enhancer) {
let currentReducer = reducer
let currentState = preloadedState
......
// 当创建一个 store 的时候,会触发一个 'INIT' action,使得 reducer 返回初始值。有效的填补了初始状态树
dispatch({ type: ActionTypes.INIT })
return {
dispatch,
subscribe,
getState,
replaceReducer,
[$$observable]: observable
}
}

createStore 接收三个参数:

  • reducer{Function}
  • state{any}(可选参数)
  • enhancer{Function}(可选参数)

返回一个对象,这个对象包含五个方法,咱们目前先只关注前三个方法:

  • dispatch
  • subscribe
  • getState

在整个 createStore 中,只执行了 dispatch({ type: ActionTypes.INIT }) 这一句代码。那 dispatch 做了什么呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function dispatch(action) {
// 要求 action 必须是简单对象
if (!isPlainObject(action)) {
throw new Error(...)
}
// 要求 action 必须有 type 属性
if (typeof action.type === 'undefined') {
throw new Error(...)
}
// 标识位。
if (isDispatching) {
throw new Error(...)
}
try {
isDispatching = true
// 把当前的 state(currentState) 和接收到的 action 传给接收到的 reducer 方法(currentReducer),并把 reducer 处理后返回的 state 赋值给 currentState
currentState = currentReducer(currentState, action)
} finally {
// 修改标识位
isDispatching = false
}
// 执行订阅队列中的方法
const listeners = (currentListeners = nextListeners)
for (let i = 0; i < listeners.length; i++) {
const listener = listeners[i]
listener()
}
// 返回 action
return action
}

我省略了一些代码,这是 dispatch 方法的核心代码。它接收一个 action 对象,并且把 createStore 接收到的 state 参数和通过 dispatch 方法传进来的 Action 参数,传给了 Reducer 并且执行,然后把 reducer 返回的 state 赋值给 currentState。最后执行订阅队列中的方法。

createStore 方法一上来就执行了 dispatch({ type: ActionTypes.INIT })。这句话的意思咱们现在也清楚了,它的主要目的就是初始化 state。

现在咱们已经把 Action 和 Reducer 联系起来了。可以看到,在 createStore 方法中,它维护一个变量 currentState,通过 dispatch 方法来更新 currentState 变量。外部如果想要获取 currentState,只需要调用 createStore 暴露出来的 getState 方法即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// createStore.js
/**
* Reads the state tree managed by the store.
*
* @returns {any} The current state tree of your application.
*/
function getState() {
if (isDispatching) {
throw new Error(...)
}
return currentState
}

getState 方法是获取当前的 currentState 变量,如果想要实时获取 state,那就需要注册监听事件,每次 dispatch 的时候,就都会执行一遍这个事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function subscribe(listener) {
if (typeof listener !== 'function') {
throw new Error(...)
}
if (isDispatching) {
throw new Error(...)
}
let isSubscribed = true
ensureCanMutateNextListeners()
nextListeners.push(listener)
return function unsubscribe() {
if (!isSubscribed) {
return
}
if (isDispatching) {
throw new Error(...)
}
isSubscribed = false
ensureCanMutateNextListeners()
const index = nextListeners.indexOf(listener)
nextListeners.splice(index, 1)
}
}

现在咱们来梳理一下思路:

  • Action:此次动作的目的
  • Reducer:根据接收到的 Action 命令来做具体的操作
  • Store:把 Action 传给 Reducer,并且更新 state。然后执行订阅队列中的方法。

Redux 和 React 是两个独立的产品,但是如果两个结合使用,就不得不提 react-redux 这个库了,可以大大的简化代码的书写,但是咱们先不讲这个库,来自己实现一下。

2、store 和 context 结合

大家都知道,在 React 中我们都是使用 props 来传递数据的。整个 React 应用就是一个组件树,一层一层的往下传递数据。

但是如果在一个多层嵌套的组件结构中,只有最里层的组件才需要使用这个数据,导致中间的组件都需要帮忙传递这个数据,我们就要写很多次 props,这样就很麻烦。

好在 React 提供了一个叫做 context 的功能,可以很好的解决和这个问题。

所谓 context 就是“上下文环境”,让一个树状组件上所有组件都能访问一个共同的对象,为了完成这个任务,需要上下级组件的配合。

首先是上级组件宣称自己支持 context,并且提供给一个函数来返回代表 context 的对象。

然后,子组件只要宣称自己需要这个 context,就可以通过 this.context 来访问这个共同的对象。

所以我们可以利用 React 的 context,把 Store 挂在它上面,就可以实现全局共享 Store 了。


了解了如何在 React 中共享 Store,那咱们就动手来实现一下吧~

Provider

Provider,顾名思义,它是提供者,在这个例子中,它是 context 的提供者。

就像下面这样来使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Provider.js
import { Component, Children } from 'react';
import PropTypes from 'prop-types'
// Provider 是一个 react 组件,它的功能是简单的把子组件渲染出来
class Provider extends Component{
// 返回 Context 对象,方法名是约定好的
getChildContext() {
return {
store: this.store
}
}
constructor(props, context) {
super(props, context)
this.store = props.store
}
render() {
return Children.only(this.props.children)
}
}
Provider.propTypes = {
store: PropTypes.object,
children: PropTypes.element.isRequired
}
// 声明 Context 对象属性
Provider.childContextTypes = {
store: PropTypes.object,
}
export default Provider

Provider 提供了一个函数 getChildContext,这个函数返回的是就是代表 context 的对象。在调用 Store 的时候可以从 context 中获取:this.context.store。

Provider 为了声明自己是 context 的提供者,还需要指定 Provider 的 childContextTypes 属性(需要和 getChildContext 对其)。

只有具备上面两个特点,Provider 才有可能访问到 context。

好了,Provider 组件咱们已经完成了,下面咱们就可以把 context 挂到整个应用的顶层组件上了。

进入整个应用的入口文件 index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
// 应用的入口文件:index.js
import React from 'react';
import ReactDOM from 'react-dom';
import Provider from './Provider';
import store from './Store';
import App from './view/App';
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);

我们把 Store 作为 props 传递给了 Provider 组件,Provider 组件把 Store 挂在了 context 上。所以下面我们就要从 context 中来获取 Store 了。

消费者

下面是我们整个计数器应用的骨架部分。

我们先把页面渲染出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import React, { Component } from 'react';
import PropTypes from 'prop-types'
import Button from './Components/Button'
import './App.css';
export default class App extends Component {
static contextTypes = {
store: PropTypes.object
}
constructor(props, context) {
super(props, context)
this.state = this.getState() // 初始化 state
}
getState = () => {
return this.context.store.getState()
}
subtract = () => {
// 减
}
add = () => {
// 加
}
render() {
const {total} = this.state
return (
<div className="App">
<Button content="-" onClick={this.subtract}/>
<span>{total}</span>
<Button content="+" onClick={this.add}/>
</div>
);
}
}

在上面的组件中,我们做了两件事情:

  • 第一件事情是:声称自己需要 context
  • 第二件事情是:初始化 state。

如何声称自己需要 context 呢?

  • 首先是需要给 App 组件的 contextType 赋值,值的类型和 Provider 中提供的 context 的类型一样。
  • 然后在构造函数中加上 context,这样组件的其他部分就可以通过 this.context 来调用 context 了。
  • 然后是初始化 state。看代码可以知道,我们调用了挂在 context 上的 Store 的 getState 方法。

上面我们了解过,getState 方法返回的就是 createStore 方法中维护的那个变量。在 createStore 执行的时候,就已经初始化过了这个变量。

接下来我们给“加号”加上具体动作。

我们想要把数字加一,所以就有一个“加”的动作,这个动作就是一个 Action,这个 Action 就是 addAction。如果想要触发这个动作,就需要执行 dispatch 方法。

1
2
3
4
add = () => {
// 加
this.context.store.dispatch(Actions.addAction())
}

通过 dispatch 方法,把 Action 对象传给了 Reducer,经过处理,Reducer 会返回一个加 1 的新 state。

其实现在 Store 中的数据已经是最新的了,可以我们看到页面上还没有更新。那我们如何能获取到最新的 state 呢?

订阅

就像关注公众号一样,我只需要在最开始的时候订阅一下,之后每次有更新,我都会收到推送。

这个时候就要使用 Store 的 subscribe 方法了。顾名思义,就是我要订阅 state 的变化。我们先看一下代码怎么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
onChange = () => {
this.setState(this.getState())
}
componentDidMount() {
// 订阅 store 的变化
this.context.store.subscribe(this.onChange)
}
componentWillUnmount() {
// 取消订阅
this.context.store.unsubscribe(this.onChange);
}

在组件的 componentDidMount 生命周期中,我们调用了 store 的 subscribe 方法,每次 state 更新的时候,都会去调用 onChange 方法;在 onChange 方法中,我们会取得最新的 state,并且赋值。在组件被卸载的时候,我们取消订阅。

上面这样就完成了订阅功能。这时候再运行程序,可以发现页面上就会显示最新的数字了。

react-redux

在这个例子中,可以看出来我们可以抽象出来很多逻辑,比如 Provider,还有订阅 store 变化的功能。其实这些 react-redux 都已经帮我们做好了。

  • Provider: 提供包含 store 的 context
  • connect: 把 state 转化为内层组件的 props,监听 state 的变化,组件性能优化

在咱们这个例子中,只是简单的实现了一下 react-redux 部分功能。具体的大家可以到官网上去看。

总结

下面咱们来总结一下 redux 和 react 结合使用的整个数据流:

good~ 我们已经全部完成了整个应用。现在大家了解 Redux 的运行原理 了吗?

具体代码可以到 GitHub 查看。

参考资料:

支持原创