最近整理旧项目时翻出一个用 jQuery 写过的商品列表管理页面功能很朴素基于 jQuery 做商品数据的增删改查再配上几组统计数字。但就是这样一个经典场景十几年了还在各种后台系统里反复出现也几乎每天都在被前端新手拿出来练手。我重新捋了一遍实现思路和当年的踩坑记录想把它完整摊开讲一讲。这个 Demo 虽然不复杂但恰好覆盖了前端 CRUD 的完整链路从表格渲染、事件绑定到数据统计、后端接口配合每一环都有值得注意的细节。如果你正在学 jQuery或者被安排维护一个老系统里的后台页面这篇内容应该能帮你省下不少摸索时间。1. 项目拆解这个 Demo 到底要做成什么样1.1 需求清单与功能边界先别急着写代码我把需求边界画清楚。我们要做的不是电商后台那种带权限、带审批、带工作流的复杂系统而是一个典型的内部管理页面一张表格展示商品信息支持在页面上新增、编辑、删除商品同时在页头或侧边给出几组统计指标。具体拆开看功能清单很明确表格列序号、商品名称、分类、单价、库存、状态上架/下架、操作按钮操作维度新增商品、编辑商品、删除商品三项必备统计维度商品总数、库存总量、平均单价、上架商品数数据量控制在几千条以内完全没有必要上 Vue、React 或者引入复杂的状态管理。这个规模下用 jQuery 加一个普通数组就能轻轻松松跑起来。更关键的是我希望通过这个项目强调一个设计习惯把数据状态和视图渲染分开。jQuery 本身没有响应式数据流但我们可以自己在内存里维护一个商品数组所有操作都先修改数组再统一调用一个渲染函数去重绘表格。这样做的好处是逻辑高度集中后续做排序、筛选、统计都直接基于数组计算不会出现界面显示跟数据不一致这种经典难题。1.2 前端方案选型为什么仍然是 jQuery有朋友肯定会问现在都什么年代了新项目谁还用 jQuery这个问题必须分场景回答。如果是从零开始做复杂的单页应用我肯定推荐现代框架组件化、响应式、生态成熟开发效率确实高。但如果你的目标是一两天内快速交付一个内部工具页、一个后台管理子页面或者项目本身就跑在 ASP.NET WebForms 这类老架构上jQuery 反而是更顺手的选择。它不需要 Node 环境不需要构建工具一个 script 标签引进来就能开工浏览器兼容性也足够稳。还有一个特别现实的原因存量系统实在太多了。你去翻那些经营了七八年甚至十年的后台项目大量代码还是 jQuery 写的。改一个表格交互、加一个统计区块用 jQuery 接手的成本是最低的。这次我们实现的 CRUD 加统计纯 jQuery 大概两百行核心代码就能跑通可维护性并不差。不过我要说一句用 jQuery 不代表可以放飞自我。事件绑定要规范化数据操作要集中化DOM 查询要尽量少。下面几节我会刻意示范一些老手习惯比如事件委托、data 属性传参、统一入口更新表格这些都是长期维护血泪换来的经验。2. 表格渲染与增删改查的完整实现2.1 HTML 骨架与数据初始化先搭页面的基础结构。我用一个表格承载商品列表顶部放新增商品按钮旁边放统计卡片。表格的表头写死表体由 jQuery 动态渲染。这样前期不会有一堆重复的 tr 需要维护后面增删改查也只操作 tbody。div classcontainer div classtoolbar button idbtnAdd classbtn btn-primary新增商品/button div classstats span商品总数b idstatTotal0/b/span span库存总量b idstatStock0/b/span span平均单价b idstatAvg0/b/span /div /div table idgoodsTable thead tr th序号/th th商品名称/th th分类/th th单价(元)/th th库存/th th状态/th th操作/th /tr /thead tbody/tbody /table /div初始数据我建议直接放一个数组模拟后端接口返回。页面加载后把这个数组渲染成表格所有后续操作都围绕这个数组展开。var goodsList [ { id: 1, name: 无线鼠标, category: 数码配件, price: 89, stock: 120, status: 1 }, { id: 2, name: 机械键盘, category: 数码配件, price: 399, stock: 45, status: 1 }, { id: 3, name: USB-C 扩展坞, category: 数码配件, price: 159, stock: 0, status: 0 } ]; var state { editId: null, maxId: 3 };这里有两个设计点值得注意。第一每条数据用 id 作为唯一标识而不用数组下标。原因在删除操作时会体现——下标会随着删除变化只有 id 才能准确锁定目标对象。第二status 用 0 和 1 表示下架和上架不用字符串上架下架这样统计数据、条件判断都很方便渲染时再映射成中文标签。至于 state 对象是给新增编辑用的后面会看到。2.2 新增与编辑弹窗表单的复用逻辑新增和编辑本质上是一回事弹出一个表单填完提交更新数组重绘表格。区别只是编辑时需要把当前行的数据回填进表单。我用一个公共函数 openForm 来复用逻辑参数 goods 有值就是编辑没有值就是新增。function openForm(goods) { $(#modalName).val(goods ? goods.name : ); $(#modalCategory).val(goods ? goods.category : ); $(#modalPrice).val(goods ? goods.price : ); $(#modalStock).val(goods ? goods.stock : ); $(#modalStatus).val(goods ? goods.status : 1); state.editId goods ? goods.id : null; $(#goodsModal).modal(show); }这里用了模态框你可以用 Bootstrap也可以自己写一个简单的遮罩层。关键点在于 state.editId 的赋值编辑时记下正在编辑的 id保存时根据它来决定执行更新逻辑还是新增逻辑。有些朋友习惯用全局变量 window.currentEditId其实在单页 Demo 里问题不大但用 state 对象统一管理多个全局状态会更整洁也方便以后扩展其他字段。保存按钮的逻辑是这次 Demo 的核心我贴出来细讲$(#btnSave).on(click, function() { var name $(#modalName).val().trim(); var price parseFloat($(#modalPrice).val()); var stock parseInt($(#modalStock).val(), 10); if (!name) { alert(请填写商品名称); return; } if (isNaN(price) || price 0) { alert(单价必须大于 0); return; } if (isNaN(stock) || stock 0) { alert(库存必须是不小于 0 的整数); return; } if (state.editId null) { goodsList.push({ id: state.maxId, name: name, category: $(#modalCategory).val(), price: price, stock: stock, status: parseInt($(#modalStatus).val(), 10) }); } else { var target goodsList.find(function(item) { return item.id state.editId; }); if (target) { target.name name; target.category $(#modalCategory).val(); target.price price; target.stock stock; target.status parseInt($(#modalStatus).val(), 10); } } $(#goodsModal).modal(hide); renderTable(); renderStats(); });整个流程分四步收集表单值、做基础校验、找到目标对象、更新数组最后统一重绘。我在实际项目里一直坚持这条规则允许直接改 DOM 文本的写法看起来很快后续做搜索、排序、统计时会非常痛苦。统一走改数组 - 重绘虽然多了一步但逻辑永远是对的。如果你还在维护兼容性要求极高的老项目可以把这里的 find 换成循环或者 $.grep思路是一样的。2.3 删除与事件委托动态元素的绑定陷阱删除操作是新手踩坑的重灾区。因为 tbody 里的内容是我们动态渲染出来的如果直接写 $(tr .btn-del).click(...)第一次渲染后绑定没问题但重新渲染后新增的那些 DOM 元素身上根本没有事件。解决办法是事件委托。把监听挂在一个不会消失的祖先节点上让事件通过冒泡触发$(#goodsTable tbody).on(click, .btn-del, function(e) { e.stopPropagation(); var id $(this).data(id); if (confirm(确定删除该商品吗)) { goodsList goodsList.filter(function(item) { return item.id ! id; }); renderTable(); renderStats(); } });事件委托的原理不复杂click 事件从被点击的按钮一路冒泡到 tbodyjQuery 在 tbody 上检查事件源是否匹配 .btn-del 这个选择器匹配到了就执行回调。这样无论 tbody 里的行怎么刷新只要 tbody 本身没被替换事件就始终有效。至于按钮携带的数据我通过>// 方法一jQuery 扩展伪类 $(#goodsTable tbody tr:first); // 方法二jQuery 方法 $(#goodsTable tbody tr).first(); // 方法三eq(0) $(#goodsTable tbody tr).eq(0);它们看起来差不多但背后有区别。:first 是 jQuery 自己扩展的伪类选择器跟 CSS 原生的 :first-child 不是一回事。:first-child 匹配的是作为父元素第一个子节点的元素也就是说如果 tbody 下面第一个节点不是 tr而是注释节点、文本节点或者其他标签:first-child 就可能选不中预期的行。而 .first() 方法是截取当前匹配集合的第一个元素不依赖父元素的子节点关系语义更直接。就性能和可读性而言我在实际项目里习惯用 $(#goodsTable tbody tr).first()。这个知识点虽然小但面试或者排查样式 bug 时经常能派上用场。3. 数据统计从数组到汇总的细节控制3.1 统计口径与前端计算页面要展示商品总数、库存总量、平均单价、上架商品数。如果后端在列表接口里直接返回了这些值前端展示即可但如果后端只给原始列表前端自己算反而更灵活。统计函数本质上就是一个数组遍历function renderStats() { var total goodsList.length; var stockTotal 0; var priceSum 0; var onSale 0; goodsList.forEach(function(item) { stockTotal item.stock; priceSum item.price; if (item.status 1) onSale; }); var avgPrice total 0 ? (priceSum / total).toFixed(2) : 0.00; $(#statTotal).text(total); $(#statStock).text(stockTotal); $(#statAvg).text(avgPrice); $(#statOnSale).text(onSale); }这里有一个统计口径的问题必须讲清楚平均单价到底应该用所有商品价格的平均还是有库存商品的平均我在项目里按业务要求选择了前者但一定会在代码注释里写明口径避免后来人误解。另一个容易踩的细节是 toFixed(2) 返回的是字符串。如果后续还要用这个值参与加减乘除必须 parseFloat 转回数字否则 JavaScript 会把它当字符串拼接。数据量小的时候全量遍历没问题但列表一旦到几千条每次新增、删除都全量遍历就会感觉到卡顿。更优的做法是把常用统计值维护成一个缓存对象增删改时做增量更新。但增量更新也有代价例如编辑时改了单价库存总量不变但价格总和变了每个统计字段的依赖关系都要理清楚。我的建议是新手阶段先写清楚的全量计算等确定有性能瓶颈再优化。3.2 单元格过长内容与 DataTable 增强表格场景里避不开长文本。商品描述、备注这些字段一长td 会被撑得七扭八歪。很多老项目里最实用的方案是结合 CSS 的 text-overflow 和原生 title 属性视觉上截断鼠标悬浮时由浏览器展示完整内容。渲染代码里把完整文本塞进 titlefunction renderTable() { var html goodsList.map(function(item, index) { return tr td (index 1) /td td classcell-long title item.name item.name /td td item.category /td td item.price /td td item.stock /td td (item.status 1 ? 上架 : 下架) /td td button classbtn-edit>.cell-long { max-width: 160px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }这种做法简单可靠不依赖任何插件。如果你的项目引入了 jQuery DataTables那就更省心了。DataTables 自带分页、排序、搜索也可以给列配置 render 函数在渲染时套一层带 title 的 span 实现同样的悬浮展示。但这里有一个很多人踩过的坑启用 DataTables 之后它会在内部维持一份数据缓存和 DOM 状态这时再手动改 tbody 的 innerHTML分页状态下就会出现数据错位。正确的做法是使用 DataTables 提供的 API比如 row.add().draw() 或者 row().remove().draw()让插件知道数据变化。换句话说用了插件就要按插件的规则来不要在外面强行操作它管理的 DOM。4. 后端配合MySQL 增删改查语句与接口约定4.1 接口设计与 jQuery ajax 对接前端做 CRUD最终还是要跟后端接口对接。虽然这篇的主角是 jQuery但接口约定直接影响前端代码怎么写。我建议用一组 RESTful 风格的接口语义清晰前后端都容易对齐请求接口说明GET/api/goods获取商品列表POST/api/goods新增商品PUT/api/goods/{id}编辑商品DELETE/api/goods/{id}删除商品用 jQuery 的 ajax 对接时有几个细节特别想说。第一项目里如果没有统一的请求封装至少要把 ajax 包一层统一处理 loading、错误提示、token 注入避免每个按钮点击处都重复写一堆 $.ajax 配置。第二POST 和 PUT 请求如果后端是 Java 或 ASP.NETContent-Type 必须配合后端模型绑定的习惯。常见的坑是前端传了 JSON 字符串但后端接口用 form-data 接收最后拿到一堆 null。写标准 JSON 格式请求时代码大概长这样$.ajax({ url: /api/goods, type: POST, contentType: application/json, data: JSON.stringify({ name: 无线鼠标, price: 89 }), success: function(res) { // 成功后重新拉列表并刷新统计 } });这个对接问题在真实项目里出现频率极高我甚至见过前后端为一个 Content-Type 格式折腾一天的。建议在项目启动阶段就把它写成接口规范的一部分。4.2 MySQL 增删改查语句速查后端落地时数据库操作绕不开 MySQL 增删改查。虽然这不是前端文章的核心但顺手整理一份最常用的语句还是有价值尤其是给做全栈练习的朋友参考。-- 查询 SELECT id, name, category, price, stock, status FROM goods WHERE status 1 ORDER BY id DESC; -- 新增 INSERT INTO goods(name, category, price, stock, status) VALUES (无线鼠标, 数码配件, 89, 120, 1); -- 更新 UPDATE goods SET price 99, stock 80 WHERE id 1; -- 删除 DELETE FROM goods WHERE id 1;这里有一个老生常谈但依然值得强调的点删除尽量用逻辑删除。也就是给表加一个 deleted 字段删除时执行 UPDATE goods SET deleted 1 WHERE id 1查询时过滤 deleted 0。物理删除在真实业务里很容易导致历史订单、关联数据断裂能用逻辑删除就不要直接 DELETE。如果后端通过 JDBC 操作数据库还要注意 SQL 注入问题。一定用 PreparedStatement 传参不要在 SQL 里直接拼接用户输入这是 Java 后端开发里最基本的安全红线。前端负责把体验做好后端负责数据安全和一致性各司其职。4.3 与 ASP.NET GridView 的场景对比老一点的项目里ASP.NET WebForms 的 GridView 是后台系统的标配。GridView 是服务端控件渲染表格自带分页、编辑、删除事件开发速度确实快。但用过的都知道这种模式的最大痛点是要频繁回发 PostBack页面刷新明显交互体验很重而且 GridView 自动生成的 id 是一长串形如 ctl00_ContentPlaceHolder1_gvGoods_ctl02_btnEdit 的字符串前端选择器写起来非常痛苦。后来我参与过一个项目的改造做法是让 GridView 只负责首次渲染和数据绑定把前端交互的增强交给 jQuery。比如引入 DataTables 插件给 GridView 的表格加上搜索、排序、分页操作按钮不再走服务端事件而是通过 jQuery 拦截改走 Ajax 请求去调用 Web API。这样既保住了老架构的稳定又让体验提升了一个档次。具体实现上在 GridView 的 RowDataBound 事件里给按钮添加 CssClass 和一个自定义属性>// 错误示范重绘后新按钮没有事件 $(.btn-del).click(function() { ... }); // 正确示范委托给 tbody $(#goodsTable tbody).on(click, .btn-del, function() { ... });还有一个高频细节如果按钮内部有图标元素比如 点击图标时 e.target 指向的是内层 i 标签而不是 button。这时用 $(e.target).closest(.btn-del) 才能追到真正的按钮从而拿到>// 错误示范modal(show) 之后立刻以为用户已经确认 $(#myModal).modal(show); deleteGoods();正确做法是把删除动作放在确认弹窗的回调函数里用户点了确认按钮才真正删除。同时要注意在异步回调里通过闭包捕获当前的 id不要在弹窗打开后再去读某个可能被改写的全局变量。凡是异步回调加循环绑定闭包陷阱最容易出现建议用 let 声明循环变量或者用立即执行函数把当前值锁住。5.3 统计值偶尔对不上追踪 toFixed 与类型混淆我说一个亲自遇到的 bug。商品平均单价算出来是 76.5217toFixed(2) 之后是76.52。后续我想在统计面板里再做一个均价税的展示直接把76.52加上税费结果得到的是字符串拼接后的傻眼值76.5213%。本质就是格式化后的字符串又被当成数字继续运算。解决方案很明确所有计算过程统一用原始数字只在最终展示到界面那一刻做格式化。另外表单里拿到的值永远是字符串比如 $(#modalPrice).val() 返回89.5。在没有 parseFloat 之前这个值不能参与任何算术运算。新手的 bug 十有八九是类型问题不是逻辑问题这点多留个心眼。5.4 其他实操时易踩的小坑速查表整理一份速查表放进项目注释里排查时会非常高效问题现象可能原因解决方案新渲染的按钮点了没反应事件没有委托改用父容器 on 绑定删除后序号不连续用数组下标当序号渲染时用 index 1 重新生成平均单价多出一堆小数没 parseFloat 或格式化时机不对计算用数字展示时才格式化悬浮 title 不生效标签内引号冲突用转义字符或 data 属性中转ajax 拿到 nullContent-Type 与后端不匹配统一 application/json编辑时数据没回填表单复用逻辑没区分新增和编辑用 editId 判断统一 openForm这些坑看起来零碎但真实项目里占据的调试时间往往远超预期。把排查思路固化成表格比单纯背 API 有用得多。5.5 关于性能的一个小提醒最后聊一个我在大列表场景里的心得。jQuery 操作 DOM 很方便但频繁操作 DOM 就有性能隐患。比如在循环里每行都执行 $(tbody).append会产生大量重排页面明显卡顿。更好的方法是先把整张表格的 html 字符串拼好最后一次性 .html() 赋值给 tbody。商品列表这种规模也许感觉不到差异但数据量上万时差距非常明显。我维护过一个三万行数据的表格页面最初代码在 map 循环里逐行更新状态结果每次操作按钮都卡顿严重。改成统一重绘加集中更新统计后流畅度提升很明显。这个优化思路对任何 jQuery 项目都适用能一次操作 DOM 就不要循环操作能用数据驱动视图就不要直接改 DOM 文本。做这个 Demo 让我最大的体会是技术本身没有过时不过时关键是它能不能解决你面前的问题。用 jQuery 实现增删改查加数据统计核心价值在于逻辑清晰、边界明确、快速落地。如果你正接手一个老项目或者只想快速搭一个内部管理页面其实不必被必须用现代框架的声音绑架jQuery 在特定场景下依然能打。按我个人的习惯每完成一个功能模块我会顺手把当天踩过的坑和对应解法记在项目根目录的 NOTES.md 里。这个动作坚持一年你的排错效率会明显提升交接时后人也少挨骂。老项目虽然不性感但能把里面的问题抽丝剥茧讲清楚才是真功夫。