首页 Redux搭配React完成迷你型任务管理应用 Todo List
文章
取消

Redux搭配React完成迷你型任务管理应用 Todo List

Redux官网cn.redux.js.org中有一个小例子:Todo List。官方源码地址:redux-todos
此文记录了自己完成此例子的过程。

1. 使用create-react-app快速构建单页面应用

create-react-app能够让我们省去安装和配置webpack、babel等工作,从而能够让我们把精力放在代码上。
安装过程如下:

1
npx create-react-app 01-todos

安装完成后:

1
2
cd 01-todos
yarn start

然后我们可以在浏览器地中输入http://localhost:3000/来访问我们的页面了。
初始的文件结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
01-todos/
  README.md
  node_modules/...
  package.json
  public/
    index.html
    favicon.ico
  src/
    App.css
    App.js
    App.test.js
    index.css
    index.js
    logo.svg

注意:

  • 1) 其中有两个文件不能删除:
    • public/index.html:模板文件;
    • src/index.js:JS入口文件。
  • 2) Webpack只能处理src文件夹中的文件,所以JS和CSS文件要放在src中,或其子目录中;
  • 3) public/index.html只能引入public文件夹中的文件。

2. 准备工作

2.1 删除、修改文件

删除不需要的文件后,文件结构如下:

1
2
3
4
5
6
7
8
9
01-todos/
  node_modules/...
  package.json
  yarn.lock
  .gitignore
  public/
    index.html
  src/
    index.js

修改public/index.html:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta name="theme-color" content="#000000">
    <title>Todo List</title>
  </head>
  <body>
    <noscript>
      You need to enable JavaScript to run this app.
    </noscript>
    <div id="root"></div>
  </body>
</html>

修改src/index.js

1
2
3
4
import React from 'react';
import ReactDOM from 'react-dom';

ReactDOM.render(<div>test</div>, document.getElementById('root'));

重新运行yarn start,用浏览器访问http://localhost:3000/,页面中出现test

2.2 新增文件夹

src文件夹中新建如下文件夹:

  • actions:存放actions创建函数的文件;
  • components:存放描述如何展现的展示组件;
  • containers:存放描述如何运行的容器组件;
  • reducers:存放处理actions的reducer文件。

修改后文件结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
01-todos/
  node_modules/...
  package.json
  yarn.lock
  .gitignore
  public/
    index.html
  src/
    actions/
    components/
    containers/
    reducers/
    index.js

2.3 安装库

  • 1) 安装redux:
    1
    
    yarn add redux
    

    若使用npm工具安装:

    1
    
    npm install --save redux
    
  • 2) 安装react-redux:
    1
    
    yarn add react-redux
    
  • 3) 安装prop-types:
    1
    
    yarn add prop-types
    

3. 逻辑代码

Todo List的界面如下:
图片
此页面有如下三个功能:

  1. 在输入框中输入内容,点击Add Todo按钮后,新增一条待办事项;
  2. 在某一条待办事项上单击鼠标,此事项会在两个状态之间切换:未完成/已完成,删除线表示已完成;
  3. 页面下方有三个筛选按钮,可以用来显示所有、显示未完成或显示已完成;

Todo List需要保存两种不同的数据:

  • 当前选中的任务过滤条件;
  • 完整的任务列表。

因此state的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
  visibilityFilter: 'SHOW_ALL',
  todos: [
    {
      text: 'Consider using Redux',
      completed: true,
    },
    {
      text: 'Keep all state in a single tree',
      completed: false
    }
  ]
}

3.1 Reducer

接下来我们要创建reducers,实现上述三个功能。
reducer 是一个纯函数,接收旧的 state 和 action,返回新的 state。
所有的reducer放在reducers文件夹中,结构如下图: 图片

3.1.1 todo.js

reducers/todo.js负责处理state.todos数组:

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 {ADD_TODO, TOGGLE_TODO} from '../actions'

