react全家桶搭一个Blog-复盘2

这一篇文章是前后台交互的实现的梳理
GitHub: https://github.com/octopustail/Octopustail_Blog.git

用户登陆及注册

Part 1. 登陆及注册实现

Actions

首先,我们需要的清楚登陆和注册功能需要的“零件”。
按照模拟一次点击注册(登陆)按钮开始,从前端界面想起,当登陆按钮被点击的时候,前端会dispatch一个“USER_LOGIN”的action的创造函数,这个函数当然会产生一个action,这个action带着表单里的username和password出发去后台办理登录手续。接待这个action的是我们的Saga部分,saga中的 LoginFlow函数会监听(take)USER_LOGIN,然后调用手下的login来处理这个aciton,login函数工作流程三步走:第一,发出(put,类似dispatch)FETCH_START的action,告诉前台,我正在联系后台取数据,然后调用axios的post方法,axios就去到指定的后台的路由,后台对应的接口就会接手这个请求,进行数据合法性的检测,去数据库取数据,返回结果或者返回错误数据。结果会返回到login中(login有一个yield就是来搞这种异步操作),如果遇到了错误,login就会put一个actionSET_MESSAGE说明夭寿了,出错了。如果一切顺利login在结果直接return给loginFlow,如果一切顺利没有error,loginFlow会put两个action,SET_MESSAGE,发送好信息:注册成功,RESPONSE_USER_INFO,带着返回的用户信息到reducer里去更新state。
整个注册登陆的前后台交互的流程就是这样了。
代码如下:

// reducer & action 部分
export const actionType = {
    FETCH_START: 'FETCH_START', //开始异步请求
    FETCH_END: 'FETCH_END', //结束异步请求
    USER_LOGIN: 'USER_LOGIN', //用户登陆
    USER_REGISTER: 'USER_REGISTER', //用户注册
    RESPONSE_USER_INFO: 'RESPONSE_USER_INFO', //收到用户登陆信息
    SET_MESSAGE: 'SET_MESSAGE', //设置全局提醒
};

/*initialState*/
const initialState = {
    isFetching: true,
    msg: {
        type: 1, //0 失败,1 成功
        content: ''
    },
    userInfo: {}
}

//action creator
export const actions = {
//这两个action creator是从前端组件里dispatch的,带着前端提交的数据
    get_login: function (username, password) {
        return {
            type: actionType.USER_LOGIN,
            username,
            password,
        }
    },
    get_register: function (data) {
        return {
            type: actionType.USER_REGISTER,
            data,
        }
    },
    clear_msg: function () {
        return {
            type: actionType.SET_MESSAGE,
            msgType: 1,
            msgContent: '',
        }
    },
}

//reducers  处理改变会store的几个action
export function reducer(state = initialState, action) {
    switch (action.type) {
        case actionType.FETCH_START:
            return {
                ...state,
                isFetching: true
            };
        case actionType.FETCH_END:
            return {
                ...state,
                isFetching: false,
            };
        case actionType.SET_MESSAGE:
            return {
                ...state,
                isFetching: false,
                msg: {
                    type: action.msgType,
                    content: action.msgContent,
                }
            };
        case actionType.RESPONSE_USER_INFO:
            return {
                ...state,
                userInfo: action.data
            };
        default:
            return state;
    }
}
/* saga部分 */
import {put, take, call, fork} from 'redux-saga/effects'
//这里稍稍封装了一下axios的配置,一劳永逸
import {get, post} from '../fetch/fetch'
import {actionType as IndexActionTypes} from '../reducers'

export function* login(username, password) {
    yield put({type: IndexActionTypes.FETCH_START});
    try {
        return yield call(post, '/user/login', {username, password})
    } catch (error) {
        yield put({type: IndexActionTypes.SET_MESSAGE, msgContent: '用户名或密码错误', msgType: 0});
    } finally {
        yield put({type: IndexActionTypes.FETCH_END});
    }
}

