后台管理系统里最不起眼、但最容易变成烂摊子的组件是什么?我的答案一直是表格。尤其是那种“既要能直接改、又要能展开层级”的表格,前端换过三拨人、后端接口改过两版之后,代码基本就没人敢动了。我最近又接手了一个内部管理后台,需求很朴素:一张配置表,支持双击单元格直接改,另外一张组织架构表,要能一层层展开。技术栈是老项目,jQuery + Bootstrap 那一套,表格组件团队一直用 bootstrap-table。这篇文章就把我这次用 bootstrap-table 建立可编辑表格和树形表格的全过程摊开讲:选型为什么没换、可编辑的几条路线各自坑在哪、treegrid 扩展的数据格式要求、两者叠加时那些文档里不会写的问题,以及最后怎么把脏数据标记和批量保存做干净。适合手里有老项目要维护、或者正在做后台配置页的同学看,前端新手也能照着代码跑起来,因为关键步骤我都会给完整的可复现配置,而不是丢一段伪代码了事。
1. 这个老插件在今天还值不值得用
1.1 我为什么没换掉它
先把结论摆出来:新项目我大概率不会选它,但老项目里它依然是性价比最高的选项。理由不是情怀,是成本账。这个后台的整体结构是 Bootstrap 3 的栅格加一堆 jQuery 插件,页面里的弹窗、日期选择、下拉搜索全是 jQuery 生态。如果为了两张表格引入一套现代框架的表格组件,意味着要额外搭一层构建、要处理样式隔离、要说服后端改分页参数格式,甚至要带着整个团队重新学一套 API。而 bootstrap-table 的引入成本是三个 script 标签加一个 link,五分钟能看到效果。
它在几个具体能力上也不弱。服务端分页、列排序、列显隐、导出、固定列这些后台高频需求开箱即用,配置项统一在初始化对象里,排查问题时只要把table.bootstrapTable('getOptions')打出来,所有当前状态一目了然。这一点在线上排查时非常救命——很多现代组件的状态藏在内部 store 里,你必须装调试工具才能看到。
需要提前说清楚的是它的定位:它本质是“渲染 + 交互壳子”,不是数据管理框架。它不管你的数据怎么来、怎么存、怎么校验,这些都要自己写。想清楚这一层,后面所有设计就顺了——把 bootstrap-table 当成一个“带皮肤的表格渲染器 + 事件总线”,业务逻辑捏在自己手里。
1.2 可编辑和树形这两个需求的真实难点
很多人以为“可编辑表格”就是给单元格塞个 input,其实真正的难点全在编辑之后。第一个难点是数据回写路径:用户改完这一格,值是只留在 DOM 里,还是要同步回 bootstrap-table 内部的数据数组?如果不同步,后面刷新、排序、导出全是旧值。第二个难点是脏数据识别:批量保存时你得知道哪几行改过,不能整表提交。第三个难点是校验时机:输入过程中校验会打断用户,失焦校验又容易漏掉最后一行。
树形表格的难点在另一处。它不复杂在渲染,复杂在数据形态。后端给你的通常是扁平数组,靠 parentId 关联;而 treegrid 扩展要的是带 children 的嵌套结构,或者至少要求你指定 parentIdField 让它自己拼。中间这次转换做不好,就会出现“数据明明有 20 条,页面上只有 5 条”这种灵异现象。另外树形表格和分页是天然冲突的——分页会把父子节点切散,展开一个节点发现子节点在下一页,体验直接崩掉。
把这两个需求拆开看都很清楚,麻烦在于它们经常同时出现在一张表上:一个多级分类的配置表,既要展开层级,又要就地改名称和排序值。下面我按“先可编辑、再树形、最后合体”的顺序讲。
1.3 依赖引入顺序踩过的坑
这里有个必须提前说的版本问题。bootstrap-table 在 1.11 版本之后做了一次大的拆分,主文件不再内置各种扩展,你要用树形表格,必须额外引入bootstrap-table-treegrid.js,而且它依赖第三方插件jquery-treegrid。我第一次踩坑就是只引了扩展文件,控制台报$(...).treegrid is not a function,翻了半天源码才发现少一个依赖。
正确的引入顺序是这样的,顺序错了同样报错:
<!-- 1. jQuery 必须最先 --> <script src="jquery.min.js"></script> <!-- 2. Bootstrap 本体 --> <link rel="stylesheet" href="bootstrap.min.css"> <script src="bootstrap.min.js"></script> <!-- 3. bootstrap-table 主文件 --> <link rel="stylesheet" href="bootstrap-table.min.css"> <script src="bootstrap-table.min.js"></script> <!-- 4. 树形依赖:先 jquery-treegrid,再扩展 --> <script src="jquery.treegrid.min.js"></script> <script src="bootstrap-table-treegrid.min.js"></script> <!-- 5. 中文语言包放最后 --> <script src="bootstrap-table-zh-CN.min.js"></script>注意:语言包必须在主文件和所有扩展之后引入,否则扩展注册的默认文案会覆盖掉中文,你会看到分页按钮突然变成英文。
还有一个隐蔽点:如果你用的是 1.11 之前的版本,treegrid 是内置在主文件里的,那套写法和现在的扩展写法不完全一样,网上搜到的文章混着两个版本的代码。判断方法很简单,打开getOptions(),看看有没有treeShowField这个配置项,有就是新版扩展。
2. 可编辑表格的方案选型与列配置
2.1 三条可编辑路线的取舍
bootstrap-table 官方没有内置编辑功能,社区里实际有三条路可走,我全试过,各有适用场景。
| 方案 | 实现方式 | 优点 | 代价 | 适用场景 |
|---|---|---|---|---|
| formatter 塞 input | 在列配置的 formatter 里返回 input 的 HTML | 零依赖,完全可控,样式随便调 | 要自己写事件、取值、回写 | 列数确定、编辑字段少 |
| x-editable 扩展 | 引入 bootstrap-table-editable 和 x-editable 库 | 点击即编辑,自带弹层和多控件 | 两套样式容易打架,依赖体积大 | 字段类型多、需要日期/下拉弹层 |
| 整行编辑模式 | 单独维护一个 editingId,切换整行渲染 | 状态清晰,好做行内保存/取消 | 要写两套模板,切换时容易闪 | 表单字段多、需要事务感 |
我这次选的是第一种,原因很实在:这次要改的字段就三个——名称、排序值、启用状态,全部是 input 和 checkbox 能解决的,引入 x-editable 反而要处理它的 CSS 和 Bootstrap 3 的兼容。第三条整行编辑我留给“新增行”的场景,表格底部放一个新增按钮,弹出的是独立表单弹窗,不参与行内编辑,逻辑更干净。
这里给个经验判断:**列里需要编辑的字段少于 4 个、且都是文本或选择,走 formatter;字段超过 6 个或者有富文本、日期区间这种复杂控件,走整行编辑;只有“哪里点哪里改”是硬需求才上 x-editable。**别为了省事上重方案,后面样式冲突的时间够你写三遍 formatter。
2.2 列配置里的几个关键字段
formatter 方案的写法看起来简单,但有几个参数不写就会出问题。下面是我这次实际用的列配置片段,字段名做了简化:
columns: [{ field: 'id', title: 'ID', visible: false }, { field: 'itemName', title: '名称', width: 220, formatter: function (value, row, index) { //>var dirtyMap = {}; $('#configTable').on('input change', '.cell-input, .cell-check', function () { var $el = $(this); var index = $el.data('index'); var field = $el.data('field'); var $row = $el.closest('tr'); // 通过当前页数据取真实主键,而不是用 index var row = $('#configTable').bootstrapTable('getData')[index]; if (!row) return; var id = row.id; var val = $el.is(':checkbox') ? $el.prop('checked') : $el.val(); dirtyMap[id] = dirtyMap[id] || {}; dirtyMap[id][field] = val; $row.addClass('row-dirty'); updateSaveBar(); });这里有三个设计决定值得解释。
第一,为什么不直接改getData()里的数据?因为我希望“未保存的修改”和“已保存的数据”在内存里是两份。用户改到一半点了取消,只要清空 dirtyMap 重建表格,数据就回滚了。如果直接改原数组,回滚就得深拷贝一份快照,反而更麻烦。
第二,为什么用事件委托而不是每个 input 绑一次?表格重新渲染(翻页、排序、刷新)后,所有 input 都是新 DOM,直接绑的监听会全部丢失,你又得在onPostBody里重绑一遍,还容易绑重复。事件委托挂在 table 容器上,一次绑定永久有效。
第三,脏标记怎么呈现?我给行加了左侧 3px 的橙色竖线,右上角加一个小圆点,同时在表格上方显示“有 N 项未保存修改”和保存按钮。这个反馈很重要——后台用户经常改完一页就翻页了,没有提示的话他会以为已经保存了。
保存的时候把 dirtyMap 转成数组提交,接口做批量更新,成功后清空 map、移除高亮、重新拉数据。不要用逐行提交,几十行改动会打几十个请求,网络一抖动就会出现“改了一半”的中间态,比全失败更难收拾。
3. 树形表格的落地细节
3.1 treegrid 扩展的加载与数据格式
树形表格的初始化比普通表格只多几个配置项,但这几个配置项的语义必须搞明白。
$('#treeTable').bootstrapTable({ url: '/api/category/tree', idField: 'id', uniqueId: 'id', columns: [ { field: 'categoryName', title: '分类名称', width: 260, formatter: nameFormatter }, { field: 'code', title: '编码', width: 140 }, { field: 'sortNo', title: '排序', width: 80 } ], treeShowField: 'categoryName', // 哪一列显示展开箭头 parentIdField: 'parentId', // 父节点标识字段 rootParentId: 0, // 根节点的父 id 值 initialState: 'expanded', // 初始展开状态 expanderExpandedClass: 'glyphicon glyphicon-minus', expanderCollapsedClass: 'glyphicon glyphicon-plus', onPostBody: function () { // 树形结构的样式补丁在这里做 } });逐条说清楚。idField告诉组件哪一列是唯一标识;uniqueId是行元素的标识属性,两者通常填同一个字段,但作用不同——前者用于数据关联,后者用于 DOM 定位。treeShowField指定展开箭头挂在哪一列的单元格里,一般是最左边那列(通常是名称列)。parentIdField是关键,组件靠它把扁平数据拼成树。rootParentId指定顶层节点的父值,有的后端用0,有的用null,有的用-1,这个值填错了就会出现“一条都不显示”或者“全部都是根节点”。
我最开始就是因为rootParentId没填对,页面上所有节点都平铺出来了,一个箭头都没有。排查方法很直接:拿到接口原始数据,看根节点的 parentId 到底是什么值,然后原样填进去。
3.2 扁平数据转树:一次转换,两处复用
虽然 treegrid 扩展支持直接吃扁平数据,但我还是习惯在拿到数据后先自己转一次树。理由有两个:一是前端做别的联动(比如级联下拉、全选父节点)需要树结构;二是自己转换能控制“孤儿节点”的处理,漏了父节点的数据不至于凭空消失。
function buildTree(list, idKey, pidKey, rootValue) { var map = {}; var roots = []; list.forEach(function (item) { map[item[idKey]] = Object.assign({}, item, { children: [] }); }); list.forEach(function (item) { var node = map[item[idKey]]; var pid = item[pidKey]; if (pid === rootValue || !map[pid]) { // 父节点不存在时按根节点处理,避免数据丢失 roots.push(node); } else { map[pid].children.push(node); } }); return roots; }这里有个小技巧:!map[pid]这个判断。线上数据里偶尔会出现父节点被删但子节点还在的情况,如果不做这个兜底,这批子节点会直接从页面上消失,运营同学会以为数据丢了。按根节点处理,至少能被看见,配合一个“数据异常”的标红提示,反而方便排查。
转换完的树结构也有个坑:叶子节点的children是空数组而不是不存在,有些判断“是否有子节点”的逻辑会误判。我在buildTree末尾加了一步清理,空数组删掉:
(function clean(nodes) { nodes.forEach(function (n) { if (n.children && n.children.length === 0) delete n.children; else if (n.children) clean(n.children); }); })(roots);这个细节看起来很琐碎,但它直接决定了展开箭头会不会出现在叶子节点上——多一个空数组,叶子行前面就会挂一个点了没反应的箭头。
3.3 展开层级与父子联动
默认全展开在数据量大的时候会卡,我自己定的策略是:总节点数小于 100 时全展开,超过就只展开第一级。判断在onLoadSuccess里做,用expandAll或者手动调expandRow。
父子联动的好处在于批量操作。比如“启用/停用”,用户点了父分类,下面的子分类应该跟着变,否则状态就不一致了。我的实现方式是在树结构上做一次深度遍历,把选中节点的所有后代收集出来,再批量写进 dirtyMap。
function collectDescendants(node, result) { result = result || []; (node.children || []).forEach(function (child) { result.push(child.id); collectDescendants(child, result); }); return result; }注意:递归深度要设上限。分类表被人手工造出环形引用(A 的父是 B,B 的父是 A)不是没发生过,递归会直接爆栈把页面卡死。我在转换树的阶段就加了一个 visited 集合和最大深度 10 的保护。
还有一个体验细节:展开某个节点后,页面高度会跳变,用户正在看的行突然被顶走。解决办法是把滚动位置记下来,展开后恢复。这个做法不优雅但有效,比任何平滑滚动都直接。
4. 从零跑通一个可编辑树形表格
4.1 页面骨架与引入顺序
把前面的东西合起来,一个完整的页面骨架是这样。注意表格容器我用了div加><div class="toolbar"> <span class="tip" id="dirtyTip">没有未保存的修改</span> <button class="btn btn-primary btn-sm" id="btnSave" disabled>保存修改</button> <button class="btn btn-default btn-sm" id="btnReset" disabled>撤销修改</button> </div> <div class="table-wrap"> <table id="categoryTable"></table> </div>
CSS 部分我加了三条必要的样式,都是为了视觉反馈:
.table-wrap { max-height: 620px; overflow: auto; } .cell-input { width: 100%; border: 1px solid transparent; padding: 2px 4px; } .cell-input:focus { border-color: #66afe9; outline: none; background: #fff; } .cell-input.cell-number::-webkit-outer-spin-button, .cell-input.cell-number::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } tr.row-dirty > td { background-color: #fffbe6 !important; } tr.row-dirty > td:first-child { box-shadow: inset 3px 0 0 #faad14; }border: 1px solid transparent这一句是为了防止聚焦时行高跳动——边框从无到有会让内容下移 1px,整张表看起来在抖。先把透明边框占好位置,聚焦时只改颜色,就不会跳。
4.2 初始化代码
完整的初始化对象,可编辑和树形同时开:
var $table = $('#categoryTable'); var dirtyMap = {}; $table.bootstrapTable({ url: '/api/category/tree', method: 'get', idField: 'id', uniqueId: 'id', pagination: false, // 树形表格关掉分页 search: false, columns: [ { field: 'categoryName', title: '分类名称', formatter: function (value, row, index) { return '<input type="text" class="cell-input" ' + 'data-id="' + row.id + '">function validateDirty() { var errors = []; var data = $table.bootstrapTable('getData'); var nameMap = {}; // 先按当前父级收集已有名称,用于查重 data.forEach(function (row) { var name = (dirtyMap[row.id] && dirtyMap[row.id].categoryName) || row.categoryName; var pid = row.parentId; nameMap[pid] = nameMap[pid] || {}; if (nameMap[pid][name]) { errors.push('【' + name + '】在同一层级下重名'); } nameMap[pid][name] = true; }); // 逐条校验格式 Object.keys(dirtyMap).forEach(function (id) { var f = dirtyMap[id]; if ('categoryName' in f) { if (!f.categoryName.trim()) errors.push('名称不能为空'); } if ('sortNo' in f) { var n = Number(f.sortNo); if (!isFinite(n) || n < 0 || n > 9999 || n % 1 !== 0) { errors.push('排序值必须是 0-9999 的整数'); } } }); return errors; }校验的错误我做了一个简单处理:把有问题的行标红,同时在顶部提示第一条错误。不要一次性弹一堆 alert,用户根本记不住。汇总成一句加行内高亮,改完一条提示就少一条,体验舒服很多。
保存请求的载荷我设计成这个结构,后端一次事务处理:
{ "updates": [ { "id": 1024, "fields": { "categoryName": "前端组件", "sortNo": 10 } }, { "id": 1025, "fields": { "enabled": false } } ] }只传改过的字段而不是整行,好处是并发冲突少。两个人同时改同一行的不同字段,不会互相覆盖。代价是后端要支持部分更新,但这是值得的。
4.4 分页与树形的冲突处理
这里单独说一嘴,因为太多人在这里栽跟头。树形表格和分页在逻辑上是矛盾的:一个父节点在第 1 页,子节点在第 2 页,展开的箭头点了什么也不出来。有几种处理方式:
| 处理方式 | 做法 | 适用场景 |
|---|---|---|
| 关闭分页 | pagination: false,全量加载 | 数据量几百条以内 |
| 懒加载子节点 | 点箭头时按 parentId 请求子节点 | 层级深、单层数据多 |
| 只分页根节点 | 服务端分页只作用于顶层,子节点全量返回 | 根节点少、子节点多 |
我这次选的是第一种,因为这个分类表只有 300 多条,全量加载一次 40KB 左右,可以接受。如果哪天数据涨上去了,我会走第二种懒加载——初始只加载根节点,onExpandRow里发请求拿子节点。这条路要注意缓存:同一个父节点重复展开不能重复请求,我会用一个loadedMap记已加载的父 id。
顺带提一句,如果一定要分页,记得把pageSize调大,并且关闭sidePagination: 'client'的排序,因为客户端排序在树形结构下会把父子顺序打乱,展开后视觉上会错位。
5. 常见问题排查速查
5.1 表格形式的问题清单
这些问题我基本都遇到过,整理成表方便对号入座。
| 现象 | 大概率原因 | 处理办法 |
|---|---|---|
| 树形箭头不显示 | rootParentId与数据不一致 | 打印接口原始数据确认根节点 parentId 值 |
报了treegrid is not a function | 缺 jquery-treegrid 依赖或顺序错误 | 按 3.1 的顺序重新引入 |
| 展开后编辑内容丢失 | 未在onPostBody回填 | 渲染钩子里按 dirtyMap 重新赋值 |
| 叶子节点也有展开箭头 | children 为空数组 | 删除空 children 字段 |
| 排序后修改错行 | 用 index 定位行 | 改用主键 id 定位 |
| 数字框滚动时值被改 | 浏览器默认行为 | 监听 wheel 事件并 preventDefault |
| 首次加载一片空白 | 数据格式不是数组 | 检查返回结构,必要时在 responseHandler 里取 data 层 |
| 中文按钮变英文 | 语言包引入顺序错误 | 语言包放最后 |
| 展开时页面跳动 | 高度变化导致 | 记录 scrollTop,渲染后恢复 |
5.2 几条不太好查的坑
有几条不是配置问题,而是思路问题,单独列出来。
第一条,uniqueId必须全局唯一且稳定。我见过一个项目用“名称 + 时间戳”当 uniqueId,结果同名的两条数据互相覆盖编辑结果。任何参与 DOM 定位的字段,都必须是后端保证唯一的业务主键。
第二条,checkbox 的 change 事件在 jQuery 里要绑change而不是click。用键盘操作或者程序触发时,click不一定触发。而且取状态一定用prop('checked'),用attr在多次切换后拿到的是初始值。
第三条,不要在 formatter 里做重量级计算。formatter 在每次渲染时对每一行执行一次,300 行乘 5 列就是 1500 次。我在里面查过 map,看着没问题,但那个 map 是循环里重建的,最终拖慢了 3 倍。所有预处理数据放在onLoadSuccess里做一次。
第四条,注意移动端和缩放。后台虽然以桌面为主,但老板偶尔用平板看。表格宽度固定 900px,在小屏上横向滚动时,左边那列展开箭头会跟着滚走,用户就找不到自己展开的是哪个节点了。我给名称列加了position: sticky; left: 0,配合背景色遮住底层内容,效果还行。
第五条,导出功能要注意。bootstrap-table 的导出扩展是读取当前渲染的表格 HTML,formatter 返回的 input 会被原样导成一段标签文本。要在导出前临时切换成纯文本渲染,或者在exportOptions里做处理。这个坑不处理,导出的 Excel 里全是<input value="xxx">。
6. 性能与后期维护的一点经验
数据量上去以后,性能问题的来源往往不是 bootstrap-table 本身,而是我们的写法。我做过一次简单测量:300 行的可编辑表格,初始化耗时大概 200ms,其中一半花在生成大量 input 上。如果超过 800 行,卡顿就明显了。
优化方向有三个,按性价比排序。第一,非编辑态用纯文本,点击才变成 input。这一招能把 DOM 节点数砍掉一大半,实测 1000 行从 1.2 秒降到 400ms 左右。做法是在单元格上绑 click,点中后把td的 innerHTML 换成 input,失焦再换回来。代码量不大,收益很高。
第二,关掉不必要的功能。showColumns、showExport、detailView这些开关看着无害,但它们在初始化时都会做一次 DOM 构建和事件绑定。不需要就别开。
第三,别在前端做全量排序。树形结构下前端排序本来就会破坏层级,老老实实让后端排。
维护层面,我最大的体会是:把 formatter 和事件处理抽成独立函数,别全塞在初始化对象里。一开始图快写在配置里,后来加了字段、改了样式,那个初始化对象涨到 400 多行,改一个字段要上下翻三屏。抽出去之后,每个 formatter 单独一个函数,参数就是值、行、索引,测试起来也方便——虽然我们通常不写前端单测,但至少能单独在控制台里调。
另外还有一个我踩过的坑,值得放这里说:dirtyMap 一定要在切换数据源时清空。这个页面有两个 tab,一个是分类树,一个是标签树,共用一个表格容器。用户在第一棵树改了东西没保存,切到第二个 tab,dirtyMap 还留着,提交时把 id 对不上的数据也发过去了,后端直接报错。后来我在每次refresh前加了清空和确认提示,问题就没了。这类“状态跨数据源残留”的问题在单页应用里特别常见,本质上是没有明确的状态生命周期。
最后分享一个小的写法习惯。我现在写表格相关的代码,都会在文件顶部集中定义几个常量:字段名、根节点父 id、最大展开层级、允许编辑的字段白名单。这些值散落在各处时,改一个字段名要全局搜索替换,很容易漏。集中之后,配置和渲染逻辑分得清清楚楚,接手的同事看一眼顶部就知道这张表能改哪些东西。
这套东西我已经在三个后台里复用过了,基本结构没变过,变的只是列配置和校验规则。如果你手上也有类似的需求,建议先把“数据怎么回写、脏数据怎么标、渲染后怎么回填”这三件事想明白再动手,代码写起来会顺很多。 дерек