// 传入(拆分后的)state:state.todos
const todos = (state = [], action) => {
    switch (action.type){
        case ADD_TODO:
            return [
                ...state,
                {
                    text: action.text,
                    completed: false
                }
            ];
        case TOGGLE_TODO:
            return state.map((todo, index) => {
                if(index === action.index){
                    return {
                        ...todo,
                        completed: !todo.completed
                    }
                }
                return todo;
            });
        default:
            return state;
    }
};

export default todos;

3.1.2 visibilityFilter.js

reducers/visibilityFilter.js负责处理state.visibilityFilter:

1
2
3
4
5
6
7
8
9
10
11
12
import {SET_VISIBILITY_FILTER, VisibilityFilters} from '../actions'

// 传入(拆分后的)state:state.visibilityFilter
const visibilityFilter = (state = VisibilityFilters.SHOW_ALL, action) => {
    switch (action.type){
        case SET_VISIBILITY_FILTER:
            return action.filter;
        default:
            return state;
    }
};
export default visibilityFilter;

3.1.3 index.js

reducers/index.js负责使用combineReducers()把todos.js和visibilityFilter.js合并成一个reducer:

1
2
3
4
5
6
7
8
9
import {combineReducers} from 'redux';
import todos from './todos.js';
import visibilityFilter from './visibilityFilter.js';

const rootReducer = combineReducers({
    todos,
    visibilityFilter
});
export default rootReducer;

3.2 Actions

我们可以看到在3.1.1的todos.js和3.1.2的visibilityFilter.js中引入了../actions文件夹中的函数和变量:
注:actions文件夹中有index.js,所以实际引入的是../actions/index.js

1
2
3
4
// todos.js中:
import {ADD_TODO, TOGGLE_TODO} from '../actions'
// visibilityFilter.js中
import {SET_VISIBILITY_FILTER, VisibilityFilters} from '../actions'

这是因为官网中推荐我们使用单独的文件/文件夹来存放actions,方便维护。

3.2.1 index.js

actions/index.js中存放着常量和创建action的函数。
actions/index.js:

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
// 导出常量:action.type中的三个值
export const ADD_TODO = 'ADD_TODO';// 新增待办事项
export const TOGGLE_TODO = 'TOGGLE_TODO';// 修改待办事项
export const SET_VISIBILITY_FILTER = 'SET_VISIBILITY_FILTER';// 设置筛选条件

// 导出其他常量: state.visibilityFilter中的三个值
export const VisibilityFilters = {
    SHOW_ALL: 'SHOW_ALL',// 显示全部
    SHOW_COMPLETED: 'SHOW_COMPLETED',// 显示已完成
    SHOW_UNCOMPLETED: 'SHOW_UNCOMPLETED'// 显示未完成
};

// 创建actions的函数:
// 1.创建新增待办事项的action
export function addTodo (text) {
    return {
        type: ADD_TODO,
        text// text: text的简写
    }
}
// 2.创建修改待办事项的action
export function toggleTodo (index) {
    return {
        type: TOGGLE_TODO,
        index
    }
}
// 3. 创建修改筛选条件的action
export function setVisibilityFilter (filter){
    return {
        type: SET_VISIBILITY_FILTER,
        filter
    }
}

3.3 测试逻辑代码

src/index.js文件中新增测试代码,来检测逻辑是否合理。
src/index.js:

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
import React from 'react';
import ReactDOM from 'react-dom';
import App from './components/App.js';

/*************************新增测试用代码*************************/
import {createStore} from 'redux';
import rootReducer from './reducers';
// 引入创建action的函数
import {addTodo, toggleTodo, setVisibilityFilter} from './actions'
//创建store
let store = createStore(rootReducer);

// 设置监听:每次 state 更新时,打印日志
// 注意 subscribe() 返回一个函数用来注销监听器
const unsubscribe = store.subscribe(()=> {
    console.log(store.getState());
});
// 新增待办事项
store.dispatch(addTodo('新建待办事项1'));
store.dispatch(addTodo('新建待办事项2'));
store.dispatch(addTodo('新建待办事项3'));
// 把第一个待办事项标记为:已完成(completed: true)
store.dispatch(toggleTodo(0));
// 把筛选条件改为 visibilityFilter: "SHOW_COMPLETED"
store.dispatch(setVisibilityFilter('SHOW_COMPLETED'));

