详解 Node + Redux + MongoDB 实现 Todolist

前言

为什么要使用 Redux?

组件化的开发思想解放了繁琐低效的 DOM 操作,以 React 来说,一切皆为状态,通过状态可以控制视图的变化,然后随着应用项目的规模的不断扩大和应用功能的不断丰富,过多的状态变得难以控制,以至于当不同的组件中触发了同一个状态的修改或者引发了视图的更新,我们可能搞不清楚到底发生了什么,state 的变化已经变得有些难以预测和不受控制,因此 Redux 应运而生,通过对 Flux 思想的实践和增强,对状态更新发生的时间和方式进行限制,Redux 试图让 state 的变化变得可预测。

项目简介

在学了一段时间 Redux 之后,开始尝试对之前做过的 Todolist 单页应用进行重构,需要说明的是,因为应用本身非常迷你,所以可能无法明显地体现使用 Redux 的优势,但是基本上能够比较清晰得说明 Redux 的工作流程,相信各位在阅读了下面对项目实用 Redux 重构过程的分析后,会有很大的收获和体会。

技术栈:  Node.js  React  Redux Webpack MongoDB

项目源代码的 Github 地址:https://github.com/wx1993/Node-Redux-MongoDB-TodoList

项目的搭建和环境的配置,可参考上一篇博客: Node.js + React + MongoDB 实现 TodoList 单页应用

相关的操作和配置可以参考博客:

Node 项目的创建:http://www.cnblogs.com/wx1993/p/5765301.html

MongoDB 的安装和配置:http://www.cnblogs.com/wx1993/p/5187530.html (Mac) 

             http://www.cnblogs.com/wx1993/p/5206587.html(windows)

Git 入门和常用命令详解:http://www.cnblogs.com/wx1993/p/6230435.html

参考资料

在学习的过程中,主要受了以下资料和博客的启发:

《深入 React 技术栈》 第五章 <深入 Redux 应用架构>  

Redux 中文文档

redux —— 入门实例 TodoList

Redux状态管理方法与实例

如何通俗易懂地理解 Redux?

Redux 基础

Redux 的三大原则

1. 单一数据源。

应用只有唯一的数据源,整个应用的状态都保存在一个对象中,为提取出整个应用的状态进行持久化提供可能,同时 Redux 提供的 combineReducers 方法对数据源过于庞大的问题进行了有效的化解。

2. 状态是只读的。

在 Redux 中,无法直接通过 setState() 来修改状态,而是通过定义 reducer ,根据当前触发的 action 类型对当前的 state 进行迭代。reducer(previousState, action) => newState

3. 状态修改由纯函数完成。

状态修改通过 reducer 来实现,每一个 reducer 都是纯函数,当接受一定的 state 和 action,返回的 newState 都是固定不变的。

Redux 组成部分

1. store:createStore(reducer,initialState)方法生成,用于维护整个应用的 state。store 包含以下四个方法:

  • getState():获取 store 中当前的状态
  • dispatch(action):分发 action,更新 state
  • subscribe(listener):注册监听器,在 store 变化的时候被调用
  • replaceReducer(nextReducer):更新当前 store 中的 reducer,一般只在开发者模式中使用

2. action:一个 JavaScript 对象,用于描述一个事件(描述发生了什么)和需要改变的数据,必须有一个 type 字段,用来标识指令,其他元素是传送这个指令的 state 值。由组件触发,并传送到 reducer

{
  type: "ADD_TODO"
  text: "study Redux"        
}

3. reducer:一个包含 switch 的函数,描述数据如何变化,根据 action type 来进行响应的 state 更新操作(如果没有更改,则返回当前 state 本身)。整个应用只有一个单一的 reducer 函数,因此需要 combileReducers()函数。

function counter(state = 0, action) {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1;
    case 'DECREMENT':
      return state - 1;
    default:
      return state;
  }
}

 Redux 数据流

                        Redux 数据流图

