Get your free and exclusive 80-page Banking Passkey Report
react express crud app mysql

React、Node.js、Express 和 MySQL CRUD 应用教程

本教程将教您如何使用 React 作为前端、Express 作为后端,并结合 MySQL 数据库来创建一个基本的 CRUD 应用。

Blog-Post-Author

Lukas

Created: June 17, 2025

Updated: July 8, 2025


See the original blog version in English here.

在本综合教程中,您将学习如何使用 React 作为前端、Node.jsExpress 作为后端,并由 MySQL 数据库提供支持,来创建一个全栈CRUD(创建、读取、更新、删除)应用程序。我们将利用 React Router、Axios 和 TailwindCSS 等技术来增强用户体验。

Debugger Icon

Want to experiment with passkey flows? Try our Passkeys Debugger.

Try for Free

1. 项目概述#

我们将开发一个全栈应用程序,用户可以在其中管理教程。每个教程将具有以下属性:

  • ID:唯一标识符
  • 标题:教程的名称
  • 描述:详细信息
  • 发布状态:指示教程是否已发布

用户可以执行以下操作:

  • 创建 新教程
  • 检索 所有教程或通过 ID 检索特定教程
  • 更新 现有教程
  • 删除 教程
  • 按标题搜索 教程
Subreddit Icon

Discuss passkeys news and questions in r/passkey.

Join Subreddit

在下文中,您会看到一些示例截图:

1.1 添加新教程#

1.2 显示所有教程#

Analyzer Icon

Are your users passkey-ready?

Test Passkey-Readiness

1.3 编辑教程#

1.4 按标题搜索教程#

Substack Icon

Subscribe to our Passkeys Substack for the latest news.

Subscribe

2. 架构#

该应用程序遵循客户端-服务器架构:

  • 后端Node.jsExpress 负责处理 RESTful API,并使用 Sequelize ORM 与 MySQL 数据库进行交互。
  • 前端:React.js 通过 Axios 与后端进行 HTTP 请求通信,并使用 React Router 进行导航。

3. 后端实现#