// 取消监听
unsubscribe();
/*************************测试代码 END*************************/

ReactDOM.render(<App />, document.getElementById('root'));

修改完成后,在浏览器中打开http://localhost:3000/的Console页面,查看输出信息: 图片

4. 组件

Redux 的 React 绑定库是基于容器组件展示组件相分离的开发思想。
它们的区别如下:

区别 展示组件 容器组件
作用 描述如何展现(骨架、样式) 描述如何运行(数据获取、状态更新)
直接使用 Redux
数据来源 props 监听 Redux state
数据修改 从 props 调用回调函数 向 Redux 派发 actions
调用方式 手动 通常由 React Redux 生成

Todo List的界面如下:
图片

Todo List各组件之间的关系如下:
图片

4.1 入口文件中传入Store

所有容器组件都可以访问 Redux store,所以可以手动监听它。一种方式是把它以 props 的形式传入到所有容器组件中。但这太麻烦了,因为必须要用 store 把展示组件包裹一层,仅仅是因为恰好在组件树中渲染了一个容器组件。

建议的方式是使用指定的 React Redux 组件 <Provider> 来 魔法般的 让所有容器组件都可以访问 store,而不必显式地传递它。只需要在渲染根组件时使用即可。

所以我们修改入口文件src/index.js(记得删除前面编写的测试用代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import React from 'react';
import ReactDOM from 'react-dom';
import {createStore} from 'redux';
import { Provider } from 'react-redux'
import rootReducer from './reducers';
import App from './components/App.js';

//创建store
let store = createStore(rootReducer);
// 使用指定的 React Redux 组件 <Provider> 来让所有容器组件
// 都可以访问 store,而不必显式地传递它。只需要在渲染根组件时使用即可。
ReactDOM.render(
    <Provider store={store}>
        <App />
    </Provider>,
    document.getElementById('root')
);

4.2 根组件 App

三大子组件AddTodoVisibleTodoListFilter最终都引入到了根组件App中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import './App.css';
import React, {Component} from 'react';
import AddTodo from '../containers/AddTodo.js'
import VisibleTodoList from '../containers/VisibleTodoList.js';
import Filter from './Filter.js'

class App extends Component{
    render() {
        return <div>
            <AddTodo />
            <VisibleTodoList />
            <Filter />
        </div>
    }
}
export default App;

4.3 实现容器组件

在编写组件之前,我们得先了解如何实现容器组件,把组件和Redux store关联起来:

  • Store中的State数据发生变化时,相应的组件重新渲染;
  • 在组件中派发actions时,修改Store中相应的State数据。

官网建议使用 React Redux 库的 connect() 方法来生成容器组件,这个方法做了性能优化来避免很多不必要的重复渲染。

4.3.1 函数connect()

connect([mapStateToProps], [mapDispatchToProps], [mergeProps], [options])(MyComp)
函数connect返回一个容器组件,能够让组件MyComp使用由store传入的数据和方法。

4.3.2 函数mapStateToProps

mapStateToProps(state, [ownProps]): stateProps
将 store 中的数据作为 props 绑定到组件上。
第二个可选参数 ownProps ,是组件自己的 props。

1
2
3
4
5
const mapStateToProps = state => {
  return {
    name: value// value通常为state中的某一部分数据
  }
}

组件MyComp中可以使用this.props.name,其值为value

4.3.3 函数mapDispatchToProps

mapDispatchToProps(dispatch, [ownProps]): dispatchProps
将 action 作为 props 绑定到组件上。
第二个可选参数 ownProps 同上。

1
2
3
4
5
6
const mapDispatchToProps = dispatch => {
  return {
    name: func// func中可以使用dispach()来发出action
    }
  }
}

组件中MyComp可以使用this.props.name,来调用函数func

4.4 新增事项组件

