资讯详情

资讯详情

bootstrapTable跨页勾选与分页选中态管理方案

1. 需求还原复选框一翻页就“失忆”是怎么回事做后台管理系统的朋友大概都碰过这个需求表格带分页、左侧带复选框用户想批量处理数据勾了第一页的 5 条翻到第二页再勾 3 条回头一看第一页的勾全没了getSelections()也只返回当前页那几条。这个坑跟了 bootstrapTable 很多年几乎每隔一阵就会有人在群里问一遍。我这里说的“复选框跨页勾选”指的是在bootstrapTable采用服务端分页sidePagination: server或前端分页sidePagination: client的场景下让用户的勾选状态跨越pageNumber的切换而稳定保留并且能一次性把多页选中的数据准确交给后端。它看起来只是个小交互实际牵扯的东西不少uniqueId的配置、getSelections的实现边界、refresh之后 DOM 重绘导致的选中丢失、checkAll与逐行onCheck的重复触发、以及“全选所有页”这种根本不能靠前端硬扛的语义。我见过项目里为了这个需求写出三套互相打架的逻辑最后提交时选中的 id 里混进了上一轮查询的残留数据直接把一批不该处理的订单给批量改了状态。这篇内容适合三类人看正在用 bootstrapTable 做管理后台、被跨页勾选折磨过的前端要评审这类方案、担心数据集合管理出问题的后端以及想把“选中态管理”这件事一次做对的同学。我会从现象定位讲到根因再给出三种可选方案和完整可落地的实现中间穿插我自己踩过的坑和排查手段。文中代码基于 1.1x 系列的常见 API低版本我会额外标注替代写法。1.1 先把现象钉死三个典型症状第一个症状是“翻页即清零”。用户在第二页刷新数据后第一页的勾选视觉上消失表头那个全选框也回到未选中态。第二个症状是“getSelections只吐当前页”。你在控制台打印明明用户勾了 8 条返回的数组长度却是 3。第三个症状最隐蔽——提交接口收到的 id 数量对但里面混着上一次查询条件下的行因为选中集合从来没有被清空过。这三个症状看着是两个问题其实根源是同一个bootstrapTable 的选中态是绑在“当前渲染的数据行”上的而不是绑在“你的业务集合”上的。分页切页时tbody被整块重建旧行的 DOM 连同checked属性一起销毁新行渲染出来时代码并不知道你上一页勾过谁。至于getSelections它内部是遍历当前data数组挑出被标记的行当前页之外的数据压根不在这个数组里自然拿不到。注意不要试图用$(#table).find(input[typecheckbox]:checked)去捞选中项。这行代码只能拿到当前 DOM 里的勾选而且一旦你的表格用了虚拟滚动或懒渲染连当前页都拿不全。1.2 根因拆解选中态到底存在哪儿要理解这个事得先知道 bootstrapTable 把“选中”记在什么地方。它的行数据对象上会带一个内部标记勾选时给对应行打上标记取消时去掉getSelections就是从这个数据数组里筛出带标记的行。也就是说选中态的生命周期等于“这个数据数组的生命周期”。而分页切页时服务端模式下会重新发请求、拿到新的一页数据、整表重建前端模式下虽然数据都在内存里但refresh或selectPage同样会重绘tbody行对象上的标记在新一轮渲染里没了着落。更麻烦的是即便数据对象还在重新渲染的 checkbox 也需要主动去“回填”checked否则视觉上就是空的。所以跨页勾选的本质不是去改 bootstrapTable 的某个参数让它自动记住而是我们要在它之外维护一份独立的、以业务主键为索引的选中集合然后在每次数据加载完成后把这份集合“投影”回当前页的表格上。这个思路一通后面所有细节都是围着它转。2. 方案选型三条路别一上来就选最复杂的在动手之前先想清楚你的数据规模和分页方式因为不同方案的成本差得很远。我见过有人一上来就搞服务端全选接口结果表格总共才 200 条数据纯属给自己加活。下面三种方案覆盖了绝大多数场景选择依据也写清楚了。2.1 方案A前端分页 原生 checkbox 标记如果总数据量在几百到两三千条以内最省事的做法是sidePagination: client一次性把数据全给前端让 bootstrapTable 自己分页。这种模式下数据数组是完整的行对象不会因为切页而销毁只是渲染部分行你在onCheck里往row上打一个标记或者干脆不管 bootstrapTable直接用uniqueId加一个 Set 维护切页回来再回填。这个方案的优点是几乎不用写多少代码getSelections有时能直接拿到全部——注意是“有时”因为不同版本在 client 模式下getSelections也会受渲染范围影响别把它当唯一依据。缺点是数据量一大首屏渲染和内存都吃不消而且筛选逻辑必须全在前端做后端只吐全量数据接口设计上不优雅。我一般只在内部小工具或者配置类页面上用它。2.2 方案B服务端分页 自维护选中集合推荐这是绝大多数管理后台的标准答案。表格走服务端分页每页只拿当前页数据同时在前端维护一个Map键是业务唯一 id值是行数据快照或者只存 id。onCheck时塞进去onUncheck时删掉onLoadSuccess时把当前页里命中集合的行回填成勾选状态提交时把Map里的 id 或整行数据序列化给后端。它的核心价值在于选中集合的生命周期完全由你控制跟表格渲染解耦。切页、刷新、甚至重新查询都不影响重新查询时你主动清空即可。代价是要处理一组边界全选当前页怎么算、半选态怎么画、行数据更新后快照要不要同步、删除行之后集合要不要摘掉。这些我在第 3、4 章会逐个拆。2.3 方案C服务端分页 “按条件全选”标记当数据量到几万甚至几十万条用户想的是“把我筛出来的这 3 万条全部处理掉”这时候前端存 id 就不现实了——光 id 数组就是几百 KB接口也扛不住。正确的解法是把“全选”的语义下沉到后端前端只传三个东西一个是当前的筛选条件一个是allSelected: true标记一个是“反选排除列表”用户在部分行上手动取消的 id。后端接到这三样用筛选条件加排除列表拼出真正的目标集合直接走批量处理。判断“全选”的条件也很直观当用户点了表头全选、且当前页所有行都在集合里、并且已选数量等于筛选结果总数时就切换到这个模式。方案适用数据量前端代码量后端配合度典型坑A 前端分页几百到两三千少低数据多时卡顿筛选逻辑全压前端B 自维护集合几千到几万中低只要 id查询条件变化忘清空、快照过期C 条件全选十万以上中高要支持排除列表语义切换时机、计数不准选型的判断标准其实就一条用户一次要处理的记录数是否可能超过“人愿意手动勾选”的量级。超过了就必须考虑 C没超过B 是最稳的。别为了炫技在 200 条数据的表格上做 C后面维护的人会恨你。3. 核心实现把跨页勾选真正跑通接下来是落地部分。我按“配置—收集—回填—取数”的顺序写每一步都给出可直接抄的代码并且标注为什么这么写。这套代码我在几个后台项目里反复用过稳定运行。3.1 表配置uniqueId 是地基少一个字段全盘皆输第一件事uniqueId必须设置而且必须是真正的业务唯一键。bootstrapTable 靠它来定位行、回填勾选、做checkBy。很多人不设或者随手设成id但数据里根本没这个字段结果回填时静默失败页面上看着像“随机勾选”。$(#goodsTable).bootstrapTable({ url: /api/goods/list, method: post, sidePagination: server, pagination: true, pageNumber: 1, pageSize: 20, pageList: [10, 20, 50, 100], uniqueId: goodsId, // 关键业务唯一键 clickToSelect: false, // 避免点行误触 columns: [ { field: state, checkbox: true, width: 40 }, { field: goodsId, title: 编号, width: 140 }, { field: goodsName, title: 名称 }, { field: status, title: 状态 } ] });第二个细节checkbox 列的field写成state是社区惯例但这个字段名会跟你的业务数据撞车。如果你的行数据里真有一个叫state的字段表示状态bootstrapTable 会把它当成勾选标记来读写直接污染数据。稳妥的做法是把 checkbox 列设成field: __checked__这种明显不可能重复的名字或者干脆不写field部分版本允许但不写会导致内部标记落到默认字段上我倾向显式写一个不冲突的名字。还有一个坑不要给 checkbox 列加自定义formatter来画勾选。bootstrapTable 内置的勾选逻辑与getSelections、checkBy是绑定的你一旦接管渲染内部标记就断了表现为“看着勾上了取数时是空的”。要控制某行默认勾选用第 3.3 节讲的回填方法别碰 formatter。提示uniqueId对应的字段值必须是字符串或数字且不能有重复。如果后端返回的 id 是数字但前端某处转成了字符串Map的键就会对不上出现“明明勾过却回填失败”。统一String(row.goodsId)能省掉一类玄学 bug。3.2 用 Map 维护选中集合存 id 还是存整行我选择用Map而不是数组原因很直接Map的has/delete是 O(1)几万条也不卡数组得indexOf遍历每次勾选都扫一遍数据一多就掉帧。键用唯一 id 的字符串形式值分两种存法取决于后端要什么。如果后端批量接口只要 id 数组值存true就够了内存最省。如果后端需要行里的其他字段比如按行里的仓库号分组处理那就存整行快照。我这里给的是存整行的版本因为实际项目里多半要展示“已选列表”并允许用户在弹窗里核对。// 全局选中集合key 为唯一 id 字符串value 为行数据快照 const selectedMap new Map(); // 统一取 id 的方法避免各处转换不一致 function getId(row) { return String(row.goodsId); }封装一个小的操作对象把增删查都收口别在事件里零散写逻辑否则后面加日志、加计数、加钩子的时候到处改const Selection { add(row) { selectedMap.set(getId(row), row); }, remove(row) { selectedMap.delete(getId(row)); }, has(row) { return selectedMap.has(getId(row)); }, clear() { selectedMap.clear(); }, ids() { return Array.from(selectedMap.keys()); }, rows() { return Array.from(selectedMap.values()); }, size() { return selectedMap.size; } };这个封装看着简单价值在后面当用户切换查询条件时你只需要调一次Selection.clear()当后端返回最新行数据时你可以遍历更新快照当要显示“已选 N 项”时读Selection.size()就行不用去问表格。3.3 事件处理onCheck 与 onCheckAll 的重复触发怎么绕这里是坑最深的地方。很多人只在onCheck和onUncheck里做增删测试完全没问题一上生产就发现“全选之后取消一行结果整页都没了”——因为checkAll除了触发onCheckAll还会逐行触发onCheckuncheckAll同理逐行触发onUncheck之后再来一次onUncheckAll。如果你在这几个回调里各自做不同的集合操作就会互相打架。我的处理原则很简单只信逐行事件全选/全不选事件只用来做计数刷新和 UI 同步。因为逐行事件天然带row对象语义最准确而onCheckAll拿到的是“操作后的行集合”在不同版本里这个集合的范围还有差异有的只给当前页不牢靠。$(#goodsTable).on(check.bs.table, function (e, row, $el) { Selection.add(row); updateSelectedCount(); }).on(uncheck.bs.table, function (e, row, $el) { Selection.remove(row); updateSelectedCount(); }).on(check-all.bs.table, function (e, rowsAfter, rowsBefore) { // 兜底某些版本全选时逐行事件可能不全这里补一遍当前页 rowsAfter.forEach(r Selection.add(r)); updateSelectedCount(); }).on(uncheck-all.bs.table, function () { // 注意这里只清当前页不能清整个集合 $(#goodsTable).bootstrapTable(getData).forEach(r Selection.remove(r)); updateSelectedCount(); });onCheckAll里再遍历一遍rowsAfter是防御性的因为Map的键天然去重重复set不会有副作用这个特性让“重复触发”这个坑变得无害。而onUncheckAll里我特意用getData()只摘当前页不能Selection.clear()——用户可能在前几页勾了东西点了当前页的“全不选”你把他前面选的也清了这是最典型的投诉来源。还有个容易忽略的点onCheck回调里的row是行数据的引用如果后续表格刷新覆盖了这个对象你Map里存的快照可能和页面显示不一致。解决办法是在onLoadSuccess里做一次“快照刷新”用新数据覆盖集合里同 id 的行。3.4 分页回填为什么回显必须写在 onLoadSuccess 里选中集合维护好了接下来是让用户切回第一页时看到那些勾还在。这里的关键是“在正确的时机调checkBy”。onPageChange是个陷阱——它触发时数据还没回来表格里还是旧行你这时候调checkBy是给旧数据打勾紧接着新数据一渲染全白干。正确的时机是onLoadSuccess数据已拿到、行已生成或者onPostBodyDOM 已渲染完。我实测下来onLoadSuccess里调checkBy完全够用checkBy内部会同步更新选中标记和 checkbox 的checked属性不会引起二次渲染。$(#goodsTable).on(load-success.bs.table, function (e, data) { const pageIds data.rows.map(r String(r.goodsId)); // 只回填当前页中命中集合的行 const hitIds pageIds.filter(id selectedMap.has(id)); if (hitIds.length) { $(#goodsTable).bootstrapTable(checkBy, { field: goodsId, values: hitIds }); } });checkBy传的field必须和uniqueId对应的字段一致values里是原始类型的值数组。如果你的 id 是数字这里传字符串可能匹配不上所以我在pageIds那一步统一String()但给checkBy的时候要还原成原始类型这一点很容易翻车。稳妥写法是const hitValues data.rows .filter(r selectedMap.has(String(r.goodsId))) .map(r r.goodsId); // 保留原始类型 $(#goodsTable).bootstrapTable(checkBy, { field: goodsId, values: hitValues });低版本没有checkBy1.11 之前替代方案是对当前页每一行按索引调checkdata.rows.forEach((r, idx) { if (selectedMap.has(String(r.goodsId))) { $(#goodsTable).bootstrapTable(check, idx); } });这两种写法我都用过索引法的风险是行顺序万一和你遍历的顺序不一致比如表格内部有排序会勾错行所以能升级就升级。3.5 提交取数忘掉 getSelections用自己的封装getSelections在跨页场景下基本不可用原因第 1 章讲过了。提交时的取数统一走自己的封装并且要在提交前做一次“有效性校验”把集合里已经不在筛选条件内、或者已被后端删除的 id 剔除掉。这个校验看着多余但在实际业务里救命——用户在两页之间挂了十分钟中间数据被别人删了你原样提交上去后端批量处理报错页面还得再报一次红。function getSubmitData() { return { ids: Selection.ids(), rows: Selection.rows() }; } // 提交前二次确认 if (Selection.size() 0) { alert(请先勾选数据); return; }另外Selection.rows()里存的是快照如果后端需要的是“最新的行数据”那提交时应该只传 id让后端自己按 id 查一遍。我一般会在接口文档里明确批量接口只接收 id 数组行内的可变字段一律由服务端重查避免“前端拿着过期的价格/库存去改单”这类数据一致性问题。4. 进阶场景全选、半选态与大数据量的取舍基础的跨页勾选跑通之后真正的麻烦才开始。用户会问“我能不能点一下表头就把所有页全选了”产品会问“为什么全选之后那个框还是半明半暗的”运维会问“这个页面怎么一勾选就卡”。这一章针对这三类问题。4.1 两种“全选”语义当前页全选 vs 全选所有页先说清楚这两个是完全不同的东西必须在产品层面区分。当前页全选是“把本页 20 条加入集合”用户翻到下一页表头是全空的这是正确行为。全选所有页是“把当前筛选条件下的 3 万条全部纳入”用户翻页时每一页的表头都应该是选中态。实现上第二种不要真的去遍历所有页然后塞 id而是用第 2.3 节的条件全选标记。数据结构上增加三个状态const batchState { mode: page, // page | all filters: {}, // 当前查询条件 excludedIds: new Set() // 全选模式下用户手动取消的行 };当用户点击表头的“全选所有页”时把mode切到all清空excludedIds用户在某一页取消某行时如果modeall不删selectedMap因为根本没存全量而是把这个 id 塞进excludedIds。前端展示“已选 N 项”时N 等于“后端返回的总数减去 excludedIds 的数量”这个总数从分页接口的total字段拿。这里有个 UI 一致性坑一旦用户在全选模式下改了查询条件mode必须重置回pageexcludedIds也要清空。我见过有人只清了selectedMap忘了清excludedIds结果新一轮查询后排除列表里躺着一批根本不存在的 id数量对不上用户直接懵。4.2 半选态表头那个 indeterminate 得自己画bootstrapTable 的表头全选框只会区分“勾上/没勾上”不会画半选态indeterminate。当用户在 20 条里勾了 7 条表头应该显示一个横杠但它只会显示空的。这个体验差距在数据量大的页面上特别明显。画半选态的做法是在每次选中集合变化后手动设置表头 checkbox 的indeterminate属性。逻辑是当前页已选数大于 0 且小于当前页总数时为半选等于总数时为全选等于 0 时为空。function updateHeaderIndeterminate() { const data $(#goodsTable).bootstrapTable(getData) || []; const total data.length; const checked data.filter(r selectedMap.has(String(r.goodsId))).length; const $head $(#goodsTable).find(thead input[typecheckbox]); if (checked 0 checked total) { $head.prop(indeterminate, true); } else { $head.prop(indeterminate, false); } }在check、uncheck、check-all、uncheck-all以及load-success之后都调一次。注意indeterminate只能在 JS 里设置HTML 属性上写不了另外设置完之后不要立刻调refreshOptions之类会重建表头的方法否则会被冲掉。注意如果你用的是“全选所有页”模式表头应该直接显示为全选而非半选哪怕当前页只命中了一部分。这个判断要放在updateHeaderIndeterminate里优先处理别让视觉和实际语义打架。4.3 上万条数据时别硬扛内存和渲染服务端分页下一页撑死几百条selectedMap里存几千个 id 完全没问题内存占用在几 MB 级别。但如果你把整行快照都塞进去一行 20 个字段一万行就是几 MB 到几十 MB再加上 Vue/React 的响应式追踪浏览器标签页的内存曲线会很难看。我的做法是分级选中数量在 2000 以内存整行方便弹窗展示超过就只存 id弹窗里的详情改成按需向后端查。这个阈值不是拍脑袋是实测出来的——超过两千行数据后每次集合变化触发的重渲染如果你用响应式框架会让输入型交互明显掉帧。另一个隐形消耗是“已选 N 项”的频繁更新。如果每次勾选都触发一次全局状态更新连续快速勾选时会出现卡顿。用防抖 200ms 包一下计数刷新体感立刻顺滑。这个细节不算高深但很多项目就是卡在这里。5. 排查实录那些文档里不会写的坑写到这儿主流程已经完整了。但真正决定这套方案能不能上生产的是边界情况的处理。下面这些坑都是我或者在评审别人代码时真实遇到过的按“症状—原因—解法”整理成速查表再挑几个细节展开。5.1 常见问题速查表症状可能原因解法切页后勾选全丢未在load-success里回填用checkBy按 id 回填回填报错或无反应uniqueId字段值含空/重复或类型不一致统一转字符串比对检查数据质量全选后取消一行整页清空onUncheckAll里误调clear()只摘当前页数据别清全集合提交的 id 混入上轮查询结果查询条件变化时未清空集合查询按钮回调里先Selection.clear()全选所有页时数量对不上excludedIds未随条件重置条件变化时同时重置 mode 和排除列表表头半选态不显示未手动设置indeterminate集合变化后主动设置勾选后getSelections为空checkbox 列被自定义 formatter 覆盖移除自定义 formatter用回填方案数字 id 匹配失败Map键是字符串checkBy传了数字/字符串不一致显式转换保持两端一致5.2 三个不明显但致命的细节细节一查询条件变化必须清空选中集合。这是线上事故率最高的一条。用户先按“待发货”筛出 50 条勾了 10 条然后改成“已发货”重新查询接着点批量发货。如果集合没清那 10 条待发货的 id 会跟着新条件一起提交上去。这类问题测试环境很难复现因为测试人员通常不会连续切换条件再操作。正确的写法是把“重新查询”收口成一个函数里面固定做三件事清集合、重置分页到第一页、刷新数据。别让业务代码到处直接调refresh。function reloadTable(params) { Selection.clear(); if (batchState.mode all) { batchState.mode page; batchState.excludedIds.clear(); } $(#goodsTable).bootstrapTable(refreshOptions, { pageNumber: 1, queryParams: function (p) { return Object.assign(p, params); } }); }refreshOptions会带上新的queryParams并从第一页重新加载比先selectPage(1)再refresh少一次请求。细节二refresh 之后事件监听会重复绑定。如果表格容器是在弹窗或者路由切换中反复初始化$(#table).on(check.bs.table, ...)会被叠加多次用户勾一行回调执行三遍。Map的去重掩盖了重复写入但如果你在回调里做发请求、弹提示这类副作用就会重复触发。解法是用.off(check.bs.table)先解绑或者把监听统一绑在$table的父容器上做事件委托初始化时先解绑再绑定。细节三行删除后要同步摘除集合。用户勾了 5 条在页面上删掉其中 1 条如果不从selectedMap里摘掉提交时会带上一个已经不存在的 id。批量接口要么报“部分记录不存在”要么更糟——静默跳过但返回成功用户以为都处理了。删除成功的回调里加一行Selection.remove(row)就能解决但要确保拿到的是被删那行的完整对象别只拿到 id 却调了需要行对象的remove。$(#goodsTable).on(click, .btn-del, function () { const row $(#goodsTable).bootstrapTable(getRowByUniqueId, $(this).data(id)); if (!row) return; $.post(/api/goods/delete, { id: row.goodsId }, function (res) { if (res.ok) { Selection.remove(row); $(#goodsTable).bootstrapTable(removeByUniqueId, row.goodsId); updateSelectedCount(); } }); });这里用removeByUniqueId而不是remove避免索引因为排序而错位。这一对方法在跨页场景下是标配getRowByUniqueId能找到当前页里的行对象拿它去做后续操作最稳。6. 一点个人体会这套东西我在三四个后台项目里反复实现过从最早用数组硬扛、每次切页重新遍历到后来把集合封装成独立模块最大的感受是跨页勾选的难点从来不在“怎么记住”而在“什么时候该忘”。刚上手时我总觉得要尽力保住用户的勾选后来发现用户切换筛选条件、重新搜索、甚至刷新页面时忘掉反而才是对的——保留一个用户看不见来源的选中集合比清空它危险得多。所以在选型时我会先问产品一句话用户切条件之后之前勾的还算不算答案通常是“当然不算”。把这句话写进需求文档后面所有的clear时机就都有了依据不需要靠猜。真要给个可以直接抄的结论的话就三条uniqueId一定要设、选中集合一定不要用getSelections、每次load-success一定要回填。这三条守住了剩下都是锦上添花。至于“全选所有页”先确认一下自己的数据量级和交互预期很多时候用户其实只需要“全选当前页”是我们把它想复杂了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →