ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从按钮到数据库:原生PHP+MySQL实现CRUD全链路源码解析

从按钮到数据库:原生PHP+MySQL实现CRUD全链路源码解析 做后端开发这些年我越来越确信一件事不管业务吹得多天花乱坠落到数据库层面核心动作永远是增删改查这四板斧。今天要分享的这套源码就是把这四个操作完整串起来的一套可运行示例——从页面上的一个“添加”按钮就是标题里那个js_add_btn开始走完前端触发、接口转发、SQL执行、数据落库再到页面回显的整个闭环。适合刚接触前后端联动不久、想弄明白“按钮按下去之后数据到底是怎么进库的”这类问题的朋友也适合需要快速搭一个管理后台原型、但不想一上来就上重型框架的人。1. 先搞清楚这套源码的边界它解决什么问题不解决什么问题1.1 从js_add_btn这个按钮说起标题里那个div和button idjs_add_btn其实就是整套代码的起点。我见过很多初学者把CRUD想得很玄乎觉得要学什么框架、什么中间件其实一个最简单的用户管理页面核心就是几个按钮加一张表添加按钮js_add_btn弹窗填数据提交后INSERT一条记录修改按钮js_edit_btn选中一行回填表单提交后UPDATE对应记录删除按钮js_del_btn选中一行或批量选中DELETE记录查询按钮js_query_btn按条件过滤SELECT并渲染列表所以这套源码的定位很明确它不是某个业务系统的完整实现而是一个“最小可用的CRUD范式”。你把它跑通了往里面套任何业务表都行——商品、订单、文章、分类换换字段名就能用。1.2 技术选型的思路为什么用原生PHPMySQL而不上框架这个选择我在做的时候专门纠结过。当时我手头有几个选择Java的MyBatis-Plus、Python的Flask/Django、Node的Express还有PHP原生。最后选原生PHP MySQL理由其实很朴素依赖最少一个能跑PHP的服务器环境就够不需要装Maven、Gradle、Composer一堆工具链每一行代码都可解释用框架的话SQL被封装进Mapper或ORM里初学者反而不清楚“这条INSERT到底是怎么发出去的”。原生写法让SQL直接摆在面前部署成本低放到任何支持PHPMySQL的虚拟主机就能跑这在很多“源码”场景里是最实际的当然如果你的项目要上生产、要面对高并发那直接用框架是更明智的选择。但这套源码的价值在于“看穿本质”框架再花哨底层干的也是这几件事。2. 前端页面细化div布局、按钮分组与事件绑定的正确姿势2.1 HTML结构为什么是div包裹按钮组先看这套源码里前端最基础的那段HTMLdiv classtoolbar button idjs_add_btn classbtn btn-primary添加/button button idjs_edit_btn classbtn btn-warning修改/button button idjs_del_btn classbtn btn-danger删除/button button idjs_query_btn classbtn btn-info查询/button /div div classtable-wrap table idjs_table thead tr thinput typecheckbox idjs_check_all/th thID/th th用户名/th th昵称/th th状态/th th创建时间/th /tr /thead tbody idjs_tbody/tbody /table /div用一个div.toolbar把按钮组包起来是我一直坚持的习惯。这样当按钮越来越多比如加一个“导出Excel”、加一个“批量启用”时布局不会乱CSS只需要控制这一个容器。按钮上只用js_前缀的ID是为了和样式类名做区隔——js_开头的是脚本钩子btn开头的是样式各管各的避免以后改样式把JS绑定的元素搞挂。有个细节值得提为什么按钮组放在表格外面而不是每一行都放“编辑/删除”按钮因为当数据量上去之后每一行都渲染操作按钮不仅DOM节点太多而且事件绑定数量会暴涨。这套源码采用“先选中、再操作”的交互模式整张表只需要四个按钮的事件监听性能和维护成本都友好得多。2.2 事件绑定不用onclick用addEventListener我看过不少源码喜欢在HTML里直接写onclickaddUser()这样写能跑但耦合太紧。这套源码用的是标准的事件监听const addBtn document.getElementById(js_add_btn); const editBtn document.getElementById(js_edit_btn); const delBtn document.getElementById(js_del_btn); const queryBtn document.getElementById(js_query_btn); addBtn.addEventListener(click, () openModal(add)); editBtn.addEventListener(click, () openModal(edit)); delBtn.addEventListener(click, handleBatchDelete); queryBtn.addEventListener(click, () loadList(1));这里有个实际好处同一个按钮可以挂多个监听函数想再加一个埋点统计直接addEventListener追加就行不用去改HTML标签。另一个好处是以后如果要做按钮权限控制只需要在绑定前判断一下用户权限不满足就直接return不需要从HTML里逐个删onclick。2.3 列表渲染与编辑回填数据驱动DOM更新列表数据从后端获取后直接用JavaScript拼接模板字符串渲染到tbodyasync function loadList(page 1) { const resp await fetch(api/user_list.php?page${page}); const result await resp.json(); if (!result.success) { alert(result.msg); return; } const list result.data.list; let html ; list.forEach(item { html tr>CREATE TABLE user ( id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, username VARCHAR(50) NOT NULL COMMENT 登录名, nickname VARCHAR(50) NOT NULL DEFAULT COMMENT 昵称, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1启用 0禁用, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;几个字段设计的经验id用INT UNSIGNED AUTO_INCREMENT这是最经典的主键方案自增ID保证了插入时不需要业务方手动生成username加唯一索引避免重复账号后续查询“按用户名搜索”也能走索引status用TINYINT而不是VARCHAR因为状态本来就是有限的离散值数字比较比字符串快也方便扩展成更多状态时间字段用DATETIME并且default CURRENT_TIMESTAMP让数据库自己管创建时间前端不用传表名user是MySQL的保留字吗不是但order、group是所以我的习惯是所有表名、字段名一律用反引号包起来从源头上避免和保留字冲突关于字符集我用的是utf8mb4而不是utf8。这俩区别很大utf8在MySQL里最多只能存3字节而utf8mb4是完整的4字节字符集。如果哪个用户的昵称里放了一个Emojiutf8直接存不进去报错换成utf8mb4就没事。现在做新库默认utf8mb4就对了。3.2 连接层PDO连接与统一配置这套源码用一个独立的db.php做数据库连接所有接口文件都引入它?php // db.php $host 127.0.0.1; $port 3306; $dbname test; $user root; $pass your_password; $dsn mysql:host$host;port$port;dbname$dbname;charsetutf8mb4; try { $pdo new PDO($dsn, $user, $pass, [ PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES false, ]); } catch (PDOException $e) { // 生产环境这里应该记录日志而不是直接输出版本信息 exit(json_encode([success false, msg 数据库连接失败])); }这里三个配置项分别解决三个问题ERRMODE_EXCEPTION让PDO出错时抛异常。如果不设置PDO默认是静默模式SQL写错了你不知道只能看到“操作失败”的假象FETCH_ASSOC查询结果返回关联数组比默认的索引关联混合数组清晰EMULATE_PREPARES false关闭PDO的预处理模拟使用MySQL原生预处理。这一点区分了“真预处理”和“假预处理”PHP 5.3以前的版本模拟预处理是在客户端做一次转义再拼SQL安全性不如交给MySQL服务端?php // db.php $host 127.0.0.1; $port 3306; $dbname test; $user root; $pass your_password; $dsn mysql:host$host;port$port;dbname$dbname;charsetutf8mb4; try { $pdo new PDO($dsn, $user, $pass, [ PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES false, ]); } catch (PDOException $e) { // 生产环境这里应该记录日志而不是直接输出版本信息 exit(json_encode([success false, msg 数据库连接失败])); }### 3.3 连接层PDO的四个关键配置项 数据库连接我单独抽了一个 db.php所有接口文件第一行都是 require db.php;。这么做的好处是以后换库换密码只改一个文件。 这里要重点说PDO的四个配置每一个都是踩过坑才加上的 php $pdo new PDO($dsn, $user, $pass, [ PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES false, PDO::ATTR_PERSISTENT false, ]);ERRMODE_EXCEPTIONSQL执行出错时抛异常。默认的静默模式会让你在“数据没写进去”时毫无头绪FETCH_ASSOC结果集只返回字段名做键的数组避免下标和关联键混在一起EMULATE_PREPARES false关掉模拟预处理用MySQL原生预处理。这一点和防SQL注入直接相关后面细说PERSISTENT false默认不搞长连接。长连接在PHP这种“请求结束就释放”的模型里反而容易把连接状态搞脏非必要不用4. 后端四个接口的实现拆解添加、查询、修改、删除4.1 添加操作预处理INSERT与自增ID回传添加对应的接口是api/user_add.php。核心代码就这几行?php require db.php; $input json_decode(file_get_contents(php://input), true); if (!$input) { exit(json_encode([success false, msg 参数格式错误])); } $username trim($input[username] ?? ); $nickname trim($input[nickname] ?? ); $status isset($input[status]) ? (int)$input[status] : 1; if ($username ) { exit(json_encode([success false, msg 用户名不能为空])); } try { $stmt $pdo-prepare(INSERT INTO user (username, nickname, status) VALUES (?, ?, ?)); $stmt-execute([$username, $nickname, $status]); $newId $pdo-lastInsertId(); echo json_encode([success true, msg 添加成功, id $newId]); } catch (PDOException $e) { // 唯一索引冲突等错误 echo json_encode([success false, msg 添加失败 . $e-getMessage()]); }几个值得注意的细节前端我用的是fetch发送POSTContent-Type: application/json所以后端用php://input读取原始请求体而不是$_POST。如果你前端用的是FormData那后端就得读$_POST。这两个别搞混否则会出现“明明传了数据后端却收不到”的诡异问题prepare execute是防SQL注入的关键。用占位符?绑定参数MySQL服务端会把参数当数据而不是SQL去解析。有人觉得“用户名字符串里加个; DROP TABLE...就能删库”在预处理面前完全无效插入成功后回传lastInsertId()前端可以用这个ID立即刷新列表并高亮新行体验好很多4.2 查询操作分页、搜索与返回值结构列表查询是最常被写砸的一个接口。很多人一上来就SELECT * FROM user然后把所有数据一次性返回数据量一大页面就卡。这套源码里做了分页和简单的关键字搜索?php require db.php; $page max(1, (int)($_GET[page] ?? 1)); $size 10; $offset ($page - 1) * $size; $keyword trim($_GET[keyword] ?? ); $where ; $params []; if ($keyword ! ) { $where WHERE username LIKE ? OR nickname LIKE ?; $like % . $keyword . %; $params[] $like; $params[] $like; } // 查询总数用于前端渲染分页 $countSql SELECT COUNT(*) FROM user $where; $countStmt $pdo-prepare($countSql); $countStmt-execute($params); $total (int)$countStmt-fetchColumn(); // 查询当前页数据 $sql SELECT id, username, nickname, status, created_at FROM user $where ORDER BY id DESC LIMIT $offset, $size; $stmt $pdo-prepare($sql); $stmt-execute($params); $list $stmt-fetchAll(); echo json_encode([ success true, data [ list $list, total $total, page $page, size $size, ], ]);查询列表时不建议SELECT *因为你未必需要所有字段而且有些大字段比如content一类的长文本会把网络传输和内存白白耗掉。这里我只查了列表页需要的五个字段。关于传参给LIMIT这里有个PHP的神坑$offset和$size是拼在SQL字符串里的。为什么不也走预处理因为PDO默认把LIMIT ?里的参数当成字符串处理拼出来是LIMIT 0, 10MySQL直接报语法错误。解决方案有两个要么在拼SQL前强制(int)转换保证是确实是两个纯数字我代码里做的就是这件事要么用bindValue(1, $offset, PDO::PARAM_INT)显式声明参数类型。两者都行前者更简洁。4.3 修改操作UPDATE必须带主键回来校验影响行数修改接口api/user_update.php是整个CRUD里最容易埋雷的。核心逻辑?php require db.php; $input json_decode(file_get_contents(php://input), true); $id (int)($input[id] ?? 0); $nickname trim($input[nickname] ?? ); $status (int)($input[status] ?? 1); if ($id 0) { exit(json_encode([success false, msg 参数错误])); } $stmt $pdo-prepare(UPDATE user SET nickname ?, status ? WHERE id ?); $stmt-execute([$nickname, $status, $id]); if ($stmt-rowCount() 0) { echo json_encode([success true, msg 修改成功]); } else { echo json_encode([success false, msg 修改失败或数据未变化]); }这里有两个点要讲透第一UPDATE的WHERE条件必须带主键。如果只按用户名更新万一有两条相同用户名虽然加了唯一索引不该出现但历史数据可能脏就会一次性改到多行。带主键是最小的、精确到行的锁定粒度。第二rowCount()返回的是“受影响的行数”。这里有个新手容易困惑的地方如果你把昵称从“张三”改成“张三”值没变MySQL会返回受影响行数为0。所以我把“修改失败”和“数据未变化”合并提示避免误导用户以为操作没成功。想严格区分的话可以先查一次旧值再判断但大多数场景没必要。4.4 删除操作单删与批量删除以及为什么不搞物理删除删除分两种场景单个删除和选中多行批量删除。批量删除用IN子句注意要把ID数组转成整型数组之后再拼SQL?php require db.php; $input json_decode(file_get_contents(php://input), true); $ids $input[ids] ?? []; if (!is_array($ids) || count($ids) 0) { exit(json_encode([success false, msg 请选择要删除的记录])); } // 关键把每个ID强制转成int防止注入 $ids array_map(intval, $ids); $placeholders implode(,, array_fill(0, count($ids), ?)); $stmt $pdo-prepare(DELETE FROM user WHERE id IN ($placeholders)); $stmt-execute($ids); echo json_encode([success true, msg 已删除 {$stmt-rowCount()} 条记录]);为什么批量删除要专门把ID转成intval因为如果这里偷懒直接implode(,, $ids)拼进SQL用户在前端把复选框的value改成一段恶意字符串就有可能改变SQL语义。用array_map(intval, ...)之后所有值保证是纯数字拼进去再安全不过。关于“物理删除 vs 软删除”的问题我在很多项目里被问过。业务数据订单、流水、用户资产绝不能物理删否则审计和恢复都无从谈起应该加一个is_deleted字段删除时执行UPDATE ... SET is_deleted 1。这套源码为了保持“最小演示”直接用了DELETE但在注释里我专门做了说明凡是核心业务表上线前务必改成软删除。5. 从按钮点击到数据落库一次添加操作的全链路串讲5.1 前端fetch请求与后端响应的完整时序很多人单独看前端能看懂单独看后端也能看懂但一联调就懵。我拿“添加用户”这一次操作把整条链路按时间顺序捋一遍用户点击js_add_btn触发openModal(add)弹窗显示表单清空用户填写用户名、昵称、状态点击弹窗里的“确认”按钮前端JS把表单数据收集成一个对象比如{username: zhangsan, nickname: 张三, status: 1}JS调用fetch(api/user_add.php, {method: POST, headers: {Content-Type: application/json}, body: JSON.stringify(obj)})浏览器发起HTTP请求PHP接收到请求体user_add.php里的json_decode(file_get_contents(php://input), true)把JSON字符串转成PHP数组后端校验必填字段通过PDO预处理执行INSERTMySQL执行插入返回自增主键ID后端echo json_encode([...])输出JSON响应浏览器拿到响应体前端JS把响应解析成对象判断success为true就关闭弹窗、调用loadList(1)刷新列表![注意] 第4步是最容易被环境坑到的地方fetch默认请求是GET你忘了写method: POST后端用$_POST收数据就会收到空的同理如果你发送的是JSON格式但没加Content-Type: application/jsonPHP端php://input里的字符串格式可能不对json_decode返回null。联调时90%的“请求失败”都出在这一步。5.2 统一的JSON响应结构success/msg/data这套源码里所有接口的返回结构我一律统一成{ success: true, msg: 操作成功, data: {} }success布尔值前端用它判断进入成功分支还是失败分支msg提示文案前端直接弹给用户看data业务数据查询接口放列表和分页信息添加接口放新ID不需要时就给空对象这个约定一旦定下来前端写一个通用的请求封装所有接口都能复用async function request(url, method GET, body null) { const options { method, headers: {} }; if (body) { options.headers[Content-Type] application/json; options.body JSON.stringify(body); } const resp await fetch(url, options); const result await resp.json(); if (!result.success) { alert(result.msg); throw new Error(result.msg); } return result; }作为对比我也见过有些源码返回结构五花八门有的接口返回{code:0,message:ok}有的返回{status:1,info:成功}前端每接一个接口就要写一套判断逻辑。统一响应结构这点投入极小收益极大。5.3 错误处理异常、超时与重复提交后端用try...catch兜底PDO异常前端也需要兜底网络错误和HTTP状态码try { const result await request(api/user_add.php, POST, formData); closeModal(); await loadList(currentPage); } catch (err) { // 这里alert已经由request里的统一逻辑弹过了 // 如果是在真实项目中应该用Toast组件替代alert }再一个高频问题用户手一抖连点两次“确认”INSERT执行了两遍库里多了两条重复数据。这个问题的解法我在后面“避坑”那一章专门讲因为它值得单独拿出来说。6. 实测中的高频坑连点、布局、乱码与性能6.1 按钮连点导致重复提交标题里的按钮场景配合热搜词里“限制一段时间内对button只能点按一次”这个需求我直接说结论凡是“提交”类按钮必须做防连点处理。我在js_add_btn对应的提交逻辑里用了“状态锁禁用”双重保险let isSubmitting false; submitBtn.addEventListener(click, async function () { if (isSubmitting) { return; // 已经在提交中直接忽略这次点击 } isSubmitting true; submitBtn.disabled true; submitBtn.textContent 提交中...; try { await request(api/user_add.php, POST, formData); closeModal(); await loadList(1); } finally { isSubmitting false; submitBtn.disabled false; submitBtn.textContent 确认; } });用isSubmitting布尔值作为锁配合disabled属性让按钮在视觉上也变成不可点状态双保险。finally里一定记得解锁否则一旦请求失败按钮就永久锁死用户只能刷新页面体验非常糟糕。如果你不希望在请求期间禁用按钮还有一种方案是“防抖”——只响应最后一次点击function debounce(fn, delay 300) { let timer null; return function (...args) { clearTimeout(timer); timer setTimeout(() fn.apply(this, args), delay); }; }但防抖对“提交”这种动作其实不完全合适因为用户第一次点击后的300毫秒内再点防抖会把第一次点击也取消掉造成“点了没反应”的错觉。提交场景我还是建议用锁禁用。6.2 循环出来的div列表一行两个最后两个元素怎么处理前端渲染列表时不一定要用table卡片式布局也很常见——比如每行两张卡片你可以用JS循环生成一堆div.card塞进容器。这个时候就会出现热搜里那个问题“循环出来的div一行两个如何算最后两个元素为什么最后两个元素不加伪类。”我直接给一套干净方案用CSS Grid或Flex的gap彻底绕开“给元素加margin导致换行错位”的问题。.card-list { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }为什么不需要给“最后两个元素”单独加伪类因为gap负责的是元素间距它天然只作用于相邻元素之间不会在容器末尾多出一个margin。如果还停留在“每个卡片margin-right: 16px然后用伪类去掉最后一项的margin”的老思路那当然要操心“哪两个是最后两个”——改布局列数、改数据条数都得重新算。用gap之后这个问题直接消失。如果你偏要用伪类做分隔线也有个经典写法.card-list .card:nth-child(odd) { margin-right: 16px; }但这个写法只在“固定一行两个”时成立一旦响应式变成一行三个就要改回3n1维护起来麻烦。我的经验是布局间距统一交给gap伪类只用来画真正的视觉装饰如下边框、背景渐变别用来处理间距。6.3 中文乱码的三个源头运行这套源码时如果出现中文乱码不外乎三个位置没对齐按顺序排查数据库建表字符集建表时用了DEFAULT CHARSETutf8mb4就没有问题如果是老库用ALTER TABLE转换字符集顺便把连接字符集也统一PDO连接DSNdb.php里的charsetutf8mb4必须写对。这是连接层只要它和表字符集不一致中文大概率变问号HTML页面编码页面头部要有meta charsetUTF-8PHP文件本身也要以UTF-8无BOM格式保存。用某些编辑器保存成带BOM的UTF-8接口返回的JSON在最前面会多一个不可见字符前端resp.json()直接解析失败配合热搜词里“mysql数据库修改结构”的需求老库转字符集的语句我也贴一下ALTER TABLE user CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;注意是CONVERT TO CHARACTER SET不是DEFAULT CHARACTER SET——前者会同时转换表内已有数据的编码后者只改默认值历史数据还是旧的。6.4 数据量变大之后索引、慢查询与批量操作这套源码只有一张用户表数据量小的时候看不出问题。但热词里既然提到了“数据库同步软件”“excel导入数据库”这些场景数据量很容易就上来了。等你表里有几十万行下面这几个优化是必须做的分页查询必须走索引ORDER BY id DESC LIMIT offset, size这种写法在offset很大的时候会越来越慢因为MySQL还是要扫过前offset行。优化方案是“延迟关联”或“游标分页”比如记住上一页最后一条记录的ID下一页用WHERE id ? ORDER BY id DESC LIMIT ?批量插入Excel导入场景下一条一条INSERT循环几千次慢到怀疑人生。改成拼一条多值INSERTINSERT INTO user (username, nickname, status) VALUES (a, A, 1), (b, B, 1), (c, C, 1);加索引要克制不是每个字段都适合加索引。像status这种只有0和1两个值的字段加索引收益极低。真正该加的是“查询条件里高频出现的字段”比如按创建时间查就加created_at索引事务Excel批量导入这种“要么全成功要么全失败”的操作必须包在事务里。PDO用法$pdo-beginTransaction(); try { // 循环执行INSERT $pdo-commit(); } catch (Exception $e) { $pdo-rollBack(); echo json_encode([success false, msg 导入失败已回滚]); }这次分享的这套CRUD源码本质上是一个“最小可运行”的脚手架。我故意没有把它包装成某种“万能代码生成器”因为在我个人经验里增删改查最大的价值不在于代码本身而在于你把它彻底跑通之后建立的“全链路直觉”——看到页面上一个按钮你就能在大脑里完整回放出它背后的HTTP请求、SQL执行和数据回显全过程。有了这个直觉以后不管换成MyBatis-Plus、Django ORM还是别的什么工具你都不会觉得那是黑魔法。如果你是把这套代码当作业或练习来跑我建议你拿到之后不要只满足于“能跑”试着做两件小事一是把用户表换成你自己熟悉的业务表比如文章表、商品表二是在这个基础上加一个“导入Excel”的入口把6.4里的事务逻辑用起来。这两步做完你对数据库操作的掌控力会明显上一个台阶。
返回列表