AddTodo是一个混合型的小组件,目前没必要把它拆分成两个组件。
containers/AddTodo.js:

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
42
43
import React, {Component} from 'react';
import {connect} from 'react-redux';
import {addTodo} from "../actions";
import PropTypes from 'prop-types';

class AddTodo extends Component{
    render() {
        return <div>
            <input
                ref="input"
                type="text"
                style={ {marginRight: '5px'} }
            />
            <input
                type="button"
                value="添加待办事项"
                onClick={this.add.bind(this)}
            />
        </div>
    }
    // 定义点击添加待办事项方法
    add() {
        let text = this.refs['input'].value.trim();
        this.props.addTodo(text);// 调用store传入的方法addTodo()
        this.refs['input'].value = '';
    }
}

// 定义mapDispathToProps,分发action,将addTodo函数作为props传给组件
const mapDispatchToProps = dispatch => {
    return {
        addTodo: text => {
            dispatch(addTodo(text));
        }
    }
};

// 使用 PropTypes 进行类型检查
AddTodo.propTypes = {
    addTodo: PropTypes.func.isRequired
};
// 注意mapDispatchToProps为第二个参数,第一个参数为空。
export default connect(null, mapDispatchToProps)(AddTodo);

4.5 显示事项组件

根据前面的组件结构图,三个组件关系为:
VisibleTodoList.js –> TodoList.js –> Todo.js

4.5.1 VisibleTodoList

容器组件VisibleTodoList中引入了展示组件TodoList,然后使用connect(...args)(TodoList)传给TodoList组件两个属性:

  • 1) filteredTodos:根据state.visibilityFilterstate.todos数组中筛选出满足条件的数组;
  • 2) toggle():发送修改todo.completed的action,待办事项被点击时调用此函数。

containers/VisibleTodoList.js:

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
42
import {connect} from 'react-redux';
import TodoList from '../components/TodoList.js';
import {toggleTodo, VisibilityFilters} from "../actions";

// 定义函数getFilteredTodos:根据条件筛选todos数组
function getFilteredTodos (state) {
    // 筛选条件:'SHOW_ALL'、'SHOW_COMPLETED'、'SHOW_UNCOMPLETED'
    const filter = state.visibilityFilter;
    switch (filter) {
        case VisibilityFilters.SHOW_ALL:
            return state.todos;
        case VisibilityFilters.SHOW_COMPLETED:
            return state.todos.filter((todo) => {
                return todo.completed;
            });
        case VisibilityFilters.SHOW_UNCOMPLETED:
            return state.todos.filter((todo) => {
                return !todo.completed;
            });
        default:
            throw new Error('Unknown Filter: ' + filter);
    }
}

// 定义mapStateToProps,把store.state中筛选后的todos映射到组件
const mapStateToProps = state => {
    return {
        filteredTodos: getFilteredTodos(state)
    }
};

// 定义mapDispathToProps,分发action,将toggle函数作为props传给组件
const mapDispatchToProps = dispatch => {
    return {
        toggle: id => {
            dispatch(toggleTodo(id))
        }
    }
};

const VisibleTodoList = connect(mapStateToProps, mapDispatchToProps)(TodoList);
export default VisibleTodoList;

4.5.2 TodoList

TodoList拿到容器组件VisibleTodoList传递过来的filteredTodostoggle()后,根据数组filteredTodos渲染引入进来的子组件Todo,并把toggle()传递给了Todo.js
components/TodoList.js:

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
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import Todo from './Todo.js';

class TodoList extends Component{
    render() {
        return <ul>
            {this.todosItem(this.props.filteredTodos)}
        </ul>
    }
    // todosItem(): 根据数组返回一个包含li元素的数组
    todosItem(filteredTodos) {
        let todosItem = filteredTodos.map((todo, index) => {
            return <Todo
                key={index}
                index={index}
                text={todo.text}
                completed={todo.completed}
                toggle={this.props.toggle}
            />
        });
        return todosItem;
    }
}
// 使用 PropTypes 进行类型检查
TodoList.propTypes={
    // PropTypes.arrayOf: 一个指定元素类型的数组
    // PropTypes.shape: 一个指定属性及其类型的对象
    filteredTodos: PropTypes.arrayOf(PropTypes.shape({
        text: PropTypes.string,
        completed: PropTypes.bool
    }).isRequired).isRequired,
    toggle: PropTypes.func.isRequired
};
export default TodoList;

