创建 MERN Stack 应用程序(2020)
开始之前,请确保已安装Node和Create React App。如果您计划使用本地 mongodb 数据库,也请确保已完成相关设置。
参考资料
Gitflow 工作流
连接到 MongoDB
MongoDB CRUD 操作
MongoDB Atlas
Mongoose
Express.js
EJS
React
React Router
Redux
Netlify
Vercel
所需工具
你可以使用任何代码编辑器和终端应用程序。但对于与后端的 HTTP API 交互,我更喜欢 Postman 应用程序。
代码编辑器:Visual Studio Code
终端:Hyper
API 测试应用程序:Postman
清单
以下是我们将要遵循的步骤
- 使用 GIT 工作流初始化项目(可选为项目设置看板)
- 设置 MongoDB 数据库(本地或在线)
- 创建使用 CRUD 请求连接到数据库的后端 Node/Express 服务器
- 使用 EJS 或 React/Redux 创建前端
- Ejs 模板(HTML 和 CSS Grid/Flexbox)
- React/Redux(使用 CSS Grid/Flexbox 样式化的组件)
- 在线部署到生产服务器(Netlify、Vercel、Heroku 等……)
项目设置
我将创建一个应用程序来追踪我看过的动漫。不过,你可以随意使用你喜欢的主题。
GIT 工作流程
前往 GitHub 创建一个新的 repo,然后在本地机器上创建一个文件夹,并cd
使用终端应用程序进入该文件夹。然后像下面这样初始化 repo。
在整个项目过程中,您应该将您的工作提交到 GitHub 并遵循 GIT 工作流程。
echo "# anime-tracker" >> README.md
git init
git add README.md
git commit -m "first commit"
git remote add origin https://github.com/yourname/anime-tracker.git
git push -u origin master
设置 MongoDB 数据库
生产环境需要使用在线数据库,本地数据库仅用于开发目的。无论哪种方式,您都可以在本指南中使用您想要的数据库。
在线
https://www.mongodb.com/cloud/atlas
https://mlab.com/
您应该有一个如下所示的连接字符串,用您的凭据替换用户名和密码
mongodb+srv://<username>:<password>@cluster0-tyqyw.mongodb.net/<dbname>?retryWrites=true&w=majority
当地的
确保本地安装了 mongoDB 和 mongoDB compass
在终端中使用以下命令创建您选择的本地数据库
mongo
show dbs;
use animes;
db.createCollection("series");
要连接到数据库,您将使用下面的连接字符串
mongodb://127.0.0.1:27017/animes
设置文件夹结构并安装依赖项
在代码编辑器中打开项目文件夹,创建后端文件夹,然后安装依赖项
touch .gitignore
mkdir backend
cd backend
npm init -y
npm i express nodemon ejs cors concurrently mongoose dotenv
设置后端文件夹内的文件夹结构
mkdir controllers
mkdir models
mkdir public
mkdir routes
mkdir src
mkdir src/pages
touch app.js
touch .gitignore
将node_modules
.env
和添加.DS_Store
到.gitignore
根文件夹和后端文件夹中的文件
创建连接数据库的 Node/Express 服务器
.env
在项目根目录中创建一个文件。以 的形式在新行中添加特定于环境的变量NAME=VALUE
。例如:
DB_HOST="mongodb://127.0.0.1:27017/animes"
DB_USER="databaseuser"
DB_PASS="databasepassword"
打开app.js
文件并添加以下代码
本地 MongoDB 数据库不需要用户名和密码,只需要主机
const express = require('express');
const mongoose = require('mongoose');
const path = require('path');
const cors = require('cors');
require('dotenv').config();
const app = express();
app.use(cors());
app.set('view engine', 'ejs');
app.set('views', './src/pages');
app.use(express.urlencoded({ extended: false }));
app.use('/static', express.static(path.join(`${__dirname}/public`)));
app.get('/', (req, res) => res.send('Home Route'));
const port = process.env.PORT || 8080;
mongoose
.connect(process.env.DB_HOST, {
useCreateIndex: true,
useUnifiedTopology: true,
useNewUrlParser: true,
useFindAndModify: false,
})
.then(() => {
app.listen(port, () => console.log(`Server and Database running on ${port}, http://localhost:${port}`));
})
.catch((err) => {
console.log(err);
});
打开package.json
文件并为启动、开发和服务器添加以下运行脚本
{
"name": "backend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node app.js",
"dev": "nodemon app.js",
"servers": "concurrently \"npm run dev\" \"cd ../frontend && npm run start\""
},
"keywords": [],
"author": "Andrew Baisden",
"license": "MIT",
"dependencies": {
"concurrently": "^5.2.0",
"cors": "^2.8.5",
"dotenv": "^8.2.0",
"ejs": "^3.1.3",
"express": "^4.17.1",
"mongoose": "^5.9.24",
"nodemon": "^2.0.4"
}
}
在终端窗口中使用该命令npm run dev
,应用程序应该启动并运行并连接到您的 mongodb 数据库。
树状结构(不显示隐藏文件)
│
...
8 个目录,4 个文件
创建控制器和路由文件
首先创建一个index.ejs
文件src/pages
并添加下面的html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Home</title>
</head>
<body>
<h1>Home Page</h1>
</body>
</html>
然后创建一个edit-anime.ejs
文件src/pages
并添加下面的 html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Add Anime</title>
</head>
<body>
<h1>Add Anime</h1>
<form method="POST" action="/add-anime">
<div>
<label>Name</label>
<input type="text" name="name" required />
</div>
<div>
<label>Image</label>
<input type="text" name="image" required />
</div>
<div>
<label>Description</label>
<input type="text" name="description" required />
</div>
<div>
<button type="submit">Add Anime</button>
</div>
</form>
</body>
</html>
最后创建一个anime.ejs
文件src/pages
并添加下面的 html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Anime</title>
</head>
<body>
<h1>Anime</h1>
</body>
</html>
接下来创建一个admin.js
文件并将其放入controllers
文件夹中
exports.getIndex = (req, res) => {
res.status(200).render('index');
};
然后创建一个admin.js
文件并将其放入routes
文件夹中
const express = require('express');
const adminController = require('../controllers/admin');
const router = express.Router();
router.get('/', adminController.getIndex);
module.exports = router;
将管理路由文件导入到app.js
根文件夹中的主文件中,并用新的管理路由替换主路由
const adminRoute = require('./routes/admin');
// Replace the code for the old route with the new route code
// Old Code
app.get('/', (req, res) => res.send('Home Route'));
// New Code
app.use('/', adminRoute);
创建 Mongoose Schema
在模型文件夹中创建一个Anime.js
文件,然后将下面的代码复制并粘贴到该文件中
const mongoose = require('mongoose');
const AnimeSchema = mongoose.Schema({
name: {
type: String,
required: true,
},
image: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
});
module.exports = mongoose.model('series', AnimeSchema);
创建 CRUD 请求
接下来,我们将创建用于与数据库交互的 CRUD 请求。这也是使用 Postman 应用为所有路由执行 HTTP 请求的绝佳机会。这将允许您在不使用浏览器的情况下 POST 数据并查看 GET 路由。这超出了本指南的范围,但如果您查看文档,就会发现它非常容易使用。
向数据库添加数据(创建)
我们正在为带有添加表单的页面创建路由,并使用 post 路由将该表单数据添加到数据库
使用下面的代码更新文件夹admin.js
中的文件controllers
const Anime = require('../models/Anime');
exports.getIndex = (req, res) => {
res.status(200).render('index');
};
exports.getAddAnime = (req, res) => {
res.status(200).render('edit-anime');
};
exports.postAnime = (req, res) => {
const { name, image, description } = req.body;
const anime = new Anime({ name: name, image: image, description: description });
anime.save();
console.log('Anime Added to the database');
res.status(201).redirect('/');
};
使用下面的代码更新文件夹admin.js
中的文件routes
const express = require('express');
const adminController = require('../controllers/admin');
const router = express.Router();
router.get('/', adminController.getIndex);
router.get('/add-anime', adminController.getAddAnime);
router.post('/add-anime', adminController.postAnime);
module.exports = router;
现在,如果您访问http://localhost:8080/add-anime并提交一些表单数据,这些数据应该会被添加到您的数据库中。如果您使用的是本地 MongoDB 数据库,请使用 MongoDB Compass 应用检查数据库,您需要刷新数据库才能看到新条目。如果您使用的是在线数据库,只需访问集群即可查看集合。
或者使用 Postman App 向路由http://localhost:8080/add-anime发送 post 请求,如下例所示
从数据库读取数据(读取)
现在,我们从数据库中检索数据,并通过异步函数调用将其渲染到页面中。我们将使用.ejs
模板语言创建页面,因此如果您想了解代码,请参阅文档。它基本上类似于原生 JavaScript,但带有.ejs
模板语法标签,因此应该很容易理解。
使用下面的代码更新文件夹admin.js
中的文件controllers
const Anime = require('../models/Anime');
exports.getIndex = async (req, res) => {
const anime = await Anime.find((data) => data);
try {
console.log(anime);
res.status(200).render('index', { anime: anime });
} catch (error) {
console.log(error);
}
};
exports.getAnime = async (req, res) => {
const animeId = req.params.animeId;
const anime = await Anime.findById(animeId, (anime) => anime);
try {
console.log(anime);
res.status(200).render('anime', { anime: anime });
} catch (error) {
console.log(error);
}
};
exports.getAddAnime = (req, res) => {
res.status(200).render('edit-anime');
};
exports.postAnime = (req, res) => {
const { name, image, description } = req.body;
const anime = new Anime({ name: name, image: image, description: description });
anime.save();
console.log('Anime Added to the database');
res.status(201).redirect('/');
};
使用下面的代码更新文件夹admin.js
中的文件routes
const express = require('express');
const adminController = require('../controllers/admin');
const router = express.Router();
router.get('/', adminController.getIndex);
router.get('/add-anime', adminController.getAddAnime);
router.post('/add-anime', adminController.postAnime);
router.get('/:animeId', adminController.getAnime);
module.exports = router;
使用下面的代码更新文件夹index.ejs
中的文件src/pages
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Home</title>
</head>
<body>
<h1>Home Page</h1>
<main>
<% anime.forEach(data => { %>
<ul>
<li><h1><a href="/<%= data.id %>"><%= data.name %></a></h1></li>
<li><img src="<%= data.image %>" alt="<%= data.name %>" /></h1></li>
<li><p><%= data.description %></p></li>
</ul>
<% }) %>
</main>
</body>
</html>
使用下面的代码更新文件夹anime.ejs
中的文件src/pages
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Anime</title>
</head>
<body>
<h1>Anime</h1>
<main>
<h1><%= anime.name %></h1>
<img src="<%= anime.image %>" alt="<%= anime.name %>" />
<p><%= anime.description %></p>
</main>
</body>
</html>
现在,你应该可以看到数据库数据已渲染到主页上,点击其中一个链接后,它会根据链接的 ID 跳转到对应的页面。这些数据也会被记录到控制台中。
从数据库中删除数据(Delete)
现在我们正在创建一个删除路由来从数据库中删除项目
使用下面的代码更新文件夹admin.js
中的文件controllers
const Anime = require('../models/Anime');
exports.getIndex = async (req, res) => {
const anime = await Anime.find((data) => data);
try {
console.log(anime);
res.status(200).render('index', { anime: anime });
} catch (error) {
console.log(error);
}
};
exports.getAnime = async (req, res) => {
const animeId = req.params.animeId;
const anime = await Anime.findById(animeId, (anime) => anime);
try {
console.log(anime);
res.status(200).render('anime', { anime: anime });
} catch (error) {
console.log(error);
}
};
exports.getAddAnime = (req, res) => {
res.status(200).render('edit-anime');
};
exports.postAnime = (req, res) => {
const { name, image, description } = req.body;
const anime = new Anime({ name: name, image: image, description: description });
anime.save();
console.log('Anime Added to the database');
res.status(201).redirect('/');
};
exports.postDelete = async (req, res) => {
const animeId = req.body.animeId;
const anime = await Anime.findByIdAndRemove(animeId, (data) => data);
try {
console.log(anime);
console.log('Item Deleted');
res.redirect('/');
} catch (error) {
console.log(error);
}
};
使用下面的代码更新文件夹admin.js
中的文件routes
const express = require('express');
const adminController = require('../controllers/admin');
const router = express.Router();
router.get('/', adminController.getIndex);
router.get('/add-anime', adminController.getAddAnime);
router.post('/add-anime', adminController.postAnime);
router.get('/:animeId', adminController.getAnime);
router.post('/delete', adminController.postDelete);
module.exports = router;
使用下面的代码更新文件夹anime.ejs
中的文件src/pages
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Anime</title>
</head>
<body>
<h1>Anime</h1>
<main>
<h1><%= anime.name %></h1>
<img src="<%= anime.image %>" alt="<%= anime.name %>" />
<p><%= anime.description %></p>
<div>
<form method="POST" action="/delete">
<div>
<input type="hidden" value="<%= anime.id %>" name="animeId" />
<button>Delete</button>
</div>
</form>
</div>
</main>
</body>
</html>
现在,如果您进入某个项目页面,然后单击“删除”按钮,您应该能够删除它
从数据库更新数据(更新)
现在,我们正在创建用于更新数据库中的每个项目的路由。使用以下代码更新文件。
使用下面的代码更新文件夹admin.js
中的文件controllers
const Anime = require('../models/Anime');
exports.getIndex = async (req, res) => {
const anime = await Anime.find((data) => data);
try {
console.log(anime);
res.status(200).render('index', { anime: anime });
} catch (error) {
console.log(error);
}
};
exports.getAnime = async (req, res) => {
const animeId = req.params.animeId;
const anime = await Anime.findById(animeId, (anime) => anime);
try {
console.log(anime);
res.status(200).render('anime', { anime: anime });
} catch (error) {
console.log(error);
}
};
exports.getAddAnime = (req, res) => {
res.status(200).render('edit-anime', { editing: false });
};
exports.getEditAnime = async (req, res) => {
const animeId = req.params.animeId;
const editMode = req.query.edit;
if (!editMode) {
return res.redirect('/');
}
const anime = await Anime.findById(animeId);
try {
if (!animeId) {
return res.redirect('/');
}
console.log(anime);
res.status(200).render('edit-anime', { anime: anime, editing: editMode });
} catch (error) {
console.log(error);
}
};
exports.postAnime = (req, res) => {
const { name, image, description } = req.body;
const anime = new Anime({ name: name, image: image, description: description });
anime.save();
console.log('Anime Added to the database');
res.status(201).redirect('/');
};
exports.postEditAnime = (req, res) => {
const animeId = req.body.animeId;
const { name, image, description } = req.body;
Anime.findById(animeId)
.then((anime) => {
anime.name = name;
anime.image = image;
anime.description = description;
return anime.save();
})
.then(() => {
console.log('Item Updated');
res.status(201).redirect('/');
})
.catch((err) => {
console.log(err);
});
};
exports.postDelete = async (req, res) => {
const animeId = req.body.animeId;
const anime = await Anime.findByIdAndRemove(animeId, (data) => data);
try {
console.log(anime);
console.log('Item Deleted');
res.redirect('/');
} catch (error) {
console.log(error);
}
};
使用下面的代码更新文件夹admin.js
中的文件routes
const express = require('express');
const adminController = require('../controllers/admin');
const router = express.Router();
router.get('/', adminController.getIndex);
router.get('/add-anime', adminController.getAddAnime);
router.get('/edit-anime/:animeId', adminController.getEditAnime);
router.post('/add-anime', adminController.postAnime);
router.post('/edit-anime', adminController.postEditAnime);
router.get('/:animeId', adminController.getAnime);
router.post('/delete', adminController.postDelete);
module.exports = router;
使用下面的代码更新文件夹anime.ejs
中的文件src/pages
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Anime</title>
</head>
<body>
<h1>Anime</h1>
<main>
<h1><%= anime.name %></h1>
<img src="<%= anime.image %>" alt="<%= anime.name %>" />
<p><%= anime.description %></p>
<div>
<form method="POST" action="/delete">
<div>
<input type="hidden" value="<%= anime.id %>" name="animeId" />
<button>Delete</button>
</div>
</form>
</div>
<div>
<a href="/edit-anime/<%= anime.id %>?edit=true">Edit</a>
</div>
</main>
</body>
</html>
使用下面的代码更新文件夹edit-anime.ejs
中的文件src/pages
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>
<% if(editing){ %>Edit Anime<% } else { %>Add Anime<% } %>
</title>
</head>
<body>
<h1><% if(editing){ %>Edit Anime<% } else { %>Add Anime<% } %></h1>
<form method="POST" action="/<% if(editing){ %>edit-anime<% } else { %>add-anime<% } %>">
<div>
<label>Name</label>
<input type="text" name="name" value="<% if(editing){ %><%= anime.name %><% } %>" required />
</div>
<div>
<label>Image</label>
<input type="text" name="image" value="<% if(editing){ %><%= anime.image %><% } %>" required />
</div>
<div>
<label>Description</label>
<input type="text" name="description" value="<% if(editing){ %><%= anime.description %><% } %>" required />
</div>
<% if(editing){ %>
<div>
<input type="hidden" name="animeId" value="<%= anime.id %>" />
</div>
<% } %>
<div>
<button type="submit"><% if(editing){ %>Edit Anime<% } else { %>Add Anime<% } %></button>
</div>
</form>
</body>
</html>
现在,当您进入某个商品页面时,您会看到一个编辑按钮。点击该按钮后,您将跳转到已使用数据库中该商品数据更新的表单。更新商品后,系统会将您重定向到主页,您将在那里看到新的更改。
React 前端
恭喜,您刚刚创建了一个连接到 MongoDB 数据库并具有完整 CRUD 请求的全栈应用程序!然而,它还不是 MERN 应用,因为它没有 React 前端。下一阶段很简单,您只需返回后端数据,并json
使用 fetch 或 axios 请求获取数据即可。至于表单,您只需确保将 POST 请求发送到后端服务器即可。我们从一开始就安装了 CORS,因此当您尝试将前端连接到后端时不会出现跨域错误。我们还设置了一个运行脚本来同时运行后端和前端服务器,这将使其更加完善。
在根文件夹中创建一个前端文件夹,然后在其中设置一个反应应用程序
mkdir frontend
cd frontend
npx create-react-app .
返回后端的根文件夹,然后运行命令npm run servers
同时启动后端和前端服务器。你应该会在浏览器中看到你的 React 应用正在运行。
现在转到后端文件夹并进入controllers/admin.js
并使用下面的代码更新代码。
我们所做的就是将发送到索引路由的数据返回,以便.json
我们可以使用 fetch/axios 在前端进行映射。我们还将更新用于添加新动漫的 POST 路由,使其重定向到 React 前端应用的索引页。
const Anime = require('../models/Anime');
exports.getIndex = async (req, res) => {
const anime = await Anime.find((data) => data);
try {
console.log(anime);
// Data rendered as an object and passed down into index.ejs
// res.status(200).render('index', { anime: anime });
// Data returned as json so a fetch/axios requst can get it
res.json(anime);
} catch (error) {
console.log(error);
}
};
exports.getAnime = async (req, res) => {
const animeId = req.params.animeId;
const anime = await Anime.findById(animeId, (anime) => anime);
try {
console.log(anime);
res.status(200).render('anime', { anime: anime });
} catch (error) {
console.log(error);
}
};
exports.getAddAnime = (req, res) => {
res.status(200).render('edit-anime', { editing: false });
};
exports.getEditAnime = async (req, res) => {
const animeId = req.params.animeId;
const editMode = req.query.edit;
if (!editMode) {
return res.redirect('/');
}
const anime = await Anime.findById(animeId);
try {
if (!animeId) {
return res.redirect('/');
}
console.log(anime);
res.status(200).render('edit-anime', { anime: anime, editing: editMode });
} catch (error) {
console.log(error);
}
};
exports.postAnime = (req, res) => {
const { name, image, description } = req.body;
const anime = new Anime({ name: name, image: image, description: description });
anime.save();
console.log('Anime Added to the database');
// Updated the home route to the React App index page
res.status(201).redirect('http://localhost:3000/');
};
exports.postEditAnime = (req, res) => {
const animeId = req.body.animeId;
const { name, image, description } = req.body;
Anime.findById(animeId)
.then((anime) => {
anime.name = name;
anime.image = image;
anime.description = description;
return anime.save();
})
.then(() => {
console.log('Item Updated');
res.status(201).redirect('/');
})
.catch((err) => {
console.log(err);
});
};
exports.postDelete = async (req, res) => {
const animeId = req.body.animeId;
const anime = await Anime.findByIdAndRemove(animeId, (data) => data);
try {
console.log(anime);
console.log('Item Deleted');
res.redirect('/');
} catch (error) {
console.log(error);
}
};
现在转到前端文件夹并进入src/app.js
并将代码替换为下面的代码
import React, { Fragment, useEffect, useState } from 'react';
const App = () => {
useEffect(() => {
const getAPI = async () => {
const response = await fetch('http://localhost:8080/');
const data = await response.json();
try {
console.log(data);
setLoading(false);
setAnime(data);
} catch (error) {
console.log(error);
}
};
getAPI();
}, []);
const [anime, setAnime] = useState([]);
const [loading, setLoading] = useState(true);
return (
<Fragment>
<h1>Anime Home</h1>
<div>
{loading ? (
<div>Loading</div>
) : (
<div>
{anime.map((data) => (
<div key={data._id}>
<ul>
<li>
<h1>
<a href="/{data.id}">{data._id}</a>
</h1>
</li>
<li>
<img src={data.image} alt={data.name} />
</li>
<li>
<p>{data.description}</p>
</li>
</ul>
</div>
))}
</div>
)}
</div>
<div>
<h1>Add New Anime</h1>
<form method="POST" action="http://localhost:8080/add-anime">
<div>
<label>Name</label>
<input type="text" name="name" required />
</div>
<div>
<label>Image</label>
<input type="text" name="image" required />
</div>
<div>
<label>Description</label>
<input type="text" name="description" required />
</div>
<div>
<button type="submit">Add Anime</button>
</div>
</form>
</div>
</Fragment>
);
};
export default App;
现在,当您访问http://localhost:3000/ 时,您应该会看到您的数据在前端呈现
我还在底部创建了一个表单,用于向数据库添加新条目。显然,在一个完整的项目中,你应该使用组件来构建你的应用。我只是创建了一个简单示例来展示它的样子。
干得好,你刚刚创建了一个 MERN 应用,这些就是基础!为了完成这个应用,你应该使用React Router在前端添加路由,以便创建更多动态页面。我推荐使用Styled Components,但你也可以使用任何你想要的 CSS 库。你甚至可以添加 Redux 或其他状态库。只需确保在后端使用 GET 路由返回数据,.json
这样你就可以在前端使用 fetch/axios 来管理数据。
或者,您也可以只使用前端,并使用 CSS 来添加样式和导航,一切由您决定。应用完成后,只需将其部署到Netlify和Vercel.ejs
等众多可用平台之一即可。
你可以在 GitHub 上的Anime Tracker上看到我的最终版本,欢迎随意克隆和下载代码库。此版本包含.ejs
前端和 CSS。我还对代码库做了一些细微的调整。