这里给出的是一个简单的 Redux 数据流图,基本上可以描述 Redux 中各个部分是如何运行和协作的,关于每一个模块的具体作用,在下文会结合代码进行详细的介绍和分析,相信在看完具体的分析之后,对于上图你会有一定的理解和新的体会。

容器组件 & 展示组件

  展示组件 容器组件
作用 描述如何展现(标签、样式) 描述如何运行(获取数据、更新状态)
直接使用 Redux
数据来源 从 this.props 中获取 使用 connect 从 Redux 状态树中获取
数据修改 调用从 props 中传入的 action creator 直接分发 action
调用方式 开发者手动创建  由 React Redux 生成

 

 

 

 

 

 

 

 

 

简单来说,容器型组件描述的是组件如何工作,即数据如何获取合更新,一般不包含 Virtual DOM 的修改或组合,也不包含组件的样式

展示型组件描述的是组件是如何渲染的,不依赖 store,一般包含 Virtual DOM 的修改或组合,以及组件的样式,可以写成无状态函数。

 

在了解了上述的一些 Redux 相关的概念,下面将结合实例对 Redux 的使用进行具体的描述和分析。 

TodoList

功能

  • 添加 Todolist
  • 删除 Todolsit

运行

克隆出上面的 github 的项目后,进入项目,

安装依赖

npm install

 启动MongoDB

mongod

 项目打包

webpack -w

 启动项目

npm start

浏览器输入 localhost:8080,查看效果

效果

目录结构

入口文件 

index.js

import React from 'react'
import ReactDOM from 'react-dom'
import { createStore, applyMiddleware } from 'redux'
import thunkMiddleware from 'redux-thunk'
import { createLogger } from 'redux-logger'
import { Provider } from 'react-redux'
import Todo from './containers/app'
import rootReducer from './reducers/todoReducer'

// 打印日志方法
const loggerMiddleware = createLogger()

// applyMiddleware() 用来加载 middleWare
const createStoreWithMiddleware = applyMiddleware(thunkMiddleware, loggerMiddleware)(createStore)

// 创建 store 对象
const store = createStoreWithMiddleware(rootReducer)

// 获取到的 store 是空的?
console.log(store.getState())

// 注册 subcribe 函数,监听 state 的每一次变化
const unsubscribe = store.subscribe(() => console.log(store.getState()) );

ReactDOM.render(
    <Provider store={store}>
        <Todo />
    </Provider>,
    document.getElementById("app")
);

 

在入口文件我们主要做了以下几件事情:

1. 引入模块;

2. 使用 thunkMiddleware(用于异步请求数据)和 loggerMiddleware(用于打印日志) 对 createStore 进行了增强;

3. 然后创建 store 对象,注册监听函数(在函数体内可以添加 state 变化时候的相关操作)

4. 引入 Provider 中间件,作为根组件的上层容器,接受 store 作为属性,将 store 放在 context 中,提供给 connect 组件来连接容器组件。

5. 将应用组件挂载到页面节点上

注:这里将 store 的相关配置也放在了 入口文件中,为了使文件结构更加清晰,可以考虑将 store 的相关配置单独定义为 configureStore.js,然后在入口文件中引入。

 

Action

src/actions/todoAction.js

import $ from 'jquery'

// 定义 action type 为常量
export const INIT_TODO = 'INIT_TODO'
export const ADD_TODO = 'ADD_TODO'
export const DELETE_TODO = 'DELETE_TODO'

// create action
export function initTodo () {
    // 这里的 action 是一个 Trunk 函数,可以将 dispatch 和 getState() 传递到函数内部
    return (dispatch, getState) => {
        $.ajax({
            url: '/getTodolsit',
            type: 'get',
            dataType: 'json',
            success: data => {
                // console.log(data)
                // 请求成功,分发 action, 这里的 dispatch 是通过 Redux Trunk Middleware 传递过来的
                dispatch({
                    type: 'INIT_TODO',
                    todolist: data.reverse()
                })
            },
            error: () => {
                console.log('获取 todolist 失败...')
            }
        })
    }
}

