基于React和Node实现一个食物热量参考应用

利用业余时间,自己做了一个食物热量参考网站,数据参考自一个app食物库。技术栈使用了sass+react+react-router+redux+antd+express+mongoose

node-health

一、How To Use

下载

首先将代码clone到本地

1
git clone https://github.com/mescalchuan/node-health.git

安装依赖包

1
cd node-health && npm i

引入数据并启动mongodb服务

要确保你已经安装了mongodb,然后在自己电脑上新建数据库文件夹(我的是E:\mongodbData\db)。在mongodb安装目录的bin文件夹下启动mongodb服务:

1
mongodb --dbpath="E:\mongodbData\db" --port 27017 -journal

启动成功后,数据库是没有任何数据的,我们需要将一些默认数据导入进来,我已经将这些数据导出成json了,你只需要重开一个命令行并输入:

1
2
mongoexport -d db -c category -o "E:node-health\db\category.json" --type json --port 27017
mongoexport -d db -c food -o "E:node-health\db\food.json" --type json --port 27017

这里推荐一个超轻量级数据库操作工具:adminMongo

adminMongo

如果数据导入成功,那么在foodcategory表里会看到导入进来的数据,否则,你需要在adminMongo里自己手动创建这两张表,然后再导入数据就可以了。

启动前端服务

1
2
cd /e/node-health
webpack --watch

用户:http://localhost:8888
管理员:http://localhost:8888/admin.html,用户名和密码均为admin

启动后台服务

1
2
cd /e/node-health
node app

项目截图

node-health

分类

推荐食物

高热量食物

食物详情

搜索结果页

管理员登录页

后台管理页

添加食物

食物详情

修改食物

删除食物

二、说明

该项目适用于有一定前端基础(包括reactredux)和node.js基础的同学,如果你正在学习node,但又无法将一系列知识体系串起来,那么本项目同样适合你~

三、环境搭建

整体目录结构

  • controller:后端控制层
  • db:导出的数据库json文件
  • model:后端模型层
  • router:后端路由
  • src:前端代码
  • admin.ejs:管理员页面(模板引擎)
  • app.js:后端根文件
  • index.ejs:用户页面(模板引擎)
  • webpack.config.js:webpack配置文件

前端

目录结构如下:

从头搭建webpack吧,由于用到了后台模板引擎,因此我们就不再单独用webpack启动一个服务了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
var path = require("path");
var webpack = require("webpack");

var OpenBrowserPlugin = require("open-browser-webpack-plugin");
var ExtractTextPlugin = require("extract-text-webpack-plugin");
var OptimizeCSSPlugin = require("optimize-css-assets-webpack-plugin");
//提高loader的解析速度
var HappyPack = require("happypack");
var CommonsChunkPlugin = webpack.optimize.CommonsChunkPlugin;
var NoEmitOnErrorsPlugin = webpack.NoEmitOnErrorsPlugin;
var UglifyJsPlugin = webpack.optimize.UglifyJsPlugin;

//externals配置的对象在生产环境下会自动引入CDN的对象,不会将node_modules下的文件打包进来
var externals = {
"React": "react",
"ReactDOM": "react-dom"
}
//配置多入口文件,包括用户和管理员
var entry = {
"index": "./src/index.js",
"admin": "./src/admin.js"
};

