soulTable 后端分页 + 表头过滤
背景
报表页有 4 个 tab:汇总统计 / 明细列表 / 分组统计 / 按日期查询。一开始做的是前端分页:后端一次性全量返回,table.reloadData 填数,分页、排序、过滤全在前端。
数据量小的时候没什么问题,主表到了接近 10 万行就不行了——两个明细类报表每次打开页面都要把上万行 JSON 整个传下来,首屏和翻页肉眼可见地卡。另一个日志页只有单表 COUNT,后端分页 50 条一页,一直很流畅。差别就在分页发生在数据库层还是前端。
于是把明细类报表改成后端分页(url + page: true)。soulTable 后端过滤模式的坑不少,趁这次一起记录一下。
分页模式切换(url + page: true)
layui table 后端分页的基本写法:
1 | table.render({ |
- 翻页、排序(
sort: true的列)时 layui 自动带page/limit/sort/order参数请求后端 - 后端返回
{code, count, data}三件套,count是总行数,用来渲染页码
第一个坑:soulTable.render(this) 必须保留。 前端分页模式里它在 reloadData 的 done 回调中调用;换成 url 模式后,要在 table.render 的 done 里补上:
1 | done: function(res, curr, count) { |
soulTable.export 和表头过滤都依赖这次注册。不调用的话,export 内部 deepClone(i.cols) 直接崩——导出按钮看着正常,点了没反应;表头过滤图标也不会初始化。
表头过滤:filterSos 协议与后端解析
soulTable 表头过滤(filter: true 的列)在后端分页模式下会把条件序列化成 filterSos 参数(JSON 字符串数组)随请求一起发:
1 | [ |
模式(mode)全集与后端语义
| mode | 来源 | 后端处理 |
|---|---|---|
in |
表头”数据”下拉多选 | col IN (%s, ...),values 列表 |
condition |
条件筛选(文本/数字) | type ∈ eq/ne/gt/ge/lt/le/contain/notContain/start/end/null/notNull,映射 = / <> / > / >= / < / <= / LIKE %v% / LIKE v% / LIKE %v / IS NULL / IS NOT NULL |
group |
“编辑筛选条件”生成的条件组 | 递归解析 children(可嵌套),组内按 prefix(and/or)拼接,整体括号包裹 |
date |
日期筛选 | type: "all" 忽略;specific(value 为具体日期)→ 区间 col >= %s AND col < DATE_ADD(%s, INTERVAL 1 DAY)(必须用区间而非等值,否则 datetime 列只会命中当天 00:00:00 一条) |
安全方面:字段名一律走白名单映射(前端列名 → SQL 列),值全部参数化 %s。过滤参数是天然的 SQL 注入入口,不能直接拼。
已知边界:条件组里嵌套聚合列(GROUP BY 报表的 COUNT 列)不支持,会忽略并记日志。聚合列条件应该进 HAVING,组内条件的归属解析太麻烦,数据量不大的场景先砍了。
最大的坑:列值字典请求(硬编码 POST)
filter items 里的 'data'(表头下拉选值)在后端分页模式下有个初始化自动行为:表格渲染完,soulTable 会往表格 URL 发一次请求,带 columns 参数(filter 列清单),拉取各列的可选值列表用来渲染下拉选项。
源码确认过(tableFilter.js):
1 | E.ajax({url: p, data: e, dataType: "json", method: "post", ...}) // method 硬编码 "post" |
请求方式是写死的 method:"post",和表格配置的 method 无关;参数放在 form body(columns=...&width=...)。这是 soulTable 官方后端过滤模式的设计,官方 Java 支持库 layui-soul-table-java 同样要处理这个请求。
踩坑 1:前端转 GET 走不通
一开始不想让后端多一个 POST 路由,想用 $.ajaxPrefilter 把 POST 转成 GET。两个致命细节:
- jQuery prefilter 阶段
options.method还没合并进options.type——jQuery 在 prefilter 之后才执行s.type = s.method || s.type。所以判断options.type === 'POST'永远是 false(type 还是默认的 GET),转换从未生效。 - 就算同时检查 method,soulTable 的 data 可能是字符串(查询串),
$.param(字符串)会按字符索引序列化成0=c&1=o&2=l&3=u...这种灾难结果。
结论:不要在前端拦截转换,后端直接支持 POST。
踩坑 2:FastAPI 同步端点完全可以接收 Form
项目有”无 async”的硬约定,最初以为读请求 body 需要 async 端点,才走了前端转换的弯路。实际上 FastAPI 的 Form 参数在同步 def 端点上完全可用——body 解析由框架在调用函数前完成,不需要 await request.form():
1 | from fastapi import Form |
GET 和 POST 路由同路径共存(router.get + router.post),共享同一个处理函数,完全合法。
踩坑 3:列值字典响应不能套统一包装
后端统一响应格式是 {code, msg, count, data},但列值字典必须返回裸 JSON:
1 | {"客户名称": ["客户A", "客户B"], "商品型号": ["T-100", "T-200"]} |
soulTable 的 success 回调会遍历响应对象的每一个键来渲染下拉选项——{code: 0, ...} 会被当成列名处理,下拉列表直接错乱。
踩坑 4:MySQL 二进制字符集列返回 bytearray
utf8_bin / gb2312 字符集列在 mysql.connector 下返回 bytearray,jsonable_encoder 序列化会 500(dict(obj) 失败)。列值字典要显式转换:
1 | if isinstance(val, bytearray): |
踩坑 5:测试直接调用端点的 Query 默认值陷阱
测试如果用”直接调用端点函数”的方式(不经 FastAPI 路由,比如单元测试基类手工构造 request/auth 参数),columns: str = Query(None) 的默认值是 Query 对象本身(truthy)——if columns: 误判进入列值字典分支,返回空 dict。测试必须显式传 columns=None(生产环境由 FastAPI 解析默认值,没有这个问题)。
GROUP BY 报表的分页细节
“分组统计”报表是 GROUP BY 客户ID, 商品ID,分页有两个特殊点:
- 聚合列过滤进 HAVING:
filterSos对”数量”列的条件(比如ge 10)不能放 WHERE——聚合列在 WHERE 里是 SQL 语法错误,要解析到HAVING COUNT(t.id) >= %s。实现上把过滤条件按字段归属拆成 WHERE 和 HAVING 两组。 - 分组数 COUNT 要包派生表:
SELECT COUNT(*) FROM t GROUP BY x返回的是每组的计数(每行一个 1),不是分组总数。正确写法:
1 | SELECT COUNT(*) FROM ( |
- 列值字典对聚合列返回空数组(DISTINCT COUNT 表达式没有意义)。
性能:翻页慢的元凶是 COUNT
改完分页后翻页还是有明显等待感。查了一圈,问题出在 COUNT 查询——主表约 10 万行,3 表 JOIN + 无索引状态过滤全扫,每次翻页都要重算一遍。三个优化,按收益排序:
- 数据库加索引(
ALTER TABLE orders ADD INDEX 日期列 (日期列))——日期范围过滤从全表扫变成只扫范围内的行,收益最大 - COUNT 短缓存(5 秒,key 含过滤条件指纹)——翻页连点时 COUNT 只算一次,条件变化自动失效
- COUNT 去掉不必要的 JOIN——COUNT 不选关联表列,没有对应列的过滤时去掉该 JOIN
另外,soulTable.export 默认导出的是表格实例当前持有的数据——后端分页后实例里只有当前页,导出全量要在导出事件里重新拉全量(不传 page/limit 参数),再 soulTable.export(id, {data: 全量})(soulTable 支持 data 选项覆盖导出数据),不能直接导实例数据。
检查清单
-
table.render配url + page: true,done回调里必须soulTable.render(this) - 后端解析
filterSos(in / condition / group 递归 / date 区间),字段白名单 + 参数化防注入 - 后端接收排序参数
sort / order(白名单映射) - 列值字典请求是硬编码 POST + form body——后端加 POST 路由(
Form参数),返回裸 JSON{列名: [值]} - 聚合列过滤进 HAVING;分组总数 COUNT 包派生表
- 列值 bytearray 转字符串;测试直接调用端点时显式传 None
- COUNT 是分页性能核心——索引 + 缓存 + 去多余 JOIN
- 导出全量需重新拉取 +
soulTable.export(id, {data})
改完以后,明细页首屏从全量拉取变成 20 条一页,翻页、排序、过滤、下拉选值、导出全部在数据库层完成,基本感觉不到卡了。
参考:layui-soul-table 官方文档(https://saodiyang.gitee.io/layui-soul-table)、`tableFilter.js` 源码(node_modules 或插件目录内)
- 标题: soulTable 后端分页 + 表头过滤
- 作者: IsayIsee
- 创建于 : 2026-08-26 10:36:13
- 更新于 : 2026-08-26 10:47:47
- 链接: https://blog.120528.xyz/2026/08/26/651d94a0/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。