export function addTodo (newTodo) {
    return (dispatch, getState) => {
        $.ajax({
            url: '/addTodo',
            type: 'post',
            dataType: 'json',
            data: newTodo,
            success: data => {
                // console.log(data)
                dispatch({
                    type: 'ADD_TODO',
                    todolist: data.reverse()
                })
            },
            error: () => {
                console.log(err)
            }
        })
    }
}

export function deleteTodo (date) {
    console.log(date)
    return (dispatch, getState) => {
        $.ajax({
            url: '/deleteTodo',
            type: 'post',
            dataType: 'json',
            data: date,
            success: data => {
                // console.log(data)
                dispatch({
                    type: 'DELETE_TODO',
                    todolist: data.reverse()
                })
            },
            error: () => {
                console.log(err)
            }
        })
    }
}

 

可以看到,这里的 action 和我们上面讲到的 action 不太一样,因为这里用 ajax 进行了数据的异步请求,在前面的入口文件中我们实用了 trunkMiddleware 中间件(需要在index.js 中引入 redux-trunk),这个中间件就是为了异步请求用的,对应的异步 action 函数的形式如下:

export const asyncAction () => {
    return (dispatch, getState) => {
        // 在这里可以调用异步函数请求数据,并在合适的时机通过 dispatch 参数派发出新的 action 对象
    }
}

 

* redux-trunk 的工作是检查 action 对象是不是函数,如果不是函数就放行,完成普通的 action 生命周期,如果是函数,则执行函数,并把 Store 的 dispatch 函数和 getState 函数作为参数传递到函数中去,并产生一个同步的 action 对象来对 redux 产生影响。

注:1. 如果涉及到的 action 类型名比较多,可以将它们单独定义到一个文件中,然后在这里引入,以便于后面的管理。

  2. 这里实用 jQuery 的 ajax 进行数据的请求,也可以尝试引入 fetch 进行 Ajax

 

Reducer

reducers/todoReducer.js

import { combineReducers, createStore } from 'redux'
import { INIT_TODO, ADD_TODO, DELETE_TODO } from '../actions/todoAction'

// 在 reducer 第一次执行的时候,没有任何的 previousState, 因此需要定义一个 initialState,
// 下面使用 es6 的写法为 state 赋初始值
function todoReducer (state = [], action) {
  console.log(action);
  switch (action.type) {
    case INIT_TODO:
      return action.todolist
      break
    case ADD_TODO:
      return action.todolist
      break
    case DELETE_TODO:
      return action.todolist
      break
    default:
      return state
  }
}

// 将多个 reducer 合并成一个
const rootReducer = combineReducers({ todoReducer })

export default rootReducer

 

在 reducer 中,首先引入 在 action 中定义的 type 参数,然后定义一个函数(纯函数),接收 state 和 action 作为参数,通过 switch 判断当前 action type,并返回不同的对象(更新 store)。

需要注意的是,当组件刚开始渲染的时候,store 中并没有 state,所以针对这种情况,需要为 state 赋一个初始值,可以在函数体内通过 if 语句来判断,但是 es6 提供了更为简洁的写法,在参数中直接赋值,当传入的 state 为空的时候,直接使用初始值,当然这里默认为空数组。

combineReducers({...}):首先需要明确的是,整个应用只能有一个 reducer 函数。如果定义的 action type 有很多,那么针对不同的 type,需要写很多的分支语句或者定义多个 reducer 文件,因此 Redux 提供 combineReducers 函数,来将多个 reducer 合并成一个。

容器组件 

containers/app.js