4.5.2 Todo

Todo拿到了TodoList传递过来的toggle()后,渲染<li>元素,并给其添加一个点击函数,调用toggle()
components/Todo.js:

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
import React, {Component} from 'react';
import PropTypes from 'prop-types';

class Todo extends Component{
    render() {
        return <li
            onClick={this.toggle.bind(this)}
            style={ {textDecoration: this.props.completed ? 'line-through': 'none'} }>
            <a href="javascript:;">{this.props.text}</a>
        </li>
    }
    // 定义li元素被点击时执行的函数toggle()
    toggle() {
        this.props.toggle(this.props.index);
    }
}
// 使用 PropTypes 进行类型检查
Todo.propTypes = {
    index: PropTypes.number.isRequired,
    text: PropTypes.string.isRequired,
    completed: PropTypes.bool.isRequired,
    toggle: PropTypes.func.isRequired
};

export default Todo;

4.6 筛选事项组件

根据前面的组件结构图,三个组件关系为:
Filter.js –> FilterButton.js –> Button.js

4.6.1 Filter

展示组件Filter中引入容器组件FilterButton,然后根据数组buttons渲染出包含三个FilterButton组件的<div>元素。

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
import React, {Component} from 'react';
import FilterButton from '../containers/FilterButton.js';
import {VisibilityFilters} from '../actions';

class Filter extends Component{
    render() {
        return <div>
            显示:
            {this.getButtons()}
        </div>
    }
    // getButtons():根据数组buttons生成一个包含FilterButton组件的数组。
    getButtons() {
        let buttons = [
            {name: '全部', filter: VisibilityFilters.SHOW_ALL},
            {name: '未完成', filter: VisibilityFilters.SHOW_UNCOMPLETED},
            {name: '已完成', filter: VisibilityFilters.SHOW_COMPLETED}
        ];
        return buttons.map((button, index) => {
            return <FilterButton
                key={index}
                name={button.name}
                filter={button.filter}
            />
        })
    }
}
export default Filter;

4.6.2 FilterButton

容器组件FilterButton中引入了展示组件Button,然后使用connect(...args)(Button)传给Button组件两个属性:

  • active:true/false,Button组件通过disabled={this.props.active}控制button元素是否可以被点击;
  • setFilter():发送修改state.visibilityFilter的action,Button组件中的button元素被点击时调用此函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import {connect} from 'react-redux';
import {setVisibilityFilter} from '../actions';
import Button from '../components/Button.js';

// 定义mapStateToProps,把active属性映射到组件
const mapStateToProps = (state, ownProps) => {
    return {
        active: state.visibilityFilter === ownProps.filter
    }
};

// 定义mapDispathToProps,分发action,将filter()作为props传给组件
const mapDispatchToProps = (dispatch, ownProps) => {
    return {
        setFilter: () => {
            dispatch(setVisibilityFilter(ownProps.filter))
        }
    }
};

const FilterButton = connect(mapStateToProps, mapDispatchToProps)(Button);
export default FilterButton;

4.6.3 Button

展示组件Button根据传入的属性渲染button元素。

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, {Component} from 'react';
import PropTypes from 'prop-types';

class Button extends Component{
    render() {
        return <button
            className='button'
            disabled={this.props.active}
            onClick={this.setFilter.bind(this)}>
            {this.props.name}
        </button>
    }
    // 设置点击按钮调用的函数
    setFilter() {
        this.props.setFilter();
    }
}
// 使用 PropTypes 进行类型检查
Button.propTypes = {
    name: PropTypes.string.isRequired,
    active: PropTypes.bool.isRequired,
    setFilter: PropTypes.func.isRequired
};
export default Button;
本文由作者按照 CC BY 4.0 进行授权

在webpack中使用react和babel

Redux搭配React完成包含异步action的应用 Async Subreddit