前面的四个专栏,我们从理论出发讲到了 Redux 涉及到的三大核心概念:存储应用数据状态并提供相关接口方法的 Store、描述修改数据状态动作的 action 和修改数据状态逻辑的纯函数 reducer。关于这三大基础概念之间的关系和 component 组件之间的数据扭转,下面这张图很好的进行了展示。
光说不练假把式!概念说的多了,大家可能不喜欢或者学习起来会感到疲惫。那么接下来,我们就进行实操,实践一下我们之前学到的概念知识,实现一个简单的 todo list 应用。
环境搭建
因为项目的简单性,我们就使用 create-react-app 初始化一个项目环境吧。如果本机安装了 create-react-app,直接在终端运行 create-react-app react-redux-todo 就可以初始化项目了。如果本机没有安装,但是你的 node 版本在 5.2 以上,那么恭喜你,你可以直接运行 npx create-react-app react-redux-todo 来初始化项目了。
当然,你也可以直接 clone 我已经写好的项目:
$ git clone https://github.com/YuQue-Case-Library/Redux-Case.git
$ react-redux-todo && npm install
$ npm start
当运行完 npm start 后,服务会自动拉起浏览器,打开 http://localhost:3000/ 页面,页面展示基本如下:
目录结构
项目初始化好后,在 src 目录下建立如下的目录结构,以便我们后续的开发:
|- src
|-- actions // 存放项目涉及的 action
|-- components // 存放项目的所有组件页面
|-- reducers // 存放项目涉及的 reducer
|-- store // 存放项目涉及的数据状态
项目目录划分比较粗略,如果项目比较大,设计到的 action 比较多的话,可以单独建一个 constant.js 文件来维护 action type 常量。
功能分析
为了更好的开展后续的开发工作,这里我们先根据上面给出的示例图分析页面的功能。首先肯定是展示列表数据了,我们可以通过 store.getState() 方法获取当前应用的状态数据,初始化的列表数据肯定是没有的,这是我们就需要添加数据了;然后就是添加数据的操作;单条数据可以进行删除,有一个删除数据的操作;单条数据的状态可以进行切换,即由未完成切换到已完成,由已完成切换到未完成,有个状态切换的操作。当然,为了查看当前单个状态的数据列表,还需要有个筛选操作,筛选出已完成或未完成或全部的列表数据。
应用的功能都分析好了,接下来,我们可以定义 action 或者 actionCreator 了。
构造 Action / ActionCreator
上面对应用的功能进行了简单的分析,发现有增、删、改和过滤这四个操作,我们可以根据这四个操作定义四个 action。为了减少代码冗余,我这里定义了四个 actionCreator。当然,你还可以根据自己的实际需求进行进一步的简化和封装。
// 为了区分数据 id,我们使用了一个自增字段
let nextTodoId = 0
// 数据新增
export const addTodo = text => ({
type: 'ADD_TODO',
id: nextTodoId++,
text
})
// 数据删除
export const deleteTodo = id => ({
type: 'REMOVE_TODO',
id,
})
// 修改数据状态
export const toggleTodo = id => ({
type: 'TOGGLE_TODO',
id,
})
// 数据筛选
export const setVisibilityFilter = filter => ({
type: 'SET_VISIBILITY_FILTER',
filter
})
构造 Reducer
应用涉及到的 action 都构建好了,我们可以通过这些 action 和初始化的 state 构造应用的 reducers 了。
在前面对应用涉及到的 action 分析,我们不难发现这个小小的应用涉及到了两类 action,一类是对 todo 数据的操作,有对数据的增删改;第二类是对数据的过滤操作。最后,为了方便代码管理维护,还有为了演示 reducer 的拆分与合并,这里我们会定义两个 reducer,然后将他们进行合并处理。
// 对 todo 数据本身的增删改操作
const todos = (state = [], action) => {
switch (action.type) {
case 'ADD_TODO':
return [
...state,
{
id: action.id,
text: action.text,
completed: false
}
]
case 'REMOVE_TODO':
if (item.id) {
return state.filter(item => item.id !== action.id)
}
return []
case 'TOGGLE_TODO':
return state.map(item => {
if (item.id === action.id) {
return {
...item,
completed: !item.completed
}
}
return item
})
default:
return state
}
}
export default todos
// 对数据过滤操作
const visibilityFilter = (state = 'SHOW_ALL', action) => {
switch (action.type) {
case 'SET_VISIBILITY_FILTER':
return action.filter
default:
return state
}
}
export default visibilityFilter
// 合并多个 reducer
import { combineReducers } from 'redux'
import todo from './todo'
import filter from './filter'
export default combineReducers({
todo,
filter
})
构造 Store
应用的 reducer 设计好了,是时候根据 reducer 创建应用的 store 了。
import { createStore } from 'redux'
import reducers from '../reducers'
export default createStore(reducers)
页面构造
应用中关于 Redux 涉及到的数据状态管理逻辑 —— Redux Flow 中的 action、store 和 reducer 都设计好了,就差展示数据状态的组件。接下来,我们就对页面的构造进行分析,然后设计出对应的页面结构,并将数据状态与页面结构联通。
通过上面的截图,已经知道页面大致长什么样子了,这里我们先对页面进行组件的拆分,让他们各司其职。
我们将整个页面拆分成 4 个部分:Item 是每个 Todo 的展示,List 是做个 Item 的展示,Form 是输入框的展示和新增 Todo 的表单组件,最后再将这个三个部分组合成整个 Container 页面。
Item 组件
import React from 'react';
import './index.css';
export default ({item: { id, text, completed }, onRemove, onChangeStatus }) => (
<li className="item">
<span onClick={() => onChangeStatus(id)} style={{textDecoration: completed ? 'line-through' : ''}}>{text}</span>
<span className="close" onClick={() => onRemove(id)}>x</span>
</li>
);
Item 组件接受单个 Todo 的数据信息进行了展示,并对单个 Todo 的状态切换事件和删除事件进行了绑定,但是事件的具体逻辑写在了 List 组件,然后通过 props 传递过来。
List 组件
import React from 'react'
import Item from './Item'
import { deleteTodo, toggleTodo } from '../actions'
import store from '../store'
import './index.css'
export default ({ items, filterType }) => {
const onRemove = (id) => {
store.dispatch(deleteTodo(id))
}
const onChangeStatus = (id) => {
store.dispatch(toggleTodo(id))
}
let newItems = []
switch (filterType) {
case 'SHOW_COMPLETED':
newItems = items.filter(t => t.completed)
break
case 'SHOW_ACTIVE':
newItems = items.filter(t => !t.completed)
break
case 'SHOW_ALL':
default:
newItems = items
break
}
return (
<div className="list">
{
newItems.map((item, index) => (
<Item key={index} item={item} onRemove={onRemove} onChangeStatus={onChangeStatus} />
))
}
</div>
);
}
List 组件对多个 Item 进行了展示,它会从 Container 页面获取所有要展示的 Todos 和数据的过滤条件,然后通过 switch case 逻辑根据 filterType 过滤条件对所有的 todos 数据进行筛选,以确保最后展示的数据是对应筛选条件的数据。最后向 Item 组件传递每个 Todo 的数据信息,以及他们要绑定的事件。
Form 组件
import React, { Component } from 'react'
import { addTodo } from '../actions'
import store from '../store'
import './index.css'
export default class Form extends Component {
constructor(props) {
super(props)
this.state = {
inputValue: ''
}
}
handleInputChange = e => {
this.setState({
inputValue: e.target.value
})
}
formSubmit = (e) => {
e.preventDefault()
this.setState({
inputValue: '',
})
store.dispatch(addTodo(this.state.inputValue))
}
render() {
const { inputValue } = this.state
return (
<form onSubmit={this.formSubmit}>
<input className="input" type="text" value={inputValue} onChange={this.handleInputChange} />
</form>
)
}
}
Form 组件和上面介绍的两个组件没有直接的联系,它主要展示一个 input,然后定义了一个 input 框输入事件监听和一个表单提交事件监听。这个组件会在触发表单提交事件通过 store.dispatch() 方法发射一个添加数据 action 向数据状态列表中添加一个数据。
注意:这是一个受控组件。在 input 输入后,用一个监听事件获取当前 input 的 value 值,然后将其通过组件的 state 回写到 input 的 value 属性!
Container 组件
import React, { Component } from 'react'
import Form from './Form'
import List from './List'
import store from '../store'
import { setVisibilityFilter } from '../actions'
import './index.css'
export default class Container extends Component {
constructor(props) {
super(props)
this.unsubscribe = null
const state = store.getState()
this.state = {
items: state.todo,
filterType: state.filter
}
}
componentDidMount() {
this.unsubscribe = store.subscribe(() => {
const state = store.getState()
this.setState({
items: state.todo,
filterType: state.filter
})
})
}
componentWillUnmount() {
this.unsubscribe()
}
filterItems = (filterType) => {
store.dispatch(setVisibilityFilter(filterType))
}
render() {
const { items, filterType } = this.state
return (
<div className="container">
<div className="btn-wrapper">
<a className={["filter-btn", filterType === 'SHOW_ALL' ? "current" : ''].join(' ')} href="javascritp:;" onClick={() => this.filterItems('SHOW_ALL')}>全部</a>
<a className={["filter-btn", filterType === 'SHOW_COMPLETED' ? "current" : ''].join(' ')} href="javascritp:;" onClick={() => this.filterItems('SHOW_COMPLETED')}>已完成</a>
<a className={["filter-btn", filterType === 'SHOW_ACTIVE' ? "current" : ''].join(' ')} href="javascritp:;" onClick={() => this.filterItems('SHOW_ACTIVE')}>未完成</a>
</div>
<Form />
<List items={items} filterType={filterType} />
</div>
)
}
}
Container 组件是以上组件组合而成的容器组件,也是涉及到逻辑最复杂的组件,它会跟 store 进行直接交互。
在 componentDidMount 生命周期中订阅 store 里面数据状态的变化。当数据状态变化时,获取 store 里面的所有数据状态,并通过 this.setState 设置到组件的 state;
在 componentWillUnmount 生命周期中,取消对 store 里面数据状态变化的订阅;
定义了 filterItems 方法,该方法会在用户切换过滤条件时触发,函数的逻辑是根据当前的过滤条件调用 store.dispatch 发射一个修改 store 里面过滤条件数据状态变化的 action,以改变对应的数据状态
集成测试
通过上面一系列的定义和组件的组装,整个基础应用就可以顺利跑起来了。为了保证应用的功能,我们可以在页面上进行功能测试。另外,我们也需要写测试用例,对代码进行测试。
使用 create-react-app 创建的项目默认集成了测试工具 Jest。可以通过创建以 .test.js 或者 .spec.js 为后缀的文件或者创建 _test_
目录并将测试逻辑写在 .js 后缀的文件中。
当测试案例的逻辑写好后,只需要在终端运行 npm start 就可以看到测试案例运行的结果。
下面给出了几个简单的测试用例,对数据的增删做了测试,当然你还可以对功能更多的方面做更多的测试,如果你感兴趣的话。
import { addTodo, deleteTodo } from './actions'
import store from './store'
// 通过传递 text 做了一次 action 添加 todo
it('run once action with text', () => {
store.dispatch(addTodo('lane'))
const state = store.getState()
// 此时 Store items 只有一个元素
// 判断第一个元素的 text 是否等于期望值
expect(state.todo[0].text).toEqual('lane');
expect(state.todo.length).toEqual(1);
store.dispatch(deleteTodo())
})
// 通过不传递 text 添加 todo
it('run once action without text', () => {
store.dispatch(addTodo())
const state = store.getState()
// 此时 Store items 只有一个元素
// 判断第一个元素的 text 是否等于期望值
expect(state.todo[0].text).toEqual(undefined);
expect(state.todo.length).toEqual(1);
store.dispatch(deleteTodo())
})
// 添加 todo 然后移除所有的 todo
it('run once and after remove all', () => {
store.dispatch(addTodo('lane'))
store.dispatch(deleteTodo())
const state = store.getState()
expect(state.todo.length).toEqual(0);
})
// 通过传递 text 做了多次 action 添加 todo
it('run times action with text', () => {
store.dispatch(addTodo('lane1'))
store.dispatch(addTodo('lane2'))
const state = store.getState()
expect(state.todo.length).toEqual(2);
store.dispatch(deleteTodo())
})
在这个案例中,我们通过建立以 .test.js 为后缀的文件来添加测试用例。上面代码中,我们添加了四个简单的测试用例。关于每个案例的作用可以看一下每个案例的注释。
注意:对于多个场景应编写多个测试按钮,并且测试用例之间不应该互相影响。
总结
本章节我们从头到尾实现了一个 todo list 的案例,对 Redux 的各个角色做了代码实现,对每个角色的功能做了更深的理解。