搞开发这些年,我越来越觉得 CRUD(增删改查)才是大多数业务系统的命根子。登录注册本质上是查和增,订单流转本质上是增、改、查,哪怕你后面接了一堆推荐算法、消息队列,落到用户脸上还是那一句“保存成功”。今天这篇不是讲高大上的架构,而是把一个数据库里最核心的 CRUD 操作从头到尾拆给你看:前端一个页面、几个按钮,后端四个接口,数据库一张表,把“增删改查”彻底跑通。源码我整理过好几版,现在这版是最适合拿来当模板的——它不依赖复杂框架,逻辑一眼能看穿,适合刚接触前后端联调的人,也适合想快速搭一个后台管理雏形的朋友。
这里包含的代码片段里有<div>和<button id="js_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 这套源码的整体结构拆解
这套代码分成三层:浏览器里的 HTML+JavaScript 负责展示和交互,中间一层用 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_btn) | POST | /api/users | INSERT INTO users |
| 查询 | 页面加载/刷新按钮 | GET | /api/users | SELECT * FROM users |
| 更新 | 修改按钮 | PUT | /api/users/:id | UPDATE users SET ... |
| 删除 | 删除按钮 | DELETE | /api/users/:id | DELETE FROM users |
这套结构最大的好处是:每一层只负责一件事,出了问题顺着链路查就行。前端报错先看请求有没有发出去,后端报错先看路由有没有收到,数据库报错先看 SQL 能不能直接在客户端执行。不用瞎猜。
2. 前端页面与交互设计
2.1 页面布局与核心按钮设计
先看前端页面的骨架。代码里那个<div>是容器,<button id="js_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给默认值 0,created_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 lang="zh-CN"> <head> <meta charset="UTF-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 class="form-row"> <input type="text" id="js_name" placeholder="姓名"> <input type="text" id="js_age" placeholder="年龄"> <button id="js_add_btn">添加</button> <button id="js_reset_btn">重置</button> </div> <div class="table-wrap"> <table> <thead> <tr> <th>ID</th> <th>姓名</th> <th>年龄</th> <th>操作</th> </tr> </thead> <tbody id="js_tbody"></tbody> </table> </div> <script> let currentEditId = null; function escapeHtml(text) { const map = { '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }; 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 class="js_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,表里多了一条 id=1 的记录,页面列表自动刷新显示出来。
- 再添加一条“李四”,列表按 id 倒序,李四显示在上面。此时连续点两次添加,第一条数据不会重复插入,因为每次点击都是独立的新 POST 请求,没有任何缓存逻辑。
- 点第一行的“编辑”按钮,表单自动填入李四的信息,按钮文案变成“保存修改”。把姓名改成“李四四”,点“保存修改”,浏览器发 PUT /api/users/2 请求,列表刷新后名字变了。
- 点第二行的“删除”按钮,弹窗确认后发 DELETE /api/users/1 请求,张三那条记录消失。
我建议你自己手打一整个流程,然后在浏览器开发者工具里逐个看 Network 标签页里的请求方法、路径、请求体和响应体。这一步能帮你把 HTTP 和数据库操作彻底串起来。很多人写后端接口时文档背得滚瓜烂熟,真到联调却分不清 PUT 和 POST 的区别,原因就是没有亲眼看过一次请求是怎么被路由到 SQL 的。
5. 实战中的坑与排查技巧
5.1 前端必踩的坑
第一个坑是按钮默认行为。HTML 里<button>如果没有指定type,默认是type="submit"。如果你把按钮放在<form>里面,点击时浏览器会尝试提交表单、刷新页面,你绑定的 click 事件执行到一半页面就跳转走了。解决办法:要么给按钮加type="button",要么干脆不用<form>包裹,像上面示例代码那样用div做容器。这也是为什么我第一版踩坑后直接放弃表单元素,少了很多麻烦。
第二个坑是 fetch 请求格式。POST 请求忘记设置Content-Type: application/json,后端req.body就是 undefined,接口直接 500。排查这种问题最快的方法是打开 Network 面板看请求头,确认Content-Type是不是application/json。请求体也检查一下,必须是合法的 JSON 字符串,不能是对象。
第三个坑是数字 ID 的类型。>