原文: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
的经验都已成为开发者的一项重要技能,并且现在是学习这些基础知识的好时机。