原文:https://www.sitepoint.com/how-to-build-a-todo-app-using-react-redux-and-immutable-js/
作者: Dan Prince 发布时间:2017.09.13
React 使用组件的方式和单向数据流使它非常适合用户界面结构的描述,然而,用于处理状态的工具故意保持得很简单——这是为了提醒我们,React 只是传统的 Model-View-Controller 体系结构中的 View。
没有什么可以阻止我们仅使用 React 来构建大型应用,但是我们很快就会发现,为了保持代码的简洁,我们需要在其它位置去管理应用的状态。
尽管没有官方解决方案来处理应用状态,但是有些库特别适合 React 的范例,在本文中,我们就将 React 与两个这样的库配对,并使用它们来构建一个简单的应用程序。
Redux
Redux 是一个轻量级的库,充当应用程序状态管理的容器,它结合了 Flux 和 Elm 的想法。我们可以使用Redux来管理任何类型的应用状态,只要遵循以下条件:
- 我们的状态保存在唯一的
store中 - 变化来自
actions(行动)而不是mutations(突变)(译者注:mutations意指直接修改引用所指向的值)
Redux 中 store 的核心是一个函数,这个函数接收当前应用的状态(state)和一个动作(action)参数,并将它们组合以创建出新的状态,我们称这个函数为 reducer。
React 组件负责将 action 发送到 store,然后 store 告诉组件何时需要重新渲染。
ImmutableJS
由于 Redux 不允许以 mutate (突变)方式更改状态,因此通过使用 Immutable (不可变)数据结构对状态进行建模可以强制执行此操作。
ImmutableJS 为我们提供了许多具有可变接口的不可变数据结构,它们的有效实现受到了 Clojure 和 Scala 中的实现的启发。(译者注:Clojure 是 Lisp 编程语言编程语言在 Java 平台上的现代、动态及函数式方言;Scala 是一门多范式的编程语言,设计初衷是要集成面向对象编程和函数式编程的各种特性。)
Demo
我们将使用 React 结合 Redux 和 ImmutableJS 来构建一个简单的待办事项列表,允许我们添加待办事项,并在完成和未完成之间切换。
See the Pen How to Build a Todo App Using React, Redux, and Immutable.js by SitePoint (@SitePoint) on CodePen.
该示例代码仓库:https://github.com/sitepoint-editors/immutable-redux-todo
安装
我们将从创建项目文件夹并使用 npm init 初始化 package.json 文件开始,然后,安装所需的依赖项:
1
2
$ npm install --save react react-dom redux react-redux immutable
$ npm install --save-dev webpack babel-core babel-loader babel-preset-es2015 babel-preset-react
我们将使用 JSX 和 ES2015,因此使用 Babel 编译代码,并将其作为 Webpack 模块捆绑过程的一部分。
首先,创建 webpack.config.js 配置文件并添加 Webpack 配置:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
module.exports = {
entry: './src/app.js',
output: {
path: __dirname,
filename: 'bundle.js'
},
module: {
loaders: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: 'babel-loader',
query: { presets: [ 'es2015', 'react' ] }
}
]
}
};
最后,我们在 package.json 添加一个 npm 脚本来编译代码:
1
2
3
"script": {
"build": "webpack --debug"
}
每次代码编译时,我们都需要运行 npm run build 来实现编译。
React 和组件
在我们实现任何组件之前,创建一些虚拟数据会有所帮助,这有助于我们了解需要使用哪些组件来渲染:
1
2
3
4
5
6
const dummyTodos = [
{ id: 0, isDone: true, text: 'make components' },
{ id: 1, isDone: false, text: 'design actions' },
{ id: 2, isDone: false, text: 'implement reducer' },
{ id: 3, isDone: false, text: 'connect components' }
];
这个应用中,我们只需要两个 React 组件:<Todo /> 和 <TodoList />。
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
// src/components.js
import React from 'react';
export function Todo(props) {
const { todo } = props;
if(todo.isDone) {
return <strike>{todo.text}</strike>;
} else {
return <span>{todo.text}</span>;
}
}
export function TodoList(props) {
const { todos } = props;
return (
<div className='todo'>
<input type='text' placeholder='Add todo' />
<ul className='todo__list'>
{todos.map(t => (
<li key={t.id} className='todo__item'>
<Todo todo={t} />
</li>
))}
</ul>
</div>
);
}
此时,我们可以通过在项目文件夹中创建一个 index.html 文件并使用以下布局结构来测试这些组件(您可以在 GitHub 上找到一个简单的样式表):
1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="style.css">
<title>Immutable Todo</title>
</head>
<body>
<div id="app"></div>
<script src="bundle.js"></script>
</body>
</html>
我们还将需要一个应用程序入口点 src/app.js:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/app.js
import React from 'react';
import { render } from 'react-dom';
import { TodoList } from './components';
const dummyTodos = [
{ id: 0, isDone: true, text: 'make components' },
{ id: 1, isDone: false, text: 'design actions' },
{ id: 2, isDone: false, text: 'implement reducer' },
{ id: 3, isDone: false, text: 'connect components' }
];
render(
<TodoList todos={dummyTodos} />,
document.getElementById('app')
);
使用 npm run build 编译代码,然后在浏览器中访问 index.html 文件,并确保它可以正常工作。
Redux 和 Immutable
现在我们已经可以看到界面了,接下来就可以开始考虑其背后的 state。我们的虚拟数据是一个很好的起点,可以轻松地将其转换为 ImmutableJS 集合:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { List, Map } from 'immutable';
// 译者注:
// List 类似于原生 JavaScript 中的 Array,
// Map 类似于原生 JavaScript 中的普通对象
// 利用 List 和 Map 可以轻松的将 JavaScript
// 中的数组和对象转换为 Immutable Data
const dummyTodos = List([
Map({ id: 0, isDone: true, text: 'make components' }),
Map({ id: 1, isDone: false, text: 'design actions' }),
Map({ id: 2, isDone: false, text: 'implement reducer' }),
Map({ id: 3, isDone: false, text: 'connect components' })
]);
ImmutableJS 映射与 JavaScript 对象的工作方式不同,因此我们需要对组件进行一些细微调整:任何以前通过属性调用访问的地方(例如 todo.id)需要替换为方法调用的方式(todo.get('id'))。
设计 Actions
现在我们已经弄清楚了数据结构,可以开始考虑对其进行更新的操作(actions)。在这种情况下,我们只需要执行两项操作(action):一项为添加新的待办事项,另一项为切换现有的待办事项。
让我们定义一些函数(译者注:我们可以将这些函数称为 action creator)来创建这些 action:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// src/actions.js
// 生成唯一的 ids
const uid = () => Math.random().toString(34).slice(2);
export function addTodo(text) {
return {
type: 'ADD_TODO',
payload: {
id: uid(),
isDone: false,
text: text
}
};
}
export function toggleTodo(id) {
return {
type: 'TOGGLE_TODO',
payload: id
}
}
每个 action 只是一个具有类型(type)和有效负载(payload)属性的 JavaScript 对象。type 属性可帮助我们决定以后处理 action 时如何处理 payload。
设计 Reducer
我们已经知道了 state (状态)的数据结构以及对其进行更新的 action,就可以构建 reducer 了,提醒一下,reducer 是一个函数,它接受一个 state 和一个 action 作为参数,然后使用它们来计算新的 state。
这是我们 reducer 函数的初始结构:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/reducer.js
import { List, Map } from 'immutable';
const init = List([]);
export default function(todos=init, action) {
switch(action.type) {
case 'ADD_TODO':
// …
case 'TOGGLE_TODO':
// …
default:
return todos;
}
}
处理 ADD_TODO 动作非常简单,因为可以使用 .push() 方法,该方法将返回一个新的 List 对象,并在末尾附加 todo 对象:
1
2
case 'ADD_TODO':
return todos.push(Map(action.payload));
注意,在将 todo 对象追加到 List 之前,我们还应将其转换为 immutable 的 Map 对象。
我们需要处理的更复杂的操作是 TOGGLE_TODO:
1
2
3
4
5
6
7
8
case 'TOGGLE_TODO':
return todos.map(t => {
if(t.get('id') === action.payload) {
return t.update('isDone', isDone => !isDone);
} else {
return t;
}
});
使用 .map() 方法遍历列表,并找到 id 与操作匹配的待办事项。然后调用 .update() 方法,该方法传递一个 key 和一个 updater 函数,返回更新后的新副本。update() 方法将指定 key 的值替换为 updater 函数的返回值,而 updater 函数又以 key 的初始值作为参数传递(译者注:即 updater 函数以key 的初始值作为参数,在函数主体内可能基于该 key 值运算生成新的结果,然后返回生成的结果,再经由 update() 方法调用时以 updater 函数生成的结果替换 Map 对象的 key 值)。
查看文字版本的示例可能会有所帮助:
1
2
3
const todo = Map({ id: 0, text: 'foo', isDone: false });
todo.update('isDone', isDone => !isDone);
// => { id: 0, text: 'foo', isDone: true }
将一切连接起来
现在我们已经准备好了 Actions 和 Reducer,可以创建一个 store 并将其连接到我们的 React 组件中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/app.js
import React from 'react';
import { render } from 'react-dom';
import { createStore } from 'redux';
import { TodoList } from './components';
import reducer from './reducer';
const store = createStore(reducer);
render(
<TodoList todos={store.getState()} />,
document.getElementById('app')
);
我们需要使组件知道 store,将使用 react-redux 来简化此过程,它使我们能够创建可感知包装组件的存储感知(store-aware)容器,而无需更改原始的实现。
我们将需要在 <TodoList /> 组件外放置一个容器,让我们来看看它是什么样的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/containers.js
import { connect } from 'react-redux';
import * as components from './components';
import { addTodo, toggleTodo } from './actions';
export const TodoList = connect(
function mapStateToProps(state) {
// …
},
function mapDispatchToProps(dispatch) {
// …
}
)(components.TodoList);
我们使用 connect 函数创建容器,调用 connect() 函数时传递了两个函数参数:mapStateToProps() 和 mapDispatchToProps()。
mapStateToProps() 函数将 store 的当前状态(state)作为参数(在示例中是待办事项列表 todos),返回值是一个对象,该对象描述了从 state 到包装组件的 props 的映射:
1
2
3
function mapStateToProps(state) {
return { todos: state };
}
比起这种方式,直接形象化的在包装好的 React 组件实例上绑定属性更有帮助(译者注:connect() 中调用 mapStateToProps() 方法相当于如下示例的意思):
1
<TodoList todos={state} />
我们还需要提供一个 mapDispatchToProps() 函数,该函数会传递 store 的 dispatch() 方法,以便我们可以使用它来调度 action creator 所创建的 action:
1
2
3
4
5
6
function mapDispatchToProps(dispatch) {
return {
addTodo: text => dispatch(addTodo(text)),
toggleTodo: id => dispatch(toggleTodo(id))
};
}
再次,形象化的在包装好的 React 组件实例上绑定这些方法可能更有帮助:
1
2
3
<TodoList todos={state}
addTodo={text => dispatch(addTodo(text))}
toggleTodo={id => dispatch(toggleTodo(id))} />
现在,我们已将组件映射到 action creators,可以从事件处理程序中调用它们:
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
export function TodoList(props) {
const { todos, toggleTodo, addTodo } = props;
const onSubmit = (event) => {
const input = event.target;
const text = input.value;
const isEnterKey = (event.which == 13);
const isLongEnough = text.length > 0;
if(isEnterKey && isLongEnough) {
input.value = '';
addTodo(text);
}
};
const toggleClick = id => event => toggleTodo(id);
return (
<div className='todo'>
<input type='text'
className='todo__entry'
placeholder='Add todo'
onKeyDown={onSubmit} />
<ul className='todo__list'>
{todos.map(t => (
<li key={t.get('id')}
className='todo__item'
onClick={toggleClick(t.get('id'))}>
<Todo todo={t.toJS()} />
</li>
))}
</ul>
</div>
);
}
容器将自动订阅 store 中的更改,并且一旦映射的属性发生更改,它们就会重新渲染包装的组件。
最后,我们需要使用 <Provider /> 组件使容器知道 store:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// src/app.js
import React from 'react';
import { render } from 'react-dom';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import reducer from './reducer';
import { TodoList } from './containers';
// ^^^^^^^^^^
const store = createStore(reducer);
render(
<Provider store={store}>
<TodoList />
</Provider>,
document.getElementById('app')
);
总结
不可否认,React 和 Redux 周围的生态系统可能非常复杂,并且对初学者构成学习障碍,但好消息是,几乎所有这些概念都是可以移植的。我们仅仅才接触到 Redux 架构的表面,但是我们已经看到足够多的知识来帮助我们开始学习 Elm 架构,或者选择像 Om 或 Re-frame 这样的 ClojureScript 库。同样地,我们只看到了一小部分不可变数据的可能性,但是现在我们有了更好的条件来开始学习诸如 Clojure 或 Haskell 这样的语言。
无论您是只是探索 Web 应用程序开发中的状态,还是整日编写 JavaScript,基于 action 的体系结构和 immutable data 的经验都已成为开发者的一项重要技能,并且现在是学习这些基础知识的好时机。