export function* register(data) {
    yield put({type: IndexActionTypes.FETCH_START});
    try {
        return yield call(post, '/user/register', data)
    } catch (error) {
        yield put({type: IndexActionTypes.SET_MESSAGE, msgContent: '注册失败', msgType: 0});
    } finally {
        yield put({type: IndexActionTypes.FETCH_END});
    }
}

export function* loginFlow() {
    while (true) {
        let request = yield take(IndexActionTypes.USER_LOGIN);
        let response = yield call(login, request.username, request.password);
        if (response && response.code === 0) {
            yield put({type: IndexActionTypes.SET_MESSAGE, msgContent: '登录成功!', msgType: 1});
            yield put({type: IndexActionTypes.RESPONSE_USER_INFO, data: response.data})
        }
    }
}

export function* registerFlow() {
    while (true) {
        let request = yield take(IndexActionTypes.USER_REGISTER);
        let response = yield call(register, request.data);
        if (response && response.code === 0) {
            yield put({type: IndexActionTypes.SET_MESSAGE, msgContent: '注册成功!', msgType: 1});
            yield put({type: IndexActionTypes.RESPONSE_USER_INFO, data: response.data})
        }

    }
}
/*后台部分*/
import Express from 'express'
const router = Express.Router();
import User from '../../models/user'
/* MD5_SUFFIX md5 用来加密的方法 responseClient封装了状态提示信息 */
import {MD5_SUFFIX,responseClient,md5} from '../util'

/**
 *定义回复模板
 */


router.post('/login', (req, res) => {
    let {username, password} = req.body;
    if (!username) {
        responseClient(res, 400, 2, '用户名不可为空');
        return;
    }
    if (!password) {
        responseClient(res, 400, 2, '密码不可为空');
        return;
    }
    User.findOne({
        username,
        password: md5(password + MD5_SUFFIX)
    }).then(userInfo => {
        if (userInfo) {
            //登录成功
            let data = {};
            data.username = userInfo.username;
            data.userType = userInfo.type;
            data.userId = userInfo._id;
            //登录成功后设置session
            req.session.userInfo = data;

            responseClient(res, 200, 0, '登录成功', data);
            return;
        }
        responseClient(res, 400, 1, '用户名密码错误');

    }).catch(err => {
        responseClient(res);
    })
});


router.post('/register', (req, res) => {
    let {userName, password, passwordRe} = req.body;
    if (!userName) {
        responseClient(res, 400, 2, '用户名不可为空');
        return;
    }
    if (!password) {
        responseClient(res, 400, 2, '密码不可为空');
        return;
    }
    if (password !== passwordRe) {
        responseClient(res, 400, 2, '两次密码不一致');
        return;
    }
    //验证用户是否已经在数据库中
    User.findOne({username: userName})
        .then(data => {
            if (data) {
                responseClient(res, 200, 1, '用户名已存在');
                return;
            }
            //保存到数据库
            let user = new User({
                username: userName,
                password: md5(password + MD5_SUFFIX),
                type: 'user'
            });
            user.save()
            /* 先保存再读取 */

                .then(function () {
                    User.findOne({username: userName})
                        .then(userInfo=>{
                            let data = {};
                            data.username = userInfo.username;
                            data.userType = userInfo.type;
                            data.userId = userInfo._id;
                            responseClient(res, 200, 0, '注册成功', data);
                            return;
                        });
                })
        }).catch(err => {
        responseClient(res);
        return;
    });
});

module.exports = router;

Part 2.Cookie & Session实现免登录

Cookie为什么出现

HTTP是不带用户状态的,不管请求多少次,它是不知道是来自同一个人的请求。所以,登录过的网站,第二次访问也需要重新登录。Cookie就是为了解决HTTP协议无状态的缺陷所做的努力。它是服务器在本地机器上存储的小短文本并随每一个亲故发送至同一个服务器。

cookie的产生和分发

正统的cookie分发是通过扩展HTTP协议来实现的,服务器通过在HTTP的响应头中加上一行特殊的指示以提示浏览器按照指示生成相应的cookie。然而纯粹的客户端脚本如JavaScript也可以生成cookie。而cookie的使用是由浏览器按照一定的原则在后台自动发送给服务器的。浏览器检查所有存储的cookie,如果某个cookie所声明的作用范围大于等于将要请求的资源所在的位置,则把该cookie附在请求资源的HTTP请求头上发送给服务器。