import React from 'react'
import PropTypes from 'prop-types'
import ReactDOM from 'react-dom'
import { connect } from 'react-redux'
import { initTodo, addTodo, deleteTodo } from '../actions/todoAction'
import TodoList from '../components/todolist'
import TodoForm from '../components/todoform'

class Todo extends React.Component {
    componentDidMount () {
        this.props.dispatch(initTodo())
    }

    handleAddTodo (newTodo) {
        console.log('add new todo......');
        console.log(newTodo);
        this.props.dispatch(addTodo(newTodo))
    }

    handleDeleteTodo (date) {
        const delete_date = { date }
        this.props.dispatch(deleteTodo(delete_date))
    }

    render() {
        // 这里的 todolist 是在 connect 中以 { todolist: state.todolist } 的形式作为属性传递给 App 组件的
        const { todolist } = this.props
        console.log(todolist);
          return (
              <div className="container">
                <h2 className="header">Todo List</h2>
                <TodoForm onAddTodo={this.handleAddTodo.bind(this)} />
                <TodoList todolist={todolist} onDeleteTodo={this.handleDeleteTodo.bind(this)} />
              </div>
        )
    }
}

// 验证组件中的参数类型
Todo.propTypes = {
    todolist: PropTypes.arrayOf(
        PropTypes.shape({
            content: PropTypes.string.isRequired,
            date: PropTypes.string.isRequired
        }).isRequired
    ).isRequired
}

const getTodolist = state => {
    console.log(state);
    return {
        todolist : state.todoReducer
    }
}

export default connect(getTodolist)(Todo)

在容器组件中,我们定义了页面的结构(标题、表单、列表),并定义了相关的方法和数据,通过 props 的方式传递给对应的子组件。在子组件通过触发 props 中的回调函数时,在容器组件中接受到就会分发响应的 action,交由 reducer 进行处理(在 reducer 进行状态的更新,然后同步到组件中,引起视图的变化)。

添加了propTypes 验证,这样在组件中的属性、方法以及其他定义的元素的类型不符的时候,浏览器会抛出警告,需要注意的是, 和 ReactDOM 一样, propTypes 已经从 React分离出来了,因此使用的时候需要单独引入模块 prop-types(仍然使用 PropTypes from 'React'会有警告,但不影响使用)。

这里最为重要的是从 react-redux 中引入了 connect,通过 connect(selector)(App) 来连接 store 和 容器组件。其中,selector 是一个函数,接受 store 中的 state 作为参数,然后返回一个对象,将里面的参数以属性的形式传递给连接的组件,同时还隐式地传递 一个 dispatch 方法,作为组件的属性。如下所示:

需要注意的是,connect 函数产生的组件是一个高阶组件,其完整的形式如下:

const mapStateToProps = (state) => {
    return {
        data: state
    }
}
const mapDispatchToProps = (dispatch) => {
    return {
        getCityWeather: (args) => {
            dispatch(asyncAction(args))
        }
    }
}
export default connect(mapStateToProps, mapDispatchToProps)(App)

 

可以看出,connect 函数接受两个参数:

1. mapStateToProps:  将 Store 上的状态转化为展示组件上的 props

2. mapDispatchToProps:将 Store 上的dispatch 动作转化为展示组件上的 props

因此, App 组件可以获得 store 中的 state并传递给子组件,并可以通过 dispatch() 方法来分发 action。如下所示:

展示组件

由容器组件中的 DOM 结构可以看出主要有 todoform todolist 两个组件,同时在 todolist 组件中,又再次划分出了 todo 组件,展示组件比较简单,不拥有自己的状态,主要是从父级获取 props,并在 DOM 中进行展示,同时在组件中触发事件,通过 this.props.eventHandler()的方式来通知父级,最后触发 action 实现状态的修改。

components/todoform.js

import React from 'React'
import PropTypes from 'prop-types'

class TodoForm extends React.Component {

  // 表单输入时隐藏提示语
  handleKeydown () {
    this.refs.tooltip.style.display = 'none'
  }
  
  // 提交表单操作
    handleSubmit (e) {
        e.preventDefault();
        // 表单输入为空验证
        if(this.refs.content.value == '') {
            this.refs.content.focus()
      this.refs.tooltip.style.display = 'block'
            return ;
        }

    // 获取时间并格式化
        let month = new Date().getMonth() + 1;
        let date = new Date().getDate();
        let hours = new Date().getHours();
        let minutes = new Date().getMinutes();
        let seconds = new Date().getSeconds();
        if (hours < 10) { hours += '0'; }
        if (minutes < 10) { minutes += '0'; }
        if (seconds < 10) { seconds += '0'; }

        // 生成参数
        const newTodo = {
            content: this.refs.content.value,
            date: month + "/" + date + " " + hours + ":" + minutes + ":" + seconds
        };
        this.props.onAddTodo(newTodo)
        this.refs.todoForm.reset();
    }

  render () {
    return (
      <form className="todoForm" ref="todoForm" onSubmit={ this.handleSubmit.bind(this) }>
        <input ref="content" onKeyDown={this.handleKeydown.bind(this)} type="text" placeholder="Type content here..." className="todoContent" />
        <span className="tooltip" ref="tooltip">Content is required !</span>
      </form>
    )
  }
}

export default TodoForm

 components/todolist.js

import React from 'react';
import PropTypes from 'prop-types'
import Todo from './todo';

class TodoList extends React.Component {

      render() {
          const todolist = this.props.todolist;
      console.log(todolist);
          const todoItems = todolist.map((item, index) => {
              return (
          <Todo
                        key={index}
                        content={item.content}
                        date={item.date}
                        onDeleteTodo={this.props.onDeleteTodo}
          />
            )
      });

        return (
            <div>
                { todoItems }
            </div>
        )
      }
}

// propTypes 用于规范 props 的类型与必需的状态,在开发环境下会对组件的 props 进行检查,
// 如果不能与之匹配,将会在控制台报 warning。在生产环境下不会进行检查。(解决 JS 弱语言类型的问题)

// arrayOf 表示数组类型, shape 表示对象类型
TodoList.propTypes = {
    todolist: PropTypes.arrayOf(
        PropTypes.shape({
            content: PropTypes.string.isRequired,
            date: PropTypes.string.isRequired,
        }).isRequired
    ).isRequired
}

export default TodoList;

 components/todo.js

import React from 'react'
import PropTypes from 'prop-types'

class TodoItem extends React.Component {

    handleDelete () {
        const date = this.props.date;
        this.props.onDeleteTodo(date);
    }

    render() {
        return (
            <div className="todoItem">
                <p>
                    <span className="itemCont">{ this.props.content }</span>
                    <span className="itemTime">{ this.props.date }</span>
                    <button className="delBtn" onClick={this.handleDelete.bind(this)}>
                        <img className="delIcon" src="/images/delete.png" />
                    </button>
                </p>
            </div>
        )
    }
}

TodoItem.propTypes = {
    content: PropTypes.string.isRequired,
    date: PropTypes.string.isRequired,
    // handleDelete: PropTypes.func.isRequired
}

export default TodoItem;

 

数据库操作

database/db.js

var mongoose = require('mongoose')

// 定义数据模式,指定保存到 todo 集合
const TodoSchema = new mongoose.Schema({
    content: {
        type: String, 
        required: true
    },
    date: {
        type: String, 
        required: true
    }
}, { collection: 'todo' })

// 定义数据集合的模型
const Todo = mongoose.model('TodoBox', TodoSchema)

module.exports = Todo

这里就比较简单了,只有两个字段,都是 String 类型,并指定保存到 todo 这个集合中,最后通过一行代码编译成对应的模型并导出,这样在 node 中就可以通过模型来操作数据库了。

