尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
手把手拆解 CRUD:Node.js + Express + MySQL 用户管理全流程
搞开发这些年我越来越觉得 CRUD增删改查才是大多数业务系统的命根子。登录注册本质上是查和增订单流转本质上是增、改、查哪怕你后面接了一堆推荐算法、消息队列落到用户脸上还是那一句“保存成功”。今天这篇不是讲高大上的架构而是把一个数据库里最核心的 CRUD 操作从头到尾拆给你看前端一个页面、几个按钮后端四个接口数据库一张表把“增删改查”彻底跑通。源码我整理过好几版现在这版是最适合拿来当模板的——它不依赖复杂框架逻辑一眼能看穿适合刚接触前后端联调的人也适合想快速搭一个后台管理雏形的朋友。这里包含的代码片段里有div和button idjs_add_btn添加/button这样的结构一看就是网页端的数据管理页面。把它跟后端接口、SQL 语句放在一起看就是一个最小的用户信息管理系统。下面我把每个环节的为什么、怎么做、踩过的坑一次说清楚。1. 先把CRUD这件事想清楚1.1 为什么几乎所有系统都在做CRUD先别觉得 CRUD 简单它其实是整个软件行业的“基础设施”。你可以把数据库想象成一个带隔间的仓库每个隔间里放一条记录CRUD 就是对仓库的四种基础操作往隔间里放东西Create、打开隔间看看有什么Read、把里面的东西换掉Update、把整个隔间清空Delete。任何复杂的业务拆到最底层逃不过这四件事。拿这个源码里的用户管理来说页面上有个输入框填入姓名、年龄点“添加”按钮数据从浏览器跑到后端后端把它写进 MySQL 表页面上显示一个列表点击“删除”按钮后端根据 ID 把那条记录删掉。这四个动作覆盖了一个数据对象从生到死的完整生命周期。为什么我现在强烈建议新手用最朴素的代码把 CRUD 过一遍因为很多人一上来就接触 ORM 框架、依赖注入、事务注解结果连自己发的 SQL 是什么都不知道。等系统出问题时看不懂日志也调不动 SQL。先把原生 SQL 和 HTTP 接口的对应关系吃透再看任何框架都是事半功倍。1.2 这套源码的整体结构拆解这套代码分成三层浏览器里的 HTMLJavaScript 负责展示和交互中间一层用 Node.js Express 提供 HTTP 接口最底下是 MySQL 数据库。前端通过 fetch 发请求后端把请求转化成 SQL 语句操作数据库再把结果返回给前端。数据流向是单向的用户点击按钮 - JS 组装请求 - 后端路由接收 - SQL 执行 - 结果层层返回 - 页面刷新有人会问这么简单的东西为什么不用 ORM 一把梭因为 ORM 是工具不是目的CRUD 这种最小闭环用原生 SQL 最能看清数据流等理解了再引入 ORM 才有意义。我特意选了 Express 而不是其他框架理由很实际它路由写法直白中间件机制容易理解而且生态成熟。数据库选 MySQL 也是因为它是目前中小型项目里最常见的存储方案跟 Node.js 配合的例子一抓一大把。如果你平时用 PHP 或者 Java 做后端接口层的写法换成对应语言就行数据库操作和页面交互的思路完全一样。如果你本地装不了 MySQL把连接配置换成 SQLite 或者 PostgreSQL 也基本是改几行的事但这里先按 MySQL 讲。技术栈和操作之间的对应关系可以看下面这个表后面代码全按这个思路写操作前端按钮/动作HTTP 方法接口路径SQL 动作新增添加按钮js_add_btnPOST/api/usersINSERT INTO users查询页面加载/刷新按钮GET/api/usersSELECT * FROM users更新修改按钮PUT/api/users/:idUPDATE users SET ...删除删除按钮DELETE/api/users/:idDELETE FROM users这套结构最大的好处是每一层只负责一件事出了问题顺着链路查就行。前端报错先看请求有没有发出去后端报错先看路由有没有收到数据库报错先看 SQL 能不能直接在客户端执行。不用瞎猜。2. 前端页面与交互设计2.1 页面布局与核心按钮设计先看前端页面的骨架。代码里那个div是容器button idjs_add_btn添加/button是触发新增操作的核心入口。我保留js_前缀其实是有意的它标记这个按钮是给 JavaScript 用的不是用来做样式或者承载文案的。这样在代码里搜索js_就能定位所有交互节点大型项目里这个习惯尤其好使。完整页面一般长这样顶部是表单区域放姓名、年龄输入框和三个按钮下面是列表区域用表格展示数据库里的记录每一行末尾有“编辑”和“删除”两个操作按钮。为了让页面加载时数据自动出现我会在window.onload里主动调一次查询接口这样用户一打开页面就看到当前数据库里的全部内容。有一个细节大家容易忽略输入框一定要做基础校验。比如姓名为空时按钮点击不能发请求年龄不是数字时要提示。很多人觉得校验是后端的事前端随便弄弄就行结果用户随便点一下后端抛一个 500体验非常差。前端校验的作用不是替代后端安全而是减少无效请求、提升交互反馈速度。2.2 用 fetch 把四个操作串起来前端的核心逻辑就是用 fetch 调用后端接口。以添加为例按钮绑定点击事件后JS 从输入框取值组装成一个对象用fetch(/api/users, { method: POST, body: JSON.stringify(data) })发出去。注意这里必须设置Content-Type: application/json否则后端收到的 body 是字符串JSON.parse 直接报错。查询接口一般是 GET 请求不需要 body。JS 里写一个loadList()函数请求/api/users拿到返回的数组后用模版字符串把每条记录拼成表格行再插入到tbody中。这里有一个很常见的坑拼接 HTML 时会用到用户输入的内容如果用户输入了script标签直接插进去就会形成 XSS 漏洞。所以我在渲染列表时会对姓名做了转义处理把和替换成实体字符。更新操作我习惯让每行记录的操作区同时放“编辑”和“删除”。点“删除”直接发 DELETE 请求点“编辑”则把这一行的数据回填到表单里按钮文案从“添加”变成“保存修改”再点一下发 PUT 请求。这里要注意区分当前是新增状态还是编辑状态我通常用一个currentEditId变量来记住正在编辑的 ID为null时表示新增。删除时最好加一个confirm(确定删除这条记录吗)弹窗。这种冗余提示在后台系统里很重要因为删除操作不可逆用户误点一下就是事故。弹窗虽然老套但它的确认成本很低能挡掉大多数手滑操作。3. 后端接口与数据库操作详解3.1 建表与连接数据库后端的起点是一张表。用户管理最简单建表语句就按照用户信息管理系统的需求来CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL, age INT DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );id自增主键是标准做法每条记录的唯一标识。name不能为空age给默认值 0created_at记录创建时间。实际业务里可能还要加updated_at更新时间和各种业务字段但核心的 CRUD 演示有这四个字段完全够用。Node.js 连接 MySQL 我用的是mysql2这个库因为它支持 Promise配合 async/await 写异步代码非常顺手。连接池配置大概是这样的const mysql require(mysql2/promise); const pool mysql.createPool({ host: localhost, user: root, password: 123456, database: crud_demo, waitForConnections: true, connectionLimit: 10, charset: utf8mb4 });connectionLimit是连接池里最多同时存在的连接数。为什么不用每次请求都新建连接因为建立数据库连接本身有开销频繁握手会让接口响应变慢。连接池把连接复用起来相当于提前准备好几条“通道”谁要用谁取用完放回。一般个人项目设置 10 就够并发再高可以往上调但别盲开几百个数据库会先撑不住。这里特别提醒一句密码和连接信息是敏感配置实际项目要放到环境变量里不要硬编码提交到仓库。我这里为了演示直观写出来了你抄的时候记得改成自己的配置并且用.env管理。3.2 新增与查询接口的实现新增接口接收前端 POST 过来的 JSON取出name和age执行INSERT INTO users (name, age) VALUES (?, ?)。注意我用的是问号占位符这叫参数化查询能从根本上防止 SQL 注入。app.post(/api/users, async (req, res) { const { name, age } req.body; if (!name) { return res.status(400).json({ message: 姓名不能为空 }); } const [result] await pool.execute( INSERT INTO users (name, age) VALUES (?, ?), [name, age] ); res.status(201).json({ message: 添加成功, id: result.insertId }); });很多人图省事会直接拼字符串INSERT INTO users (name, age) VALUES (${name}, ${age})。如果你输入的姓名是张三; DROP TABLE users;--拼接后的 SQL 就把表删了。参数化查询是把数据当作参数传进去数据库引擎会帮你处理转义用户输入再奇葩也只是字符串里的内容不再具备改变 SQL 结构的能力。这条底线一定要守住。查询接口分两种查全量和查单条。列表页用查全量编辑时用查单条。app.get(/api/users, async (req, res) { const [rows] await pool.query(SELECT id, name, age, created_at FROM users ORDER BY id DESC); res.json(rows); }); app.get(/api/users/:id, async (req, res) { const id Number(req.params.id); if (Number.isNaN(id)) { return res.status(400).json({ message: 非法 ID }); } const [rows] await pool.query(SELECT id, name, age FROM users WHERE id ?, [id]); if (rows.length 0) { return res.status(404).json({ message: 记录不存在 }); } res.json(rows[0]); });为什么列表要ORDER BY id DESC因为新添加的记录通常用户马上想看按倒序排能让最新数据出现在最上面省得滚到底部去找。查单条时我用Number(req.params.id)做了类型转换因为 URL 参数进来是字符串直接拿去和数据库里的数字比较容易出幺蛾子。3.3 更新与删除接口的实现更新接口用 PUT语义上表示整体替换。前端在编辑状态下会把表单里的姓名、年龄全部提交过来后端执行UPDATE users SET name ?, age ? WHERE id ?。app.put(/api/users/:id, async (req, res) { const id Number(req.params.id); const { name, age } req.body; if (Number.isNaN(id)) { return res.status(400).json({ message: 非法 ID }); } if (!name) { return res.status(400).json({ message: 姓名不能为空 }); } const [result] await pool.execute( UPDATE users SET name ?, age ? WHERE id ?, [name, age, id] ); if (result.affectedRows 0) { return res.status(404).json({ message: 记录不存在 }); } res.json({ message: 更新成功 }); });affectedRows是 MySQL 执行 UPDATE 后受影响的行数0 说明 WHERE id 没匹配到任何记录这时候返回 404 比返回成功更诚实。还有一个细节如果更新的内容和原来一模一样有些数据库引擎返回的 affectedRows 也是 0这时前端可能误报“记录不存在”。要规避这个误判可以在更新前先查一次记录是否存在但这会多一次查询。我个人是接受这个误判的因为实际业务中几乎不会有完全相同的更新如果你要严谨就先用 SELECT 判断。删除接口更简单但也要面对“记录不存在”的情况app.delete(/api/users/:id, async (req, res) { const id Number(req.params.id); if (Number.isNaN(id)) { return res.status(400).json({ message: 非法 ID }); } const [result] await pool.execute(DELETE FROM users WHERE id ?, [id]); if (result.affectedRows 0) { return res.status(404).json({ message: 记录不存在 }); } res.json({ message: 删除成功 }); });注意 DELETE 和 POST 的请求体处理方式不一样。DELETE 一般不需要 body资源定位全靠 URL 里的 id这也符合 RESTful 接口的设计思想用 HTTP 方法表达动作用 URL 表达操作对象。4. 完整源码演示与参数说明4.1 前端完整代码下面把整份前端页面贴出来。为了聚焦 CRUD 逻辑样式我写了极简版你拿到后随便套个 CSS 框架就能用。!DOCTYPE html html langzh-CN head meta charsetUTF-8 title用户管理 - CRUD 示例/title style body { font-family: sans-serif; max-width: 700px; margin: 40px auto; } .form-row { margin-bottom: 12px; } .form-row input { padding: 6px; width: 160px; } .table-wrap { margin-top: 20px; } table { width: 100%; border-collapse: collapse; } th, td { border: 1px solid #ddd; padding: 8px; text-align: left; } button { padding: 6px 14px; margin: 4px; cursor: pointer; } /style /head body h1用户管理/h1 div classform-row input typetext idjs_name placeholder姓名 input typetext idjs_age placeholder年龄 button idjs_add_btn添加/button button idjs_reset_btn重置/button /div div classtable-wrap table thead tr thID/th th姓名/th th年龄/th th操作/th /tr /thead tbody idjs_tbody/tbody /table /div script let currentEditId null; function escapeHtml(text) { const map { : lt;, : gt;, : amp;, : quot;, : #039; }; return String(text).replace(/[]/g, (ch) map[ch]); } async function loadList() { const res await fetch(/api/users); const rows await res.json(); const tbody document.getElementById(js_tbody); tbody.innerHTML rows.map((item) tr td${item.id}/td td${escapeHtml(item.name)}/td td${item.age}/td td button classjs_edit_btn>const express require(express); const mysql require(mysql2/promise); const app express(); app.use(express.json()); const pool mysql.createPool({ host: process.env.DB_HOST || localhost, user: process.env.DB_USER || root, password: process.env.DB_PASS || 123456, database: process.env.DB_NAME || crud_demo, waitForConnections: true, connectionLimit: 10, charset: utf8mb4 }); app.post(/api/users, async (req, res) { const { name, age } req.body; if (!name) return res.status(400).json({ message: 姓名不能为空 }); const ageNum age undefined || age ? 0 : Number(age); if (Number.isNaN(ageNum) || ageNum 0) { return res.status(400).json({ message: 年龄必须是合法数字 }); } const [result] await pool.execute( INSERT INTO users (name, age) VALUES (?, ?), [name, ageNum] ); res.status(201).json({ message: 添加成功, id: result.insertId }); }); app.get(/api/users, async (req, res) { const [rows] await pool.query(SELECT id, name, age, created_at FROM users ORDER BY id DESC); res.json(rows); }); app.get(/api/users/:id, async (req, res) { const id Number(req.params.id); if (Number.isNaN(id)) return res.status(400).json({ message: 非法 ID }); const [rows] await pool.query(SELECT id, name, age FROM users WHERE id ?, [id]); if (rows.length 0) return res.status(404).json({ message: 记录不存在 }); res.json(rows[0]); }); app.put(/api/users/:id, async (req, res) { const id Number(req.params.id); const { name, age } req.body; if (Number.isNaN(id)) return res.status(400).json({ message: 非法 ID }); if (!name) return res.status(400).json({ message: 姓名不能为空 }); const ageNum age undefined || age ? 0 : Number(age); if (Number.isNaN(ageNum) || ageNum 0) { return res.status(400).json({ message: 年龄必须是合法数字 }); } const [result] await pool.execute( UPDATE users SET name ?, age ? WHERE id ?, [name, ageNum, id] ); if (result.affectedRows 0) return res.status(404).json({ message: 记录不存在 }); res.json({ message: 更新成功 }); }); app.delete(/api/users/:id, async (req, res) { const id Number(req.params.id); if (Number.isNaN(id)) return res.status(400).json({ message: 非法 ID }); const [result] await pool.execute(DELETE FROM users WHERE id ?, [id]); if (result.affectedRows 0) return res.status(404).json({ message: 记录不存在 }); res.json({ message: 删除成功 }); }); app.listen(3000, () { console.log(CRUD 服务已启动: http://localhost:3000); });启动前先npm init -y再装两个依赖npm install express mysql2然后node server.js。数据库这边要提前建好表我习惯写一个init.sql保存建表语句这样换环境时直接执行一份 SQL 就能还原数据结构。表结构变更也应该写成新的 SQL 脚本不要手工在数据库里乱改否则团队成员之间很难同步。这里的端口用了 3000前端页面直接打开index.html会面临跨域问题。因为file://协议下 fetch/api/users找不到地址。最简单的办法是也用 Express 托管静态文件在server.js里加一行app.use(express.static(public))把index.html放到public目录下然后访问http://localhost:3000/index.html。这样前后端同源绕开 CORS 的配置。4.3 联调主流程演示假设现在数据库里是空的打开页面后整个操作链路是这样走的在姓名框输入“张三”年龄框输入 25点“添加”。浏览器控制台能看到 POST /api/users 请求后端返回 201表里多了一条 id1 的记录页面列表自动刷新显示出来。再添加一条“李四”列表按 id 倒序李四显示在上面。此时连续点两次添加第一条数据不会重复插入因为每次点击都是独立的新 POST 请求没有任何缓存逻辑。点第一行的“编辑”按钮表单自动填入李四的信息按钮文案变成“保存修改”。把姓名改成“李四四”点“保存修改”浏览器发 PUT /api/users/2 请求列表刷新后名字变了。点第二行的“删除”按钮弹窗确认后发 DELETE /api/users/1 请求张三那条记录消失。我建议你自己手打一整个流程然后在浏览器开发者工具里逐个看 Network 标签页里的请求方法、路径、请求体和响应体。这一步能帮你把 HTTP 和数据库操作彻底串起来。很多人写后端接口时文档背得滚瓜烂熟真到联调却分不清 PUT 和 POST 的区别原因就是没有亲眼看过一次请求是怎么被路由到 SQL 的。5. 实战中的坑与排查技巧5.1 前端必踩的坑第一个坑是按钮默认行为。HTML 里button如果没有指定type默认是typesubmit。如果你把按钮放在form里面点击时浏览器会尝试提交表单、刷新页面你绑定的 click 事件执行到一半页面就跳转走了。解决办法要么给按钮加typebutton要么干脆不用form包裹像上面示例代码那样用div做容器。这也是为什么我第一版踩坑后直接放弃表单元素少了很多麻烦。第二个坑是 fetch 请求格式。POST 请求忘记设置Content-Type: application/json后端req.body就是 undefined接口直接 500。排查这种问题最快的方法是打开 Network 面板看请求头确认Content-Type是不是application/json。请求体也检查一下必须是合法的 JSON 字符串不能是对象。第三个坑是数字 ID 的类型。>
RELATED

相关推荐

边缘时序数据库sfsDb:弱网环境下的本地存储与同步方案

边缘时序数据库sfsDb:弱网环境下的本地存储与同步方案

在做某制造车间的设备数据接入时,我遇到了一个非常头疼的问题:现场上了快三百个传感器,采集PLC电流、振动脉冲、能耗和温湿度,数据在本地网关上都好好的,一旦要求“先全部回传到中心再分析”,整个链路就变得…

📅 2026/10/23 5:21:45
企业AI争议管理实战:从合同审查到诉讼风险预警

企业AI争议管理实战:从合同审查到诉讼风险预警

这两年和不少做企业法务、合规、风控的朋友交流,大家有一个高度一致的感受:争议管理已经不是"出了事再找律师"的被动阶段了,而是要在合同签订、履约监控、逾期处理的每个环节提前下手。我所在的某科技公司,过去一年经手…

📅 2026/10/23 5:21:45
广州AI培训机构筛选指南:课程大纲与实战项目是关键

广州AI培训机构筛选指南:课程大纲与实战项目是关键

前阵子有个朋友问我:广州学AI技术哪家机构好?他说自己刷了一周短视频,看了十几家机构的广告,有说包就业的,有说名师一对一辅导的,还有说“零基础三个月转行AI工程师”的,越看越懵。这个问题我这…

📅 2026/10/23 5:21:45
MORE NEWS

更多资讯

📰

从一个 HelloWorld 到 49 个示例模块:LangChain4j 在 Java LLM 应用开发中的完整实践路径

从一个 HelloWorld 到 49 个示例模块:LangChain4j 在 Java LLM 应用开发中的完整实践路径 【免费下载链接】langchain4j-examples 项目地址: https://gitcode.com/GitHub_Trending/la/langchain4j-examples langchain4j-examples 是 LangChain4j 框架的官方…

📰

TradeX-TPS交易插件:轻量级协议翻译器与跨语言调用实践

简介:TradeX-TPS交易插件系统v1.0是一款面向量化交易开发者与券商系统集成人员的轻量级股票自动化交易工具,专为适配大智慧、通达信等主流行情软件设计,解决第三方程序对接券商交易通道难、接口兼容性差、功能扩展受限等实际问题。资源包共20…

📰

洛谷-P3650 [USACO1.3] 滑雪课程设计 Ski Course Design 题解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

用Codex和Chromatic复刻街机游戏:AI辅助开发实践记录

用 Codex 在 Chromatic 上复刻街机游戏:AI 辅助开发的实践记录最近在做一个复古游戏复刻的项目,核心思路是用 Codex 生成代码,跑在 Chromatic 引擎上,目标是还原经典街机游戏的玩法和手感。这个组合很有意思:Codex 负责…

📰

洛谷-B3930 [GESP202312 五级] 烹饪问题 题解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

【opencv】Opencv python: seamlessClone泊松融合 (我把Lena变成了彼岸花怪/(ㄒoㄒ)/~~)

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