这一篇文章是前后台交互的实现的梳理
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为例子,其他是一个道理
<textarea
value={this.props.content}
className={style.textArea}
onChange={this.onChanges.bind(this)} //在这里绑定好onchange
/>
onChanges(e){
this.props.updateContent(e.target.value) //dispatch(update_content(content))
}
//reducer
/* actionType */
/* */
export const actionType = {
UPDATE_TITLE: 'UPDATE_TITLE',
UPDATE_CONTENT: 'UPDATE_CONTENT',
UPDATE_TAGS: 'UPDATE_TAGS',
// SAVE_ARTICLE: 'SAVE_ARTICLE',
// SET_ARTICLE_ID: 'SET_ARTICLE_ID',
};
/*initialState*/
const initialState = {
title: '',
content: '',
tags: [],
id: '',
}
/* actionCreators*/
/* TODO: 考虑一下啊为什么没有saveID的action*/
export const actions = {
update_title: function (title) {
return {
type: actionType.UPDATE_TITLE,
title,
}
},
update_content: function (content) {
return {
type: actionType.UPDATE_CONTENT,
content
}
},
update_tag: function (tags) {
return {
type: actionType.UPDATE_TAGS,
tags,
}
},
// save_article: function (data) {
// return {
// type: actionType.SAVE_ARTICLE,
// data
// }
// }
}
/* reducers */
export function reducer(state = initialState, action) {
switch (action.type) {
case actionType.UPDATE_TITLE:
return {
...state,
title: action.title
};
case actionType.UPDATE_CONTENT:
return {
...state,
content: action.content
};
case actionType.UPDATE_TAGS:
return {
...state,
tags: action.tags
};
// case actionType.SET_ARTICLE_ID:
// return {
// ...state,
// id: action.id
// }
default:
return state;
}
}
至于文章的发布,其实就是在saga里除了保存文章之外,再加上isPublish字段的判断就好了。
未来在这个地方有可以改进的地方,就是加入上传图片的功能。文章预览的功能还没有加上,其实就是用antd的modal来做就好了。
告一段落总结
这个项目是跟着NealYang的react全家桶搭blog实战模仿或者说自己动手实践一遍的,这个博文也是自己思路的重新整理。
真正从头到尾实现过一次全栈的项目之后,对web开发心里终于有个谱子了。如果要列举收获的话:
- 首先是了解到了全栈开发环境的搭建。虽然说大神们可能会觉得这tm算个什么问题。但是开发环境的搭建确实对一个菜鸡来说,是第一道劝退的坎,先是用npm装了一万个不知道是什么的包,然后可能就会出现包的版本导致的问题。然后又是有webpack这样的模块管理打包工具的配置。在这个地方曾经就遇到过一个想象真的非常傻逼但是实实在在遇到了的问题撞到鬼(其实就是因为菜)的webpack+react搭环境记。以及然后在Neal大神的项目中,找了半天找不到HTML在哪里。结果是因为webpack里配置了一个HtmlWebpackPlugin。
有被劝退的感觉哦。但是世界上没有什么事情是坚强点解决不了的(误)。一点一点google,还原webpack真实面貌,它就是一个好用的工具啊,所以大家都在用啊,一点都不可怕的。搭环境这个坎最后还是过去了。
终于完成了一个比较复杂的React+Redux的前端。之前做的小的demo涉及到的state树都不是很复杂。通过这个项目了解到了了状态树的设计的一些tips。在哪些地方分发props,怎么组织组件。以及一些React-route路由跳转的用法。还有action和reducer的设计。actionCreator用props的形式在组件中存在,就可以dispatch相应的action。Saga监听这些action,然后发出相应的异步请求。再将请求到的数据交给reducer去更新状态树。
填补了理解空白区,知道了前后端交互的方式。前端saga+跑腿的axios+后台的express+数据库mongodb。知道了后台部分没有那么复杂(当然,这里用到的后台的东西都非常的基础,只是很简单的crud),对于一个之前一直对前后台交互有心里阴影望而却步(其实就是莫名的怂)的人,还是很有鼓舞作用的。毕竟已经迈出了一步了。
还有就是心态上的收获吧。有时候自己也在想,为什么我这个菜鸡内心戏会这么多。在舒适圈的边缘畏手畏脚。讲个道理,虽然这个项目只是仿写(或者直接叫临摹比较合适),但是在开发的过程中还是遇到过成山的报错。这个时候我多想反手就是关页面,关IDE,打开b站三连。但是想想这样下去明年秋招就凉凉了。结果最后排查发现,问题往往并不是什么根本无法解决的(毕竟大佬已经在前面实践过了)。在解决问题的过程中,对代码的逻辑就能理解的更深入了。所以说,如果真的有和我一样想做东西但是各种被劝退的朋友看到我这篇根本没有什么访问量的博客,其实没在怕的。不要怂,就是淦。或者也可以实现一个这样的东西。毕竟个人博客可能是前端实战非常好的第一课。
这个项目下一步计划,就是UI方面希望能做一套自己的设计。功能上已经是一个成熟的个人blog了,所以希望能够部署到服务器上。