cookie的内容:

名字,值,过期时间,路径和域。路径与域一起构成cookie的作用范围。若不设置过期时间,则表示这个cookie的生命期为浏览器会话期间,关闭浏览器窗口,cookie就消失。这种生命期为浏览器会话期的cookie被称为会话cookie。会话cookie一般不存储在硬盘上而是保存在内存里,当然这种行为并不是规范规定的。若设置了过期时间,浏览器就会把cookie保存到硬盘上,关闭后再次打开浏览器,这些cookie仍然有效直到超过设定的过期时间。存储在硬盘上的cookie可以在不同的浏览器进程间共享,比如两个IE窗口。而对于保存在内存里的cookie,不同的浏览器有不同的处理方式。

session机制

session机制采用的是一种在服务器端保持状态的解决方案。同时我们也看到,由于采用服务器端保持状态的方案在客户端也需要保存一个标识,所以session机制可能需要借助于cookie机制来达到保存标识的目的。
session是针对每一个用户的,变量的值保存在服务器上,用一个sessionID来区分是哪个用户session变量,这个值是通过用户的浏览器在访问的时候返回给服务器,当客户禁用cookie时,这个值也可能设置为由get来返回给服务器。
而session提供了方便管理全局变量的方式 。服务器端的session机制更安全些,因为它不会随意读取客户存储的信息。
session采用一种类似散列表的结构。
当程序需要为某个客户端的请求创建一个session时,服务器首先检查这个客户端的请求里是否已包含了一个session标识(称为session id),如果已包含则说明以前已经为此客户端创建过session,服务器就按照session id把这个session检索出来使用(检索不到,会新建一个),如果客户端请求不包含session id,则为此客户端创建一个session并且生成一个与此session相关联的session id,session id的值应该是一个既不会重复,又不容易被找到规律以仿造的字符串,这个session id将被在本次响应中返回给客户端保存。

保存这个session id的方式可以采用cookie,这样在交互过程中浏览器可以自动的按照规则把这个标识发挥给服务器。一般这个cookie的名字都是类似于SEEESIONID。但cookie可以被人为的禁止,则必须有其他机制以便在cookie被禁止时仍然能够把session id传递回服务器。

cookie和session不同

cookie数据存放在客户的浏览器上,session数据放在服务器上。
cookie不是很安全,别人可以分析存放在本地的COOKIE并进行COOKIE欺骗 考虑到安全应当使用session。
session会在一定时间内保存在服务器上。当访问增多,会比较占用你服务器的性能 考虑到减轻服务器性能方面,应当使用COOKIE。
单个cookie保存的数据不能超过4K,很多浏览器都限制一个站点最多保存20个cookie。
所以建议:将登陆信息等重要信息存放为session、其他信息如果需要保留,可以放在cookie中

可以参考掘金上的这篇文章。Cookie 与 Session 的区别

==================================

2. 在node.js中使用cookie和session

res.cookie()来设置cookie
也可以给cookie加点选项
express中cookie的使用可以参考express中cookie的使用和cookie-parser的解读
](https://segmentfault.com/a/1190000004139342)

这一部分也可以参考关于nodejs中cookie和session浅谈

3. 本Blog中实现免登录的过程

首先,要引入session和cookie在express中。
在apiServer中引入

api/apiServer
app.use(cookieParser('express_react_cookie')); //这个key 'express_react_cookie'需要和session的secret一致
app.use(session({
    secret:'express_react_cookie',  
    resave: true,
    saveUninitialized:true,
    cookie: {maxAge: 60 * 1000 * 30}//过期时间
}));

然后需要在用户从登陆成功的时候设置session

//api/user.js
//router.post('/login', (req, res) => {
    //let {username, password} = req.body;
    //if (!username) {
        //responseClient(res, 400, 2, '用户名不可为空');
      //  return;
    //}
    //if (!password) {
        //responseClient(res, 400, 2, '密码不可为空');
      //  return;
    //}
    //User.findOne({
       // username,
      //  password: md5(password + MD5_SUFFIX)
    //}).then(userInfo => {
        //if (userInfo) {
            //登录成功
            //let data = {};
           // data.username = userInfo.username;
            //data.userType = userInfo.type;
            //data.userId = userInfo._id;
            ////登录成功后设置session
            req.session.userInfo = data;

           // responseClient(res, 200, 0, '登录成功', data);
         //   return;
       // }
     //   responseClient(res, 400, 1, '用户名密码错误');

   // }).catch(err => {
   //     responseClient(res);
 //   })
});

然后server端需要一个处理登陆请求的接口,就是将请求中的req.session.userInfo再返回去,如果以及登陆过,就拿着这个userInfo直接put一个action到reducer中修改状态树。

router.get('/userInfo',function (req,res) {
    if(req.session.userInfo){
        responseClient(res,200,0,'',req.session.userInfo)
    }else{
        responseClient(res,200,1,'请重新登录',req.session.userInfo)
    }
});

saga部分需要添加一个处理自动登陆的saga

export function* user_auth() {
    while (true) {
        yield take(IndexActionTypes.USER_AUTH);
        try {
            yield put({type: IndexActionTypes.FETCH_START});
            let response = yield call(get, '/user/userInfo');
            if (response && response.code === 0) {
                yield put({type: IndexActionTypes.RESPONSE_USER_INFO, data: response.data})
            }
        } catch (err) {
            console.log(err);
        } finally {
            yield put({type: IndexActionTypes.FETCH_END});
        }
    }
}

reducer.js里也要有相应的action 部分:

actiontype.USER_AUTH =  'USER_AUTH',  //实现免登陆
//这个creator每次打开主页的时候就会dispatch,即在AppIndex的ComponentDidMount的时候发出
user_auth: function () {
        return {
            type: actionType.USER_AUTH,
        }
    }

Neal大佬写的时候遇到的问题,看的半懂不懂,可以品一品:react redux身份验证,取state的问题
](https://segmentfault.com/q/1010000011325608)

Tag管理

对于Tag的操作,主要是在主页需要get_all_tags(),在后台管理中,需要能归对Tag进行增加add_tag或者删除del_tag。首先明确,前一个操作是没有什么权限分别的,后两个操作是管理员才有的,所以这里就涉及到两个问题,一是管理员权限的验证,即登陆过期的检查。二是后台api的路由设计。

管理员权限认证

为了能够涉及到需要管理员权限的操作,都需要先验证登陆是否有过期。所以在所有/admin路由分发之前,就做验证会比较方便,验证完成验证,再分发路由。

// api/admin.js
//先进行身份过期检验
router.use( (req,res,next) =>{
    if(req.session.userInfo){
        next()
    }else{
        res.send(responseClient(res,200,1,'身份信息已过期,请重新登录'));
    }
});

//然后在分发路由
router.use('/tags',require('./tags'));
router.use('/article',require('./article'));

由于在不能将get_all_tags的api在admin里分发(否则主页就要重新写api,没必要,所以在'/'路由里写对应的mongoose操作:

router.get('/getAllTags', function (req, res) {
    Tags.find(null, 'name').then(data => {
        responseClient(res, 200, 0, '请求成功', data);
    }).catch(err => {
        responseClient(res);
    })
});

后台api写好之后前台的也要有相应的saga才行。前台的saga其实就是在每个需要管理员权限的saga里,加上接收后台身份过期的的SET_MESSAGE和页面跳转到。

//示例,其他的也都是则么些
//export function* delTagFlow() {
//         while (true){
//             let req = yield take(ManagerTagsTypes.DELETE_TAG);
//             let res = yield call(delTag,req.name);
//             if (res.code === 0) {
//                 yield put({type: IndexActionTypes.SET_MESSAGE, msgContent: //res.message, msgType: 1});
//                 yield put({type:ManagerTagsTypes.GET_ALL_TAGS});
             } else if (res.message === '身份信息已过期,请重新登录') {
                 yield put({type: IndexActionTypes.SET_MESSAGE, msgContent: res.message, msgType: 0});
                 setTimeout(function () {
                     location.replace('/');
                 }, 1000);
//             } else {
//                 yield put({type: IndexActionTypes.SET_MESSAGE, msgContent: //res.message, msgType: 0});
//             }
         }
     }

然后就是在前端主页的加入dispatch(get_all_tags)的props,在组件挂载完毕后调用。以及在tags管理的部分,也dispatch(get_all_tags)。

后台delTag和addTag的api是正常操作:

router.get('/delTag', function (req, res) {
    let {name} = req.query;
    Tags.remove({name})
        .then(result => {
            if(result.result.n === 1){
                responseClient(res,200,0,'删除成功!')
            }else{
                responseClient(res,200,1,'标签不存在');
            }
        }).catch(err => {
        responseClient(res);
    });
});

//添加标签
router.post('/addTag', function (req, res) {
    let {name} = req.body;
    Tags.findOne({
        name
    }).then(result => {
        if (!result) {
            let tag = new Tags({
                name
            });
            tag.save()
                .then(data => {
                    responseClient(res, 200, 0, '添加成功', data);
                }).catch(err => {
                throw err
            })
        } else {
            responseClient(res, 200, 1, '该标签已存在');
        }
    }).catch(err => {
        responseClient(res);
    });
});

saga也是正常操作:

export function* delTag(name) {
    yield put({type: IndexActionTypes.FETCH_START});
    try {
        return yield call(get, `/admin/tags/delTag?name=${name}`);
    } catch (err) {
        yield put({type: IndexActionTypes.SET_MESSAGE, msgContent: '网络请求错误', msgType: 0});
    } finally {
        yield put({type: IndexActionTypes.FETCH_END})
    }
}
... ...

export function* delTagFlow() {
    while (true){
        let req = yield take(ManagerTagsTypes.DELETE_TAG);
        let res = yield call(delTag,req.name);
        if (res.code === 0) {
            yield put({type: IndexActionTypes.SET_MESSAGE, msgContent: res.message, msgType: 1});
            yield put({type:ManagerTagsTypes.GET_ALL_TAGS});
        } else if (res.message === '身份信息已过期,请重新登录') {
            yield put({type: IndexActionTypes.SET_MESSAGE, msgContent: res.message, msgType: 0});
            setTimeout(function () {
                location.replace('/');
            }, 1000);
        } else {
            yield put({type: IndexActionTypes.SET_MESSAGE, msgContent: res.message, msgType: 0});
        }
    }
}

Home.js 路由判断重定向

???


Article部分

GET请求的参数在URL中,在原生Node中,需要使用url模块来识别参数字符串。在Express中,不需要使用url模块了。可以直接使用req.query对象。POST请求在express中不能直接获得,必须使用body-parser模块。使用后,将可以用req.body得到参数。但是如果表单中含有文件上传,那么还是需要使用formidable模块。

增加文章

api部分也是比较常规,字段的定义可以参见对应的Schema,前端相应操作看源码应该问题不大的。

router.post('/addArticle', function (req, res) {
    const {
        title,
        content,
        time,
        tags,
        isPublish
    } = req.body;
    const author = req.session.userInfo.username;
    const coverImg =  `/${Math.round(Math.random() * 9 + 1)}.jpg`;
    const viewCount = 0;
    const commentCount = 0;
    let tempArticle = new Article({
        title,
        content,
        isPublish,
        viewCount,
        commentCount,
        time,
        author,
        coverImg,
        tags
    });
    tempArticle.save().then(data=>{
        responseClient(res,200,0,'保存成功',data)
    }).cancel(err=>{
        console.log(err);
        responseClient(res);
    });
});

前端state设计的小tip,就是将文章的title,content,tags,写到状态树里保存起来,这样子用户写文章写到一半突然点到别的地方去了,回来依然能够看到之前输入的内容:

//textarea为例子,其他是一个道理