//最基本的webpack配置
var webpackConfig = {
entry: entry,
output: {
path: path.resolve(__dirname, "src/build"),
filename: "[name].bundle.js"
},
externals: externals,
devtool: "source-map",
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: ["happypack/loader?id=babel"]
},
{
test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
loader: "url-loader",
options: {
limit: 8192,
name: "[name].[ext]"
}
}, {
test: /\.css$/,
exclude: /node_modules/,
use: ExtractTextPlugin.extract({
fallback: "style-loader",
use: ["css-loader"]
})
},
{
test: /\.scss$/,
exclude: /node_modules/,
use: ExtractTextPlugin.extract({
use: ["css-loader", "sass-loader"],
fallback: "style-loader"
})
}
]
},
resolve: {
extensions: [".js", ".json"]
},
plugins: [
new HappyPack({
id: "babel",
loaders: [{
loader: "babel-loader",
options: {
presets: ["es2015", "stage-2", "react"]
}
}]
}),
new CommonsChunkPlugin({
name: ["vendor"],
filename: "vendor.bundle.js",
minChunks: Infinity
}),
new NoEmitOnErrorsPlugin(),
new OpenBrowserPlugin({
url: "http://localhost:8888"
}),
new ExtractTextPlugin("[name].bundle.css", {
allChunks: false
}),
//为了方便调试,暂时屏蔽
// new UglifyJsPlugin({
// minimize: true,
// output: {
// comments: false,
// beautify: false
// },
// compress: {
// warnings: false,
// drop_console: true,
// collapse_vars: true,
// reduce_vars: true
// }
// }),
new OptimizeCSSPlugin()
]
};

module.exports = webpackConfig;

之后,使用webpack --watch既可以完成打包。

后端

后端基于expressmongoose,用到了express-sessionbody-parser,所以我们先把这些包安装好:

1
npm i express mongoose express-session body-parser -S

然后我们看一下app.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
const express = require("express");
const mongoose = require("mongoose");
const cookieParser = require("cookie-parser");
const bodyParser = require("body-parser");
const session = require("express-session");

const app = express();

app.use(cookieParser());
//解析post请求
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
//设置session
app.use(session({
secret: 'keyboard cat',
resave: false,
saveUninitialized: true
}));

//设置存放模板文件的目录
app.set("views", __dirname);
//设置模板引擎为ejs
app.set("view engine", "ejs");
//访问静态资源文件
app.use(express.static("src"));
app.use(express.static(__dirname));
app.get("/", (req, res) => {
return res.render("index", {
userName: "",
token: "",
hasLogin: false
})
})
//连接mongodb,db为该工程的数据库名
mongoose.connect("mongodb://localhost/db", function(err, db) {
if(err) {
console.log("连接失败");
process.exit(1);
}
else {
console.log("连接成功")
}
})

app.listen("8888", () => {
console.log("server created!");
})

运行node app

我们还可以使用supervisor实现代码更新功能,只需要npm i supervisor -g然后用supervisor app代替node app即可。每次代码有了变更都会自动帮你重启服务器。

环境搭建结束

到此步为止,环境搭建已经结束,项目也可以成功跑起来了,只不过没有任何内容,剩下的就是一步一步写业务。

四、CSRF防范

在写业务之前,简单实现了一下CSRF的防范,我的做法是管理员登录成功后,后端直接在页面中生成一个script标签,标签内包含了简单的登录信息和token。之后管理员每一次与后端交互都要发送这个token,由后端校验token,如果不一致,则直接返回,不再执行正常逻辑。

模板引擎

由后端生成script标签,让我最先想到了模板引擎,因此我使用了ejs来实现该功能,这也是为什么用户页面和管理员页面的后缀不是html的原因。我们看一下admin.html里面的内容吧:

当管理员登录成功后,就可以全局访问userInfo了。

下面我们看一下登录的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const login = module.exports = (req, res) => {
const {userName, password} = req.body;
const session = req.session;
//用户名和密码正确,保存session,并告知前端登录成功
if(userName === "admin" && password === "admin") {
if(session) {
if(session.user) {
res.json({
retCode: -1,
retMsg: "您已登录过了"
})
}
else {
session.user = {
userName,
password
}
res.json({
retCode: 0,
retInfo: {}
})
}
}
else {
res.json({
retCode: -1,
retMsg: ""
})
}
}
else {
res.json({
retCode: -1,
retMsg: "用户名或密码错误"
})
}
}

中间件

管理员登录成功后,session里面保存了登录信息,那么下一步就是生成token并将其和用户登录信息渲染到页面中。在asp.netjava中有一个叫做拦截器的东西,它的作用就是拦截所有请求,包括ajax请求和资源请求,在其中做一些操作然后控制请求是否继续往下执行,就像一个管道一样。在express中,中间件的作用和其是一样的,我们看一下中间件的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
//app,js
const interceptor = require("./controller/interceptorCtrl");
app.use((req, res, next) => {
interceptor(req, res, next);
})

