前言
本文是 Redux 及 Redux 配合 React 开发的教程,主要翻译自 Leveling Up with React: Redux,并参考了 Redux 的文档及一些博文,相对译文原文内容有增减修改。
目录
- 前言
- 目录
- 什么是 Redux,为什么使用 Redux
- Redux 的三大基本原则
- 1.唯一数据源
- 2.State 是只读的
- 3.使用纯函数来执行修改
- 第一个 Redux Store
- 不要改变原 State , 复制它
- 复合 Reducer
- Dispatch 之后哪个 Reducer 会被调用?
- Action 策略
- 初始 state 和时间旅行
- Redux with React
- 使用 react-redux 链接
- Ajax 生命周期
- 通过事件 Dispatch
- Container 组件不作为
- Provider
- 最终完成的项目
什么是 Redux,为什么使用 Redux
Rdeux 是一个用来管理数据状态和 UI 状态的 JavaScript 应用工具。随着 JavaScript 单页应用(Single Page Applications, SPAs)开发日趋复杂,JavaScript 需要管理比任何时候都要多的 state (状态),Redux 被创造用来让 state 的更易于管理。
Redux 支持 React、Angular、Ember、jQuery 甚至纯 JavaScript。尽管如此,Redux 还是和 React 和 Deku 这类框架搭配起来用最好,因为这类框架允许你以 state 函数的形式来描述界面。
如下图所示,在 React 中,UI 以组件的形式来搭建,组件之间可以嵌套组合。但 React 中组件间通信的数据流是单向的,顶层组件可以通过 props 属性向下层组件传递数据,而下层组件不能向上层组件传递数据,兄弟组件之间同样不能。这样简单的单向数据流支撑起了 React 中的数据可控性。
当项目越来越大的时候,管理数据的事件或回调函数将越来越多,也将越来越不好管理。管理不断变化的 state 非常困难。如果一个 model 的变化会引起另一个 model 变化,那么当 view 变化时,就可能引起对应 model 以及另一个 model 的变化,依次地,可能会引起另一个 view 的变化。直至你搞不清楚到底发生了什么。state 在什么时候,由于什么原因,如何变化已然不受控制。 当系统变得错综复杂的时候,想重现问题或者添加新功能就会变得举步维艰。
如果这还不够糟糕,考虑一些来自前端开发领域的新需求,如更新调优、服务端渲染、路由跳转前请求数据等等。state 的管理在大项目中相当复杂。
如下图所示,Redux 提供了一个叫 store
的统一仓储库,组件通过 dispatch
将 state 直接传入 store ,不用通过其他的组件。并且组件通过 subscribe
从 store 获取到 state 的改变。
store 就像一个管理 state 改变的“中间人”,组件之间的信息传递不必直接在彼此间进行,所有的 state 变化都通过 store 这唯一数据源。
使用了 Redux ,所有的组件都可以从 store 中获取到所需的 state, 他们也能从 store 获取到 state 的改变。这比组件之间互相传递数据清晰明朗的多。
看起来是不是有点像 Flux 。
Redux 的三大基本原则
Flux 是一种构架思想, Redux 是一种可下载的工具。Redux 可以看做 Flux 架构的的一种实现,但并不完全按照 Flux 的规则,是一个“类 Flux”的工具。
本文假定你并不是 Flux 的使用者,如果你是的话,你会发现它们间的不同,特别是在 Redux 的这三个基本原则中:
1.唯一数据源
Redux 只用唯一一个 store 储存应用所有的 state,被称为 single source of truth(唯一数据源)。
store 中的数据结构往往是个给应用使用的深度嵌套的对象
2.State 是只读的
在 Redux 的文档中这么描述:惟一改变 state 的方法就是触发 action,action 是一个用于描述已发生事件的普通对象。
这意味着应用不能直接修改 state,“action”是用来向 store 表示要修改 state 的载体。
store 对象本身只有4个简单的方法作为 API:
store.dispatch(action)
store.subscribe(listener)
store.getState()
replaceReducer(nextReducer)
如你所见,并没有直接设置 state 的方法。所以,使用 dispatch(action)
发送 action 至 store 是唯一改变 state 的方式:
1
2
3
4
5
6
7
8
|
var action = {
type: 'ADD_USER',
user: {name: 'Dan'}
};
// 假定 store 已经被创建了
store.dispatch(action);
|
dispatch()
方法发送一个对象给 Redux ,这个对象就是 action。action 可以说是一个带着 type
属性和其他数据的载体,用来更新 state。 注意 type
属性的值,设计 action 对象时你要给它命名赋值。
3.使用纯函数来执行修改
正如之前说的,Redux 不允许应用直接修改 state。它要求使用载体 action 来描述 state 的变化,通过发送 action 到 store 来改变 state。为了描述 action 如何改变 state tree ,你需要编写 reducers。
Reducer 只是一些纯函数,它接收先前的 state 和 action,并返回新的 state:
1
2
3
4
5
6
|
// Reducer Function
var someReducer = function(state, action) {
...
return state;
}
|
在设计 Reducer 应当注意按照纯函数来设计,纯函数有以下几个特征:
- 不使用外界网络或数据库调用
- 返回的值完全取决于其参数的值
- 调用具有相同的参数集的一个纯函数始终返回相同的值
总之,一个函数在程序执行的过程中只会根据输入参数给出运算结果,我们把这类函数称为“纯函数”。纯函数由于不依赖外部变量,使得给定函数输入其返回结果永远不变。
比如整数的加法函数,它接收两个整数值并返回一个整数值,对于给定的两个整数值,它的返回值永远是相同的整数值。
第一个 Redux Store
首先,我们通过 Redux.createStore()
创建一个 store ,并把所有的 reducers 都作为其参数。
我们看下这个只有一个 reducer 的示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
// 注意这儿使用 .push() 并不
// 是好的方法。只是这样比较易于展示这
// 个示例。我将会在下一节解释原因。
// Reducer 函数
var userReducer = function(state, action) {
if (state === undefined) {
state = [];
}
if (action.type === 'ADD_USER') {
state.push(action.user);
}
return state;
}
// 建立一个 store 并把 reducer 传递进去
var store = Redux.createStore(userReducer);
// 发送我们的第一个 action 来改变 state
store.dispatch({
type: 'ADD_USER',
user: {name: 'Dan'}
});
|
简短的总结下这段代码发生了什么:
- store 被建立了,并传递了一个 reducer。
- reducer 规定应用程序的初始 state 是一个空数组。
- 我们发送了一个 action,其
type
是ADD_USER
,携带的数据是user: {name: 'Dan'}
,意为添加一个名为 Dan 的新用户 - reducer 向 state 添加了新用户,并返回了更改后的 state,然后更新了 store
在这个示例中 reducer 实际上被调用了两次,一次是 store 被创建时,一次是使用 dispatch 发送 action 时。
当 store 被创建时,Redux 立即调用 reducer 并获取到 reducer 返回的初始 state。第一次调用 reducer 时 state 的值为 undefined
,于是 reducer 返回了一个空的数组作为 store 的初始 state。
Reducer 在每次传递 action 时都会被调用。Action 只是描述了有事情发生了这一事实,reducer 指明应用如何更新 state。
示例中传递 action 时第二次调用了 Reducer,Reducer 通过 action 携带的 type 属性的值 ADD_USER
来判断如何处理 state。
可以把 Reducer 想想成一个漏斗,它接收先前的 state 和 action,输出新的 state 来更新 store:
在示例之后,我们的 store 现在是个有一个 user 对象的数组:
1
2
|
store.getState(); // => [{name: 'Dan'}]
|
不要改变原 State , 复制它
上面的示例中 reducer 获取到当前的 state 后对它使用 .push()
,这是个不好的示例。当前 state 的值不应该被改变,应该新建一个数组作为新的 state 输出。而 .pish()
是一个mutation method(变异方法),它会改变原数组的值。
参数传递给 reducer 后应当是不变的,我们应该使用non-mutating methods(不变异方法) 例如 .contact()
创建数组的副本,然后处理改变并返回这个副本:
1
2
3
4
5
6
7
8
|
var userReducer = function(state = [], action) {
if (action.type === 'ADD_USER') {
var newState = state.concat([action.user]);
return newState;
}
return state;
}
|
上面这个重写的 rudecer 代码。如果添加了新用户,我们新定义了个 newState
作为输出的新 state,而没有直接对原 state 做更改。 如果没有添加新用户,则直接输出原 state。
你可能注意到这段代码中我使用了 ES2015 的语法,为了使代码看起来更简洁,接下来的所有代码我都会使用 ES2015 的语法
复合 Reducer
多数应用都会需要更复杂 state 来描述完整的功能跟数据。但 Redux 只使用唯一的 store,所以我们只能使用嵌套对象来组织 state 以应对应用不同的部分。让我们想象一下我们希望我们的 store 类似这种结构:
1
2
3
4
5
|
{
userState: { ... },
widgetState: { ... }
}
|
这依然是个‘’one store = one object“ 的应用,但这个嵌套结构的对象里包含了 userState
和 widgetState
来储存所有的数据。
为了创建一个如此的 store,我们需要给它的每个部分都定义一个 reducer:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
import { createStore, combineReducers } from 'redux';
// The User Reducer
const userReducer = function(state = {}, action) {
return state;
}
// The Widget Reducer
const widgetReducer = function(state = {}, action) {
return state;
}
// 合并 Reducers
const reducers = combineReducers({
userState: userReducer,
widgetState: widgetReducer
});
const store = createStore(reducers);
|
combineReducers()
函数的作用是把多个 reducer 合并成一个最终的 reducer 函数。现在,当每个 reducer 返回初始 state 时,state 将会分别更新到各自的 userState
或者 widgetState
部分。
谨记一下,现在每个 reducer 获得各自部分的 state, 而不是整个 store 的 state。之后 state 也将从每个 reducer 被返回至各自适用的部分。
Dispatch 之后哪个 Reducer 会被调用?
现在,reducer 的行为更像一个过滤 action 的漏斗了。当 action 被 dispatch 后,所有的 reducer 都会被调用以更新他们各自管理的 state:
每个 reducer 接收的当前 state 参数以及他们返回的升级后的 state 仅仅影响 store 中跟此 reducer 相关的部分。记住,正如上一节所说,每个 reducer 只获得与它自己相关的 state ,而不是全部的state。
Action 策略
在新建和管理 action 及 action 的 type 时可以使用一些策略,不过这一点不如本片文章中的其他内容重要。为了让这篇文章更简短,我将与 action 策略的相关内容写到了这篇GitHub repo上。
初始 state 和时间旅行
如果你看了这篇文档, 你会注意到 createStore()
的第二个参数“initial state”。这看起来像是代替 reducers 创建个初始 state。然而这个 initial state 应当仅仅用来做 state 水合(hydrate)。
hydrate(水合)
根据stackflow大神的解释,hydrate与serialization的部分操作很像,但是概念却大相径庭。serialization是将数据结构或者对象属性转化为文件或者内存buffer的过程,根据serialization生成的文件或者字节流,我们可以在另一个环境重构语义相同的对象或数据结构。而hydrate是向已创建的对象中填充数据,对于熟悉js的人来说,这不是一个陌生的概念。使用hydrate可以优化性能,比如实例化了一个对象后,只向数据库请求将要用的部分field值,那么剩余的值就不会浪费宽带和cpu,性能不就优化了吗?
用户会有刷新重置你的单页面应用或撤销操作的需求。此时你要重置清空 store 的 state 。有时我们可能并不希望清空 store。
相反,我们希望在使用了一个保持 store 不改变的策略(例如来自接口的 store 数据)之后在刷新时将这个 store 水合进 Redux 。这就是在 createStore()
中使用 initial state 的原因。
这带来了一个有趣的概念,如果能非常容易的水合之前的 state,这相当于 state 可以在 app 里“时间旅行”,这在 debugging 或者做撤销/重置功能时很有用。把你所有的 state 都放进一个 store 里是非常有意义的。
Redux with React
之前已经说过,Redux 不依赖任何框架,但与 React 这类框架配合使用时最合适的。
首先我们写写一个没有使用 Redux 的 React 组件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
import React from 'react';
import axios from 'axios';
import UserList from '../views/list-user';
const UserListContainer = React.createClass({
getInitialState: function() {
return {
users: []
};
},
componentDidMount: function() {
axios.get('/path/to/user-api').then(response => {
this.setState({users: response.data});
});
},
render: function() {
return <UserList users={this.state.users} />;
}
});
export default UserListContainer;
|
这里我们通过 Ajax 请求来更新本地 state。但是如果应用的其他组件需要基于这儿的 user list 的改变而更新,这么写显然是不足的。
使用 Redux 的话,当 Ajax 请求成功后我们可以 dispatch 一个 action 来代替 this.setState()
。这样这个组件及其他组件便都可接收到 state 更新。但这样给我们带来一个问题,我们如何通过设置 store.subscribe()
来让组件接收到新的 state。
我推荐使用官方的 React/Redux 绑定插件 react-redux 来做它们间的链接。
使用 react-redux 链接
首先 react
,redux
,react-redux
在npm 上是三个独立的模块。react-redux
让我们可以以简单实用的方式“链接” React components 及 Redux。
就像这样:
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
|
import React from 'react';
import { connect } from 'react-redux';
import store from '../path/to/store';
import axios from 'axios';
import UserList from '../views/list-user';
const UserListContainer = React.createClass({
componentDidMount: function() {
axios.get('/path/to/user-api').then(response => {
store.dispatch({
type: 'USER_LIST_SUCCESS',
users: response.data
});
});
},
render: function() {
return <UserList users={this.props.users} />;
}
});
const mapStateToProps = function(store) {
return {
users: store.userState.users
};
}
export default connect(mapStateToProps)(UserListContainer);
|
这段代码里发生了很多新事情:
- 我们从
react-redux
中引入了connect
- 仅看逻辑关联的话这段代码从下往上看可能更简单。函数
connect()
带入了两个参数,但我们仅显示了其中之一mapStateToProps()
你可能会感到很奇怪为什么这儿有两个括号
connect()()
.其实这儿是两次函数调用。首先。connect()
返回了一个函数,然后我们立刻调用了这个函数并给它传递进第二个括号内的参数。第二个参数一般是一个 React 组件,在这个示例里是我们的容器组件。
这是一种常见的“函数式编程”的写法,学习一下很有好处。 -
connect()
的第一个参数是个返回了一个对象的函数mapStateToProps
,这个对象的属性将会变成 React 组件中的“props”的属性,你可以看到他们的值来自 store 的 state。这里我把这第一个参数命名为“mapStateToProps”,如命名所示,是希望它来将全局 states 转化成本地 component 的 props。mapStateToProps()
将会接收到一个携带着完整的 Redux store 的参数store
,然后抽出本地组件需要的 states 转换成 props。 - 有了全局 state 及
mapStateToProps()
后,我们就不再需要getInitialState()
来给组件插件初始 state 了。我们使用 ‘this.props.users’ 来代替this.state.users
。现在user
数组是一个 prop 而不是 本地组件的 state。 - Ajax 的返回现在是 dispatch action 来代替更新本地组件的 state 。
这个示例看起来似乎没有 reducer 的部分。注意一下 store 有一个 userState
属性,这个属性是从哪来的?
1
2
3
4
5
|
const mapStateToProps = function(store) {
return {
users: store.userState.users
};
|
这个属性来自另外一个文件,在这里我们联合了的 reducer:
1
2
3
4
5
|
const reducers = combineReducers({
userState: userReducer,
widgetState: widgetReducer
});
|
示例里我们没有展示出来 reducer (因为 reducer 被定义在其他的文件里),而 reducer 决定了每部分 state 的属性值。为了确保 .user
的值来自 userState
,这个示例的 reducer 应当是这样:
1
2
3
4
5
6
7
8
9
10
11
|
const initialUserState = {
users: []
}
const userReducer = function(state = initialUserState, action) {
switch(action.type) {
case 'USER_LIST_SUCCESS':
return Object.assign({}, state, { users: action.users });
}
return state;
}
|
Ajax 生命周期
在示例的 Ajax 里,我们仅仅 dispatch 了一个 action,就是 USER_LIST_SUCCESS
, 在成功获取前我们可能想要 dispatch USER_LIST_REQUEST
,Ajax 失败后我们可能想要 dispatch USER_LIST_FAILED
。关于这一点可以阅读异步 Action。
通过事件 Dispatch
通过事件可以 dispatch action,以改变展示层:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
...
const mapDispatchToProps = function(dispatch, ownProps) {
return {
toggleActive: function() {
dispatch({ ... });
}
}
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(UserListContainer);
|
在展示组件里,我们可以通过 onClick={this.props.toggleActive}
来调用。
Container 组件不作为
『Container』只是一种称呼,事实上,代码中不会出现 container 这个后缀。React 项目与 Redux 结合时,需要将组件连接到 Redux 并且让它能够 dispatch actions 以及从 Redux store 读取到 state。这一部分逻辑通常放在一个叫『containers』的文件下,于是我们就称呼这些包装过的组件为 Container。
有时,Container 只需要跟 store 连接,不需要使用像 componentDidMount()
这样的方法来启动 Ajax 请求。Container 只需要通过 render()
方法将 state 传递给展示组件。这种情况下,我们可以这样构建 Container:
1
2
3
4
5
6
7
8
9
10
11
12
|
import React from 'react';
import { connect } from 'react-redux';
import UserList from '../views/list-user';
const mapStateToProps = function(store) {
return {
users: store.userState.users
};
}
export default connect(mapStateToProps)(UserList);
|
这个简短的文件就是我们新的容器组件,你可能很奇怪,容器组件在哪呢?为什么没有看到使用 React.createClass()
?
正如展示的那样,connect()
为我们创建了 Container 组件。注意这次我们直接在展示组件中传递而不是创建我们自己的容器组件传递。仔细想想容器组件是做什么的,他们存在是为了让展示组件专注于 view 而不是 state,他们也将 state 传入子展示组件作为它们的 props。这就是 connect()
在做的——传递 state(经由 props)进我们的展示组件,返回作为展示组件的 React componet。
那么之前的示例实际上是两个容器组件包裹着一个展示组件吗?是的,是可以这样理解。但这并不是一个问题,只有在我们的容器组件需要除 render()
外的更多方法时这才是必要的。
想象两个容器组件有不同的功能但又相互联系:
呵呵,可能这就是 React 的 loge 看起来像是原子的原因。
Provider
为了让 react-redux
工作,你需要通过一个 <Provider />
component 让你的应用使用 react-redux
。让这个 component 包裹着你全部的 React 应用。如果你也有使用 React Router,那么代码看起来应该是这个样子:
1
2
3
4
5
6
7
8
9
10
11
|
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import store from './store';
import router from './router';
ReactDOM.render(
<Provider store={store}>{router}</Provider>,
document.getElementById('root')
);
|
最终完成的项目
本文最终完成的项目是个如下图所示的单页面应用: