前面的四个专栏,我们从理论出发讲到了 Redux 涉及到的三大核心概念:存储应用数据状态并提供相关接口方法的 Store描述修改数据状态动作的 action修改数据状态逻辑的纯函数 reducer。关于这三大基础概念之间的关系和 component 组件之间的数据扭转,下面这张图很好的进行了展示。
image.png

光说不练假把式!概念说的多了,大家可能不喜欢或者学习起来会感到疲惫。那么接下来,我们就进行实操,实践一下我们之前学到的概念知识,实现一个简单的 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 我已经写好的项目:

  1. $ git clone https://github.com/YuQue-Case-Library/Redux-Case.git
  2. $ react-redux-todo && npm install
  3. $ npm start

当运行完 npm start 后,服务会自动拉起浏览器,打开 http://localhost:3000/ 页面,页面展示基本如下:
image.png

目录结构

项目初始化好后,在 src 目录下建立如下的目录结构,以便我们后续的开发:

  1. |- src
  2. |-- actions // 存放项目涉及的 action
  3. |-- components // 存放项目的所有组件页面
  4. |-- reducers // 存放项目涉及的 reducer
  5. |-- store // 存放项目涉及的数据状态

项目目录划分比较粗略,如果项目比较大,设计到的 action 比较多的话,可以单独建一个 constant.js 文件来维护 action type 常量。

功能分析

为了更好的开展后续的开发工作,这里我们先根据上面给出的示例图分析页面的功能。首先肯定是展示列表数据了,我们可以通过 store.getState() 方法获取当前应用的状态数据,初始化的列表数据肯定是没有的,这是我们就需要添加数据了;然后就是添加数据的操作;单条数据可以进行删除,有一个删除数据的操作;单条数据的状态可以进行切换,即由未完成切换到已完成,由已完成切换到未完成,有个状态切换的操作。当然,为了查看当前单个状态的数据列表,还需要有个筛选操作,筛选出已完成或未完成或全部的列表数据。

应用的功能都分析好了,接下来,我们可以定义 action 或者 actionCreator 了。

构造 Action / ActionCreator

上面对应用的功能进行了简单的分析,发现有增、删、改和过滤这四个操作,我们可以根据这四个操作定义四个 action。为了减少代码冗余,我这里定义了四个 actionCreator。当然,你还可以根据自己的实际需求进行进一步的简化和封装。

  1. // 为了区分数据 id,我们使用了一个自增字段
  2. let nextTodoId = 0
  3. // 数据新增
  4. export const addTodo = text => ({
  5. type: 'ADD_TODO',
  6. id: nextTodoId++,
  7. text
  8. })
  9. // 数据删除
  10. export const deleteTodo = id => ({
  11. type: 'REMOVE_TODO',
  12. id,
  13. })
  14. // 修改数据状态
  15. export const toggleTodo = id => ({
  16. type: 'TOGGLE_TODO',
  17. id,
  18. })
  19. // 数据筛选
  20. export const setVisibilityFilter = filter => ({
  21. type: 'SET_VISIBILITY_FILTER',
  22. filter
  23. })

构造 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 都设计好了,就差展示数据状态的组件。接下来,我们就对页面的构造进行分析,然后设计出对应的页面结构,并将数据状态与页面结构联通。

通过上面的截图,已经知道页面大致长什么样子了,这里我们先对页面进行组件的拆分,让他们各司其职。

Redux 系列五:案例应用 - 图3

我们将整个页面拆分成 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 的各个角色做了代码实现,对每个角色的功能做了更深的理解。