//interceptorCtrl.js
const jwt = require('jsonwebtoken');

const interceptor = module.exports = (req, res, next) => {
let url = req.path;
//页面请求,判断session是否有值,如果有的话则生成token并将userName、token、hasLogin渲染到页面上
if(!!(~url.indexOf(".html"))) {
url = url.replace(/\//g, "");
const page = url.split(".")[0];
//将用户登录信息和token返回给前台
if(req.session.user) {
const token = jwt.sign({name: "token"}, "node-health", {expiresIn: 600});
const { userName } = req.session.user;
res.render(page, {
userName,
token,
hasLogin: true
})
}
else {
res.render(page, {
userName: "",
token: "",
hasLogin: false
})
}
next();
}
//如果是ajax请求并且请求接口来自管理员,那么校验请求参数中的token是否正确,不正确的话则直接返回retCode 500
else if(!!(~url.indexOf("/api/admin"))) {
let token = "";
const method = req.method.toLowerCase();
if(method == "get") {
token = req.query.token;
}
else {
token = req.body.token;
}
jwt.verify(token, "node-health", function (err, decoded) {
if (!err) {
if(decoded.name !== "token") {
return res.json({
retCode: 500,
retMsg: "csrf"
})
}
else {
next();
}
}
else {
return res.json({
retCode: 500,
retMsg: "csrf"
})
}
})
}
else {
next();
}
}

功能很简单:如果是页面请求,则判断session是否有用户信息:如果有的话说明登录成功了,生成token并将其和登录信息渲染到页面上;如果没有登录信息,则渲染空值即可,执行next()让请求继续往下执行。如果是ajax请求,获取请求参数中的token并解密,校验值的正确性:如果不正确,则直接返回错误信息,请求不再往下执行;如果正确,执行next()让请求继续往下执行。

五、写一个Ajax吧

我们以管理员获取所有分类为例,看一下前后端分别是如何实现的。

前端

组件在componentDidMount阶段发起server的请求 –> 等待后端返回数据 –> 发起action –> reducer中保存数据 –> 更新视图

由于用户和管理员都需要获取分类列表,因此我将分类的serveraction都划分到了用户模块。

src/components/admin/center.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { bindActionCreators } from "redux";
import { connect } from "react-redux";
import * as server from "../../server/adminServer";
...
componentDidMount() {
this.props.actions.getCategory({token: userInfo.token}, null, res => message.error(res.retMsg));
}
render() {
return (
<div>
...
{/*渲染分类列表*/}
{this.props.category.map((item, index) => (<div>...</div>)}
</div>
)
}
...
// 将actions绑定到props上
const mapDispatchToProps = (dispatch) => ({
actions: bindActionCreators(server, dispatch)
});
//将state绑定到props上
const mapStateToProps = (state) => ({
category: state.adminReducer.category
});

export default connect(mapStateToProps, mapDispatchToProps)(AdminCenter);
src/server/adminServer.js

这里使用到了redux-thunk

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
...
import * as action from "../action/userAction";
export function getCategory(successBK, errorBK) {
return (dispatch, getState) => {
return getData(url.SERVER_ADMIN + url.GET_CATEGORY).then(res => {
if(res.retCode == 0) {
dispatch(action.getCategory(res.retInfo));
successBK && successBK(res.retInfo);
}
else {
errorBK && errorBK(res);
}
}, e => console.log(e))
.catch(e => console.log(e))
}
}
src/actionType/userAction.js
1
2
3
4
5
6
7
...
export function getCategory(category) {
return {
type: types.GET_CATEGORY,
category
}
}
src/reducer/adminReducer.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
...
const defaultState = {
category: []
}
const adminReducer = (state = defaultState, action) => {
switch(action.type) {
case types.GET_CATEGORY:
return Object.assign({}, state, {
category: action.category
})
default:
return state;
}
}
export default adminReducer;

后端

中间件拦截请求,校验token并继续执行 –> 路由映射 –> 转发给控制层 –> 处理并返回数据

app.js
1
2
3
4
5
...
const adminRouter = require("./router/adminRouter");

app.use("/api/admin", adminRouter);
...
router/adminRouter.js
1
2
3
4
5
6
7
8
9
10
const express = require("express");
const category= require("../controller/user/category");
const router = express.Router();
...
//调用控制层
router.get("/getCategory", (req, res) => {
category.getCategory(req, res);
})

module.exports = router;
controller/user/category
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const models = require("../../model/index");
//从数据库中读取分类并返回给前端
const getCategory = (req, res) => {
models.Category.find((err, result) => {
if(err) {
res.json({
retCode: -1,
retMsg: "mongoose error"
})
}
res.json({
retCode: 0,
retInfo: result
})
})
}

module.exports = {
getCategory
}

六、图片上传

管理员添加和修改食物信息时需要上传图片。如果只是练习的话,可以将图片保存到本地并将图片绝对路径保存到数据库中。但是,我们来个更加贴切真实项目的吧,将图片保存到图片服务器中~

我们将图片保存到七牛云存储系统中,你需要先注册个账号,官网地址在这里
七牛云取消了测试账号,现已将图片全部存储在阿里云中,官网地址在这里

在管理控制台 –> 对象存储 –> 内容管理中可以看到已经存储的图片:
在控制台 –> 对象存储 OSS –> 文件管理中可以看到已经存储的图片:

下一步要做的就是前端上传图片发送给后端,后端上传到七牛云阿里云并将图片链接保存到数据库。

前端上传图片

使用<input type="file" />实现图片选择。默认样式比较丑,因此我自己重写了样式:

然后要做的就是使用formData对象将图片信息发送给后端。

1
2
3
4
5
6
7
const fileEle = this.refs.file;
const file = fileEle.files[0];
let formData = new FormData();
formData.append("imgUrl", file);
formData.append("name", this.state.name);
...
this.props.actions.addFood(formData);

后端接收图片

后端接收图片需要用到multiparty插件,你只需要npm i multiparty -S即可。

1
2
3
4
5
6
7
8
9
10
//controller/admin/foodHandler.js
const multiparty = require("multiparty");

const addFood = (req, res) => {
const form = new multiparty.Form();
form.parse(req, (err, fields, files) => {
console.log(fields);
console.log(files);
})
}

上传到七牛云阿里云

我们需要使用到七牛云的node sdknpm i qiniu -Snpm i ali-oss。使用文档请访问Node.js SDK

我们首先要做一些配置:

1
2
3
4
5
6
7
8
9
10
const domain = "http://mescal-chuan.oss-cn-beijing.aliyuncs.com/";

const OSS = require('ali-oss');
const client = new OSS({
region: 'oss-cn-beijing',
//云账号AccessKey有所有API访问权限,建议遵循阿里云安全最佳实践,部署在服务端使用RAM子账号或STS,部署在客户端使用STS。
accessKeyId: 'LTAIa2EaQxqPMBfb',
accessKeySecret: 'WjKeNw8gAdU1y80SpO1JYnWfzq9Pbe',
bucket: 'mescal-chuan'
});

接下来要做的就是将图片信息上传到七牛云阿里云:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const file = files.imgUrl[0];
const localFile = file.path//"/Users/jemy/Documents/qiniu.mp4";
let temp = file.path.split("\\");
if(temp.length <= 1) {
temp = file.path.split("/")
}
const key = temp[temp.length - 1]//'test.mp4';
// 文件上传
client.put('/' + key, localFile).then((respBody, reject) => {
if (reject) {
res.json({
retCode: -1,
retMsg: "ali yun upload error"
})
throw reject;
}
if(respBody.res.statusCode == 200) {
const imgUrl = domain + respBody.name;
//保存到数据库即可
}
}

我们可以在七牛云上看到已经上传的图片:

结束语

本项目从功能上来说只是简单的CRUD,但用到的技术比较多,也是为了给自己做一个整体技术栈的实战,后期还可以考虑添加分页和排序功能。

如果你觉得对你有帮助,欢迎star~,如果有任何疑问或bug,也欢迎提供issue