3.1 设置 Node.js 应用程序#

  1. 创建项目目录:

    mkdir react-node-express-mysql-crud cd react-node-express-mysql-crud
  2. 初始化 Node.js 应用:

    npm init -y
  3. 安装依赖项:

    npm install express sequelize mysql2 cors --save
  4. 使用 ESModule 语法 将以下行添加到您的 package.json 文件中:

    { "type": "module" // ... }

3.2 配置 MySQL 和 Sequelize#

  1. 创建配置文件 (app/config/db.config.js):
export default { HOST: "localhost", USER: "root", PASSWORD: "root", DB: "db", PORT: 8081, dialect: "mysql", pool: { max: 5, min: 0, acquire: 30000, idle: 10000, }, };
  1. 初始化 Sequelize (app/models/index.js):
import dbConfig from "../config/db.config.js"; import Sequelize from "sequelize"; import Tutorial from "./tutorial.model.js"; const sequelize = new Sequelize(dbConfig.DB, dbConfig.USER, dbConfig.PASSWORD, { host: dbConfig.HOST, dialect: dbConfig.dialect, pool: dbConfig.pool, port: dbConfig.PORT, }); const db = {}; db.Sequelize = Sequelize; db.sequelize = sequelize; db.tutorials = Tutorial(sequelize, Sequelize); export default db;
  1. 定义教程模型 (app/models/tutorial.model.js):
export default (sequelize, Sequelize) => { const Tutorial = sequelize.define("tutorial", { title: { type: Sequelize.STRING, }, description: { type: Sequelize.STRING, }, published: { type: Sequelize.BOOLEAN, }, }); return Tutorial; };

如果您没有用于本地开发的 MySQL 数据库,可以使用 Docker 创建一个临时的 MySQL 容器:

docker run --rm -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=root \ -e MYSQL_DATABASE=db \ mysql:8

3.3 定义路由和控制器#

  1. 创建控制器 (app/controllers/tutorial.controller.js):
import db from "../models/index.js"; const Op = db.Sequelize.Op; const Tutorial = db.tutorials; // 创建并保存一个新教程 export const create = (req, res) => { // 验证请求 if (!req.body.title) { res.status(400).send({ message: "内容不能为空!", }); return; } // 创建一个教程 const tutorial = { title: req.body.title, description: req.body.description, published: req.body.published ? req.body.published : false, }; // 将教程保存在数据库中 Tutorial.create(tutorial) .then((data) => { res.send(data); }) .catch((err) => { res.status(500).send({ message: err.message || "创建教程时发生了一些错误。", }); }); }; // 检索所有教程 export const findAll = (req, res) => { // 允许通过查询参数进行筛选 const title = req.query.title; const condition = title ? { title: { [Op.like]: `%${title}%` } } : null; Tutorial.findAll({ where: condition }) .then((data) => { res.send(data); }) .catch((err) => { res.status(500).send({ message: err.message || "检索教程时发生了一些错误。", }); }); }; // 通过 ID 查找单个教程 export const findOne = (req, res) => { const id = req.params.id; // 通过主键查找教程 Tutorial.findByPk(id) .then((data) => { if (data) { res.send(data); } else { res.status(404).send({ message: `找不到 id=${id} 的教程。`, }); } }) .catch((err) => { res.status(500).send({ message: "检索 id=" + id + " 的教程时出错", }); }); }; // 通过 ID 更新教程 export const update = (req, res) => { const id = req.params.id; // 更新指定 ID 的教程 Tutorial.update(req.body, { where: { id: id }, }) .then((num) => { if (num == 1) { res.send({ message: "教程已成功更新。", }); } else { res.send({ message: `无法更新 id=${id} 的教程。也许教程未找到或 req.body 为空!`, }); } }) .catch((err) => { res.status(500).send({ message: "更新 id=" + id + " 的教程时出错", }); }); }; // 通过 ID 删除教程 export const deleteOne = (req, res) => { const id = req.params.id; // 删除指定 ID 的教程 Tutorial.destroy({ where: { id: id }, }) .then((num) => { if (num == 1) { res.send({ message: "教程已成功删除!", }); } else { res.send({ message: `无法删除 id=${id} 的教程。也许教程未找到!`, }); } }) .catch((err) => { res.status(500).send({ message: "无法删除 id=" + id + " 的教程", }); }); }; // 删除所有教程 export const deleteAll = (req, res) => { // 删除所有教程 Tutorial.destroy({ where: {}, truncate: false, }) .then((nums) => { res.send({ message: `${nums} 个教程已成功删除!` }); }) .catch((err) => { res.status(500).send({ message: err.message || "删除所有教程时发生了一些错误。", }); }); }; // 查找所有已发布的教程 export const findAllPublished = (req, res) => { // 查找所有 published = true 的教程 Tutorial.findAll({ where: { published: true } }) .then((data) => { res.send(data); }) .catch((err) => { res.status(500).send({ message: err.message || "检索教程时发生了一些错误。", }); }); };
  1. 设置路由 (app/routes/tutorial.routes.js):
import * as tutorials from "../controllers/tutorial.controller.js"; import express from "express"; export default (app) => { let router = express.Router(); // 创建一个新教程 router.post("/", tutorials.create); // 检索所有教程 router.get("/", tutorials.findAll); // 检索具有 id 的单个教程 router.get("/:id", tutorials.findOne); // 更新具有 id 的教程 router.put("/:id", tutorials.update); // 删除具有 id 的教程 router.delete("/:id", tutorials.deleteOne); // 删除所有教程 router.delete("/", tutorials.deleteAll); // 查找所有已发布的教程 router.get("/published", tutorials.findAllPublished); app.use("/api/tutorials", router); };
Demo Icon

Want to try passkeys yourself in a passkeys demo?

Try Passkeys

3.4 运行服务器#

  1. 创建服务器文件 (server.js):
import express from "express"; import cors from "cors"; import db from "./app/models.js"; import tutorialRoutes from "./app/routes/tutorial.routes.js"; const app = express(); const corsOptions = { origin: "http://localhost:5173", }; app.use(cors(corsOptions)); app.use(express.json()); app.use(express.urlencoded({ extended: true })); // 简单路由 app.get("/", (req, res) => { res.json({ message: "欢迎来到教程应用。" }); }); // 路由 tutorialRoutes(app); // 同步数据库 db.sequelize.sync().then(() => { console.log("Synced db."); }); const PORT = process.env.PORT || 8080; app.listen(PORT, () => { console.log(`服务器正在端口 ${PORT} 上运行。`); });
  1. 启动服务器:
node server.js

输出:

服务器正在端口 8080 上运行。 Synced db.

4. 前端实现#

这是其架构:

或者,您也可以使用 Redux:

4.1 文件结构#

您最终的文件结构将如下所示:

frontend/ ├─ index.html ├─ package.json ├─ postcss.config.js ├─ tailwind.config.js ├─ vite.config.js ├─ src/ │ ├─ App.jsx │ ├─ main.jsx │ ├─ index.css │ ├─ services/ │ │ └─ tutorial.service.js │ └─ pages/ │ ├─ AddTutorial.jsx │ ├─ Tutorial.jsx │ └─ TutorialsList.jsx
Slack Icon

Become part of our Passkeys Community for updates & support.

Join

4.2 创建 React 应用#

运行以下命令,使用 Vite 设置一个新的 React 应用:

npm create vite@latest frontend -- --template react cd frontend npm i npm i react-router-dom axios npm install -D tailwindcss autoprefixer npx tailwindcss init -p

最后,在配置文件 tailwind.config.js 中设置 tailwind 的 content 选项:

/** @type {import('tailwindcss').Config} */ export default { content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], theme: { extend: {}, }, plugins: [], };

然后,打开 src/index.css(Vite 已为您创建此文件)并添加 Tailwind 指令:

@tailwind base; @tailwind components; @tailwind utilities;

4.3 初始化应用布局#

App.jsx 组件配置了 React Router 路由并设置了一个基本的 Tailwind 导航栏。我们将在以下路由之间导航:

  • /tutorials – 教程列表
  • /add – 创建新教程的表单
  • /tutorials/:id – 编辑单个教程
import { Routes, Route, Link } from "react-router-dom"; import TutorialsList from "./pages/TutorialsList"; import AddTutorial from "./pages/AddTutorial"; import Tutorial from "./pages/Tutorial"; function App() { return ( <BrowserRouter> <div> {/* 导航栏 */} <nav className="bg-blue-600 p-4 text-white"> <div className="flex space-x-4"> <Link to="/tutorials" className="hover:text-gray-300 font-bold"> 教程 </Link> <Link to="/add" className="hover:text-gray-300"> 添加 </Link> </div> </nav> {/* 路由 */} <div className="container mx-auto mt-8 px-4"> <Routes> <Route path="/" element={<TutorialsList />} /> <Route path="/tutorials" element={<TutorialsList />} /> <Route path="/add" element={<AddTutorial />} /> <Route path="/tutorials/:id" element={<Tutorial />} /> </Routes> </div> </div> </BrowserRouter> ); } export default App;

4.4 创建数据服务#

该服务处理对我们的 Node/Express 后端(http://localhost:8080/api)的 Axios HTTP 请求。如果您的服务器在不同的地址或端口上运行,请更新 baseURL。

import axios from "axios"; const http = axios.create({ baseURL: "http://localhost:8080/api", headers: { "Content-Type": "application/json", }, }); const getAll = () => { return http.get("/tutorials"); }; const get = (id) => { return http.get(`/tutorials/${id}`); }; const create = (data) => { return http.post("/tutorials", data); }; const update = (id, data) => { return http.put(`/tutorials/${id}`, data); }; const remove = (id) => { return http.delete(`/tutorials/${id}`); }; const removeAll = () => { return http.delete("/tutorials"); }; const findByTitle = (title) => { return http.get(`/tutorials?title=${title}`); }; export default { getAll, get, create, update, remove, removeAll, findByTitle, };

4.5 添加项目组件#

一个位于 src/pages/AddTutorial.jsx 下用于创建新教程的组件。它允许输入标题和描述,然后调用 TutorialService.create()

import React, { useState } from "react"; import TutorialService from "../services/tutorial.service"; function AddTutorial() { const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [submitted, setSubmitted] = useState(false); const saveTutorial = () => { const data = { title, description }; TutorialService.create(data) .then((response) => { console.log(response.data); setSubmitted(true); }) .catch((e) => { console.log(e); }); }; const newTutorial = () => { setTitle(""); setDescription(""); setSubmitted(false); }; return ( <div className="max-w-sm mx-auto p-4 bg-white rounded shadow"> {submitted ? ( <div> <h4 className="font-bold text-green-600 mb-4">教程提交成功!</h4> <button className="bg-blue-500 text-white px-3 py-1 rounded" onClick={newTutorial} > 再添加一个 </button> </div> ) : ( <div> <h4 className="font-bold text-xl mb-2">添加教程</h4> <div className="mb-2"> <label className="block mb-1 font-medium">标题</label> <input type="text" className="border border-gray-300 rounded w-full px-2 py-1" value={title} onChange={(e) => setTitle(e.target.value)} /> </div> <div className="mb-2"> <label className="block mb-1 font-medium">描述</label> <input type="text" className="border border-gray-300 rounded w-full px-2 py-1" value={description} onChange={(e) => setDescription(e.target.value)} /> </div> <button className="bg-green-500 text-white px-3 py-1 rounded mt-2" onClick={saveTutorial} > 提交 </button> </div> )} </div> ); } export default AddTutorial;
Analyzer Icon

Are your users passkey-ready?

Test Passkey-Readiness

4.6 教程列表组件#

一个位于 src/pages/TutorialsList.jsx 下的组件,它:

  • 显示一个搜索栏,用于按教程标题筛选
  • 在左侧列出教程
  • 在右侧显示所选教程的详细信息
  • 提供一个删除所有教程的按钮
import { useState, useEffect } from "react"; import TutorialService from "../services/tutorial.service"; import { Link } from "react-router-dom"; function TutorialsList() { const [tutorials, setTutorials] = useState([]); const [currentTutorial, setCurrentTutorial] = useState(null); const [currentIndex, setCurrentIndex] = useState(-1); const [searchTitle, setSearchTitle] = useState(""); useEffect(() => { retrieveTutorials(); }, []); const onChangeSearchTitle = (e) => { setSearchTitle(e.target.value); }; const retrieveTutorials = () => { TutorialService.getAll() .then((response) => { setTutorials(response.data); console.log(response.data); }) .catch((e) => { console.log(e); }); }; const refreshList = () => { retrieveTutorials(); setCurrentTutorial(null); setCurrentIndex(-1); }; const setActiveTutorial = (tutorial, index) => { setCurrentTutorial(tutorial); setCurrentIndex(index); }; const removeAllTutorials = () => { TutorialService.removeAll() .then((response) => { console.log(response.data); refreshList(); }) .catch((e) => { console.log(e); }); }; const findByTitle = () => { TutorialService.findByTitle(searchTitle) .then((response) => { setTutorials(response.data); setCurrentTutorial(null); setCurrentIndex(-1); console.log(response.data); }) .catch((e) => { console.log(e); }); }; return ( <div className="flex flex-col lg:flex-row gap-8"> {/* 左栏:搜索 + 列表 */} <div className="flex-1"> <div className="flex mb-4"> <input type="text" className="border border-gray-300 rounded-l px-2 py-1 w-full" placeholder="按标题搜索" value={searchTitle} onChange={onChangeSearchTitle} /> <button className="bg-blue-500 text-white px-4 py-1 rounded-r" onClick={findByTitle} > 搜索 </button> </div> <h4 className="font-bold text-lg mb-2">教程列表</h4> <ul className="divide-y divide-gray-200 border border-gray-200 rounded"> {tutorials && tutorials.map((tutorial, index) => ( <li className={ "px-4 py-2 cursor-pointer " + (index === currentIndex ? "bg-blue-100" : "") } onClick={() => setActiveTutorial(tutorial, index)} key={index} > {tutorial.title} </li> ))} </ul> <button className="bg-red-500 text-white px-3 py-1 rounded mt-4" onClick={removeAllTutorials} > 全部删除 </button> </div> {/* 右栏:详情 */} <div className="flex-1"> {currentTutorial ? ( <div className="p-4 bg-white rounded shadow"> <h4 className="font-bold text-xl mb-2">教程</h4> <div className="mb-2"> <strong>标题:</strong> {currentTutorial.title} </div> <div className="mb-2"> <strong>描述:</strong> {currentTutorial.description} </div> <div className="mb-2"> <strong>状态:</strong> {currentTutorial.published ? "已发布" : "待定"} </div> <Link to={`/tutorials/${currentTutorial.id}`} className="inline-block bg-yellow-400 text-black px-3 py-1 rounded" > 编辑 </Link> </div> ) : ( <div> <p>请点击一个教程...</p> </div> )} </div> </div> ); } export default TutorialsList;
StateOfPasskeys Icon

Want to find out how many people use passkeys?

View Adoption Data

4.7 教程组件#

一个位于 src/pages/Tutorial.jsx 下的函数组件,用于查看和编辑单个教程。它使用:

  • useParams() 从 URL 中获取 :id
  • useNavigate() 进行重定向
  • TutorialService 进行获取、更新和删除操作
import { useState, useEffect } from "react"; import { useParams, useNavigate } from "react-router-dom"; import TutorialService from "../services/tutorial.service"; function Tutorial() { const { id } = useParams(); const navigate = useNavigate(); const [currentTutorial, setCurrentTutorial] = useState({ id: null, title: "", description: "", published: false, }); const [message, setMessage] = useState(""); const getTutorial = (id) => { TutorialService.get(id) .then((response) => { setCurrentTutorial(response.data); console.log(response.data); }) .catch((e) => { console.log(e); }); }; useEffect(() => { if (id) getTutorial(id); }, [id]); const handleInputChange = (event) => { const { name, value } = event.target; setCurrentTutorial({ ...currentTutorial, [name]: value }); }; const updatePublished = (status) => { const data = { ...currentTutorial, published: status, }; TutorialService.update(currentTutorial.id, data) .then((response) => { setCurrentTutorial({ ...currentTutorial, published: status }); console.log(response.data); }) .catch((e) => { console.log(e); }); }; const updateTutorial = () => { TutorialService.update(currentTutorial.id, currentTutorial) .then((response) => { console.log(response.data); setMessage("教程已成功更新!"); }) .catch((e) => { console.log(e); }); }; const deleteTutorial = () => { TutorialService.remove(currentTutorial.id) .then((response) => { console.log(response.data); navigate("/tutorials"); }) .catch((e) => { console.log(e); }); }; return ( <div> {currentTutorial ? ( <div className="max-w-sm mx-auto p-4 bg-white rounded shadow"> <h4 className="font-bold text-xl mb-2">编辑教程</h4> <div className="mb-2"> <label className="block font-medium" htmlFor="title"> 标题 </label> <input type="text" className="border border-gray-300 rounded w-full px-2 py-1" id="title" name="title" value={currentTutorial.title} onChange={handleInputChange} /> </div> <div className="mb-2"> <label className="block font-medium" htmlFor="description"> 描述 </label> <input type="text" className="border border-gray-300 rounded w-full px-2 py-1" id="description" name="description" value={currentTutorial.description} onChange={handleInputChange} /> </div> <div className="mb-2"> <strong>状态:</strong>{" "} {currentTutorial.published ? "已发布" : "待定"} </div> <div className="space-x-2 mt-2"> {currentTutorial.published ? ( <button className="bg-blue-500 text-white px-3 py-1 rounded" onClick={() => updatePublished(false)} > 取消发布 </button> ) : ( <button className="bg-blue-500 text-white px-3 py-1 rounded" onClick={() => updatePublished(true)} > 发布 </button> )} <button className="bg-red-500 text-white px-3 py-1 rounded" onClick={deleteTutorial} > 删除 </button> <button className="bg-green-500 text-white px-3 py-1 rounded" onClick={updateTutorial} > 更新 </button> </div> {message && <p className="text-green-600 mt-2">{message}</p>} </div> ) : ( <div> <p>正在加载教程...</p> </div> )} </div> ); } export default Tutorial;
Debugger Icon

Want to experiment with passkey flows? Try our Passkeys Debugger.

Try for Free

4.8 运行应用程序#

npm run dev

现在您可以在 http://localhost:5173 访问该应用程序。在浏览器中打开该 URL。您现在可以导航到:

  • /tutorials – 查看所有教程
  • /add – 添加教程
  • /tutorials/:id – 编辑特定教程

(请确保您的 Node/Express 后端正在 http://localhost:8080 上运行,或相应地更新 tutorial.service.js 中的 baseURL。)

5. 总结#

您已成功使用 React、Node.js、Express 和 MySQL 构建了一个全栈 CRUD 应用程序。该项目展示了如何使用 Node.js 和 Express 设置 RESTful API,如何使用 Sequelize ORM 管理数据,以及如何使用 React 和 TailwindCSS 创建响应式前端。

编码愉快,我们下个教程再见!

Add passkeys to your app in <1 hour with our UI components, SDKs & guides.

Start for free

Share this article


LinkedInTwitterFacebook

Enjoyed this read?

🤝 Join our Passkeys Community

Share passkeys implementation tips and get support to free the world from passwords.

🚀 Subscribe to Substack

Get the latest news, strategies, and insights about passkeys sent straight to your inbox.

Related Articles