注:因为项目比较简单,只涉及一个数据集合,所以直接将 schema 和 model 写在一个文件中,如果涉及多个数据集合,建议将 schema 和 model 放在不同的文件中

接口封装

routes/index.js

var express = require('express');
var Todo = require('../src/database/db')
var router = express.Router();

router.get('/', (req, res, next) => {
    res.render('index', {
        title: 'React TodoList'
    });
});

// 获取 todolist
router.get('/getTodolsit', (req, res, next) => {
    Todo.find({}, (err,todolist) => {
        if (err) {
            console.log(err);
        }else {
            console.log(todolist);
            res.json(todolist);
        }
    })
});

// 添加 todo
router.post('/addTodo', (req, res, next) => {
    const newItem = req.body;
    Todo.create(newItem, (err) => {
        if (err) {
            console.log(err);
        }else {
            Todo.find({}, (err, todolist) => {
                if (err) {
                    console.log(err);
                }else {
                    res.json(todolist);
                }
            });
        }
    })
})

// 删除 todo
router.post('/deleteTodo', (req, res, next) => {
    const delete_date = req.body.date

    Todo.remove({date: delete_date}, (err, result) => {
        if (err) {
            console.log(err)
        }else {
            // 重新获取 todolist
            Todo.find({}, (err, todolist) => {
                if (err) {
                    console.log(err);
                }else {
                    res.json(todolist);
                }
            })
        }
    });
});

module.exports = router;

没有任何魔法,只是简单的数据库增删改查的操作,封装成接口,来供 createAction 中通过 Ajax 来请求调用。

然后是 webpack 的配置和 CSS 的编写,都比较简单,和未使用 Redux 重构的代码没有任何修改,所以也就不贴代码了。

测试

因为使用了 loggerMiddleware 中间件, 可以跟踪 actoin 的变化并在浏览器控制台中打印出 state 信息,因此可以十分直观地看到数据的变化。

下面就根据这里的打印信息,结合 React 的生命周期,来简单捋一遍 Redux 的工作流程。

进入页面

可以看到,打印出来的 state 是一个对象,并且最开始是空的数组对象,这是因为在页面尚未渲染完毕的时候,即在 componentWillMount 阶段,页面并没有任何的 state,直到渲染结束,即 componentDidMount 阶段,容器组件主动的触发了 INIT_TODO 的 action,reducer 接受到这个 action 后开始请求数据,更新 state,然后同步到页面上来,这也是为什么打印出来的 state 在 todoReducer 这个对象中,因为 state 就是在 reducer 中进行处理和返回的。

添加 todo

可以看到,这里触发了 ADD_TODO 的 action,在执行 reducer 的操作后,state 的中的数据由 6 条变成了 7 条。

删除 todo

同样,执行删除操作时触发了 DELETE_TODO 的 action,在执行 reducer 的操作后,state 的中的数据由 7 条变成了 6 条。

总结

1. Redux 是一个"可预测的状态容器",由 Store、Action、Reducer 三部分组成。

2. Store 负责存储状态,通过 createStore(reducer, initialState) 生成。

3. Action 中声明了数据的结构,不提供逻辑,在 createAciton 结合中间件可以发出异步请求。

4. Reducer 是一个纯函数,每个应用只能有唯一的一个 reducer, 多个 reducer 使用 combineReducers() 方法进行合并。

5. react-redux 提供了 <Provider />组件和 connect () 方法实现 Redux 和 React 的绑定。

6. <Provider />接受一个 store 作为 props,是整个应用的顶层组件;connect () 提供了在整个 React 应用中热议组件获取 store 中数据的功能。

7. 容器型组件和 Redux 进行交互并获取状态,分发 action;展示型组件从传入的 props 中获取数据,通过容器组件下发的的回调函数来触发事件,向上通知父级,从而触发 action,实现状态的更新。

 

posted @ 2017-04-23 22:36  Raychan  阅读(1845)  评论(1编辑  收藏  举报