接了个中后台项目,列表页要支持点击表头排序,我第一反应就是用 jEasyUI 的 DataGrid 把列的 sortable 打开,前后端配合一下就能跑起来。jEasyUI 这里说的其实是 jQuery EasyUI 这套组件库,在传统管理系统里出镜率很高,尤其是那些要快速搭后台、又不愿意在前端工程化上投入太多时间的场景。它的排序功能看着简单,真正用起来还是有不少细节讲究,比如本地排序和远程排序怎么选、sortName 和 sortable 之间什么关系、数字列为什么经常排成 1、10、2。这篇就按我实际做过的项目来拆一拆,把 jEasyUI 设置排序的完整玩法、参数逻辑和踩坑点都理清楚。
1. 动手之前,先搞清楚 jEasyUI 排序到底在排什么
很多人一上来就在 columns 里加sortable: true,结果发现表头确实能点,数据却纹丝不动。这不是 bug,而是没理解 jEasyUI 排序的机制。DataGrid 的排序本质上是一个状态管理过程:点击表头,组件内部更新 sortName 和 sortOrder 这两个值,然后要么走本地排序,要么重新请求远程接口。所以设置排序不是一个属性的事,而是一整套配合流程。
1.1 本地排序还是远程排序,这是第一个选择题
本地排序的意思是,数据一次性加载到前端后,表头点击时直接在浏览器里对已加载的数据做排序,不需要再请求后端。这种模式适合数据量小的场景,比如配置表、字典表、单页报表,撑死几百行到一千行。jEasyUI 默认的排序模式就是本地排序,它对当前页的数据数组排序,好处是响应快、不依赖网络、实现成本最低。缺点是只排当前加载的数据,如果你用了分页而且每页 20 条,那本地排序只是把这 20 条排一下,整个数据集的排序并不生效,用户会看到每翻一页排序都是“乱的”,这就是本地排序和分页天然冲突的地方。
远程排序则是把排序参数提交给后端,由数据库或服务端处理后再返回完整排序结果。DataGrid 在加载数据时会把sort和order两个参数自动带到请求里,服务端根据这两个参数拼接 ORDER BY 语句,查询后再把数据返回,整页刷新。远程排序适用于数据量大、需要配合后端分页的场景,也是我在实际项目中用得最多的模式,因为后台管理系统的列表几乎都是上万条起步。
我见过很多刚接触 jEasyUI 的人,把url指向接口后,分页、排序都开了,结果发现排序只对当页生效。原因就是没有设置remoteSort: true。所以选型时要提前判断:如果列表有分页,且总数据量预期超过一页能承载的量,就一定要走远程排序,开头把这个定了,后面能省很多事。
1.2 点击一次表头,背后发生了什么
理解排序机制最好的方式,是把“点击表头”拆成几步来看。第一步,jEasyUI 检测到用户点击了某个设置了sortable: true的列,这个点击的目标列对应一个 field 名。第二步,组件内部判断当前排序状态,如果此前没有排序列,就设置 sortName 为当前列 field,sortOrder 默认为 ascending 或你在初始化时定义的值;如果此前已经按这一列排序了,就切换方向,asc 变 desc,desc 变 asc;如果配置了sortStable,点击第三下会恢复到无排序状态。第三步,判断remoteSort的状态:为 true 时,重组查询参数,触发 load 或 reload,请求带上 sort、order 参数;为 false 时,调用本地排序逻辑,按指定字段对 rows 重新排序。第四步,重新渲染表格,把排序状态的箭头或样式显示在对应表头上。
这四步看下来,你会发现排序方向是自动切换的,不需要自己维护“上次点了哪列”。但自动切换也有坑:jEasyUI 内部只认 field,不认列的显示文本,所以如果你在多个列上用了相同的 field,排序逻辑就会“串台”,点哪一列都是排同一个字段。另一个坑是,某些情况下初始化时就设置了sortName,比如sortName: 'createTime',那么表格首次加载就会按这个条件排序,然后用户点击其他列时,sortName 会被新点击的列覆盖。这意味着如果你希望默认按创建时间倒序,但又允许用户切到按名称排序,初始化时设定 sortName 是正确姿势,但也要确保后端接口支持这组默认参数的接收。
2. 核心参数逐个拆解:sortable、sortName、sortOrder、remoteSort
jEasyUI 排序相关的配置项其实就这几个,但它们组合起来能玩出的效果和踩出来的坑一点也不少。我先把四个核心参数拆开讲清楚,再补几个平时容易忽略的相关配置。
2.1 四个参数各自的职责和配合关系
先说属性层面的配置,整理成一张表来看最直观:
| 参数 | 配置位置 | 作用 | 关键点 |
|---|---|---|---|
| sortable | columns 列配置 | 控制该列是否允许点击表头排序 | 必须配合 field 使用,没有 field 的列即使 true 也没用 |
| sortName | datagrid 初始化参数 | 默认排序列的 field 名 | 首次加载就按这个字段排序,优先级高于用户点击前的默认状态 |
| sortOrder | datagrid 初始化参数 | 默认排序方向,asc 或 desc | 与 sortName 成对出现 |
| remoteSort | datagrid 初始化参数 | 指定排序发生在远程还是本地 | true 走后端接口,false 在本地排序,默认 false |
sortName 和 sortOrder 是初始化层面的配置,它们和 columns 里的 sortable 不是一回事。sortName 的值为字段名,对应列配置中的 field;sortOrder 决定首次加载是升序还是降序。举个例子,你的表有个“创建时间”字段,列配置里 field 写的 createTime,sortable 开了 true,那么初始化时写sortName: 'createTime', sortOrder: 'desc',页面第一次加载出来的数据就是按创建时间倒序的,表头上也会带一个下降箭头。这样做的实际意义是:排序状态和接口加载是同一个时机,而不是表格加载完后再触发一次额外排序,避免了首屏出现“先看一遍正序,再自动切到倒序”的闪烁。
remoteSort 则更像一个全局开关。它决定排序走本地还是远程,并且这个配置不能和分页的本地模式混着用。曾经我接手过一个项目,前端开了 remoteSort: true,接口也接了 sort 和 order 参数,但分页用的是本地分页,也就是说接口把所有数据一次性返回来,前端只切页显示。这时候 remoteSort 配合本地分页是矛盾的:每次点击表头都重新请求接口,但分页只是在本地切,两套逻辑互相冲突,表现为排序偶尔正常偶尔失效。后来统一成接口分页加远程排序,问题才消失。如果你不想改接口,就要把 remoteSort 关掉,让本地排序和本地分页搭配起来。
2.2 关于 sortable 列的两个隐藏约束
sortable 这个列属性看起来只是开个开关,实际还有两个隐藏约束。第一个约束是列必须设置 field,没 field 的列,比如那种“操作”列、复选框列,jEasyUI 根本不知道点击后要排哪个字段,点上去毫无反应。很多项目里的“序号”列、纯展示列都没有 field,这种天然就不能排序,不算故障。第二个约束是如果列设置了 formatter 格式化显示,排序时排的是原始数据的字段值,而不是格式化后的显示文本。这反而是一件好事,因为 formatter 常用来做状态标签、日期格式化,如果排序排的是文本,状态和时间全按字符串比了,结果肯定乱套。所以 jEasyUI 排序动作发生在 formatter 之前的较底层,拿到的始终是字段原始值。
我碰到过一个比较难受的情况是:某列表里的“金额”字段为了显示好看,在 formatter 里加了千分位逗号,比如 1,234.56。列配置里 executable 是没问题,但如果你把这个列设置成本地排序,jEasyUI 拿到的排序值其实是字符串 "1,234.56" 而不是 1234.56,于是排序结果就是 1000.00、1,234.56、900.00 这种诡异顺序。查了半天才发现是 formatter 输出的文本参与排序了。这其实不怪 formatter,要怪列配置里的排序字段名写错了,或者 local 模式下 formatter 影响了排序值。总之记住一条经验:排序列不要为了展示方便和排序字段混用一个字段,如果非要混用,把排序逻辑放在后端或自定义 sorter 里处理。
2.3 多列排序和单列排序,边界在哪
jEasyUI 官方虽然支持多列排序,但那是通过几次点击操作叠加上去的,不是常见的多关键字排序。我理解很多用户其实想要的是“先按类型排,再按时间排”这样的二级排序,jEasyUI 原生并没有把这个能力直接暴露出来。它一次只维护一个 sortName 和 sortOrder,新点击的列会覆盖之前的排序状态。
如果你的业务确实需要多关键字排序,常见的做法有两种。第一种是在后端接口里自行处理,前端只传一个主排字段,服务端在 ORDER BY 里写死次要排序规则,例如ORDER BY ${sort} ${order}, create_time DESC,这是最简单也不影响用户体验的做法。第二种是自定义工具栏,做一个“组合排序”弹窗,用户选择第一关键字段、第二关键字段,然后前端拼一个复合排序字符串,比如type asc, createTime desc,后端解析这个字符串来拼 SQL。这个方法灵活度最高,但工作量也大,适合数据仓库或报表类的后台。至于 DataGrid 的 sortable 点击,就当它是单列排序的入口,不要强行扩展为多列机制。
// 多关键字复合排序参数示例(配合后端解析) let multiSort = 'type asc, createTime desc'; $('#dg').datagrid('load', { multiSort: multiSort });3. 实操:从零到一搭一套能用的排序列表
前面原理讲了一堆,现在上真操作。我会用一套前后端配合的完整示例,从页面的 HTML 初始化开始,到后端接口接收参数,再到自定义排序规则,把整个链路串起来。这套流程我在不同项目里反复用过,照抄基本都能跑。
3.1 基础初始化:一个带默认排序的本地排序示例
如果你只是做个内部小工具、数据量不过百,本地排序最快。HTML 部分定义一个表格容器,然后用 jQuery 初始化 DataGrid:
<table id="dg"></table>$('#dg').datagrid({ url: 'api/list-data', // 如果不走接口,也可以不写 url,直接用 data 属性 method: 'get', remoteSort: false, // 明确走本地排序 pagination: false, rownumbers: true, singleSelect: true, columns: [[ { field: 'id', title: 'ID', width: 60, sortable: true }, { field: 'name', title: '名称', width: 120, sortable: true }, { field: 'price', title: '价格', width: 100, sortable: true, align: 'right' } ]], sortName: 'id', sortOrder: 'desc' });这里remoteSort: false能写就写上,不然默认 false 虽然也一样,但显式写出来,后来的人一看就知道这页是前端排序。sortName 配了 id,sortOrder 配了 desc,页面加载后 ID 就会倒序展示。用户再点“名称”列,表格会在本地把 name 字段做一次字符串排序,点第二次就反向,整体体验很顺。
如果你完全不想走后端接口,数据是写死的,那就别用 url,直接在初始化里指定 data 属性:
$('#dg').datagrid({ data: staticRows, remoteSort: false, columns: [[ { field: 'name', title: '名称', sortable: true } ]] });本地排序时要特别注意:排序范围只是当前 datagrid 的 rows 数组。如果你不启用分页,那自然所有数据都能排到;一旦启用分页,本地排序就只对当前页生效。这也是我刚才反复强调的,小数据量可以直接本地,大数据量直接上远程,别折中。
3.2 切换远程排序:后端怎么接住参数
数据量大、有分页、要全员排序的项目,远程排序是标配。做法不复杂,前端先把remoteSort: true打开,然后正常情况下,DataGrid 会在 load 时自动发送sort和order参数到 url 对应的接口。如果你用的是默认请求方式,接口里直接取这两个参数拼 SQL 就行。
后端接口我用一个简单的 PHP 示例说明,其他语言思路都一样,无非是把请求参数映射进 SQL:
$sort = isset($_GET['sort']) ? $_GET['sort'] : 'id'; $order = isset($_GET['order']) ? $_GET['order'] : 'desc'; // 白名单校验,防止恶意传字段名 $allowSortField = ['id', 'name', 'price', 'create_time']; if (!in_array($sort, $allowSortField)) { $sort = 'id'; } $order = strtoupper($order) === 'ASC' ? 'ASC' : 'DESC'; $sql = "SELECT * FROM product ORDER BY {$sort} {$order} LIMIT {$offset}, {$pageSize}";需要注意两个安全细节。第一,千万别直接拼用户传进来的 sort 字段,否则就是 SQL 注入。白名单校验是底线,只允许代码里预设的字段名通过,其他一律回退到默认值。第二,order 参数也要做同样的处理,只接受 asc 和 desc 两个值,其余全按 desc 处理。网上有很多现成框架,比如 Spring Boot 的 PageHelper、PHP 的 ThinkPHP,都有排序参数的安全封装,但道理是一样的。
有个容易忽略的点:DataGrid 翻页时也会把 sort 和 order 一起带上。你可能没主动设置分页参数,但 DataGrid 的默认分页参数是 page 和 rows,也就是页码和每页行数。后端分页查询一般用这两个参数算 offset 和 limit,再把总条数 total 和当前页数据 rows 返回给前端。只要 total 和 rows 是 jEasyUI 认识的格式,翻页和排序就能无缝联动。
{ "total": 256, "rows": [ { "id": 1, "name": "苹果", "price": 3.5 } ] }这种数据格式是 jEasyUI 的默认约定。如果你后端返回的数组直接是一个 list,没有 total 字段,翻页控件就不显示总页数,排序也可能出问题。最简单的处理是把 total 设为 list 的长度,虽然分页总数不准,但至少接口格式能让 DataGrid 正常工作。
3.3 自定义 sorter:字符串和版本号的高级玩法
jEasyUI 的列配置里还有一个sorter属性,它允许你传入一个自定义排序函数。这个 sorter 在本地排序时会被调用,格式是function(a, b),a、b 是两个字段原始值,返回值小于 0 表示 a 在前,大于 0 表示 b 在前,等于 0 则保持不变。sortable 没它也能排,但处理特殊数据格式就有它没它两回事了。
最常见的场景是版本号排序,比如 1.2.10、1.2.9 这种字符串,直接按字典序排会得到 1.2.10 排在 1.2.9 前面,因为字符串比较是从左到右逐字符比较,所以 "1.2.10" 的第四段 "10" 是 "1" 开头,而 "1.2.9" 第四段是 "9",字典序里字符 "1" 小于 "9",结果完全不对。解决办法是把版本号拆成数字数组再逐个比较:
function versionSorter(a, b) { const pa = String(a).split('.').map(num => parseInt(num, 10)); const pb = String(b).split('.').map(num => parseInt(num, 10)); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const na = pa[i] || 0; const nb = pb[i] || 0; if (na !== nb) return na - nb; } return 0; } $('#dg').datagrid({ columns: [[ { field: 'version', title: '版本号', sortable: true, sorter: versionSorter } ]] });另一个常见场景是混合中文和数字的排序,比如“第1章”“第2章”“第10章”。默认字符串排序会把第10章排在第2章前面。这种可以用一个简单的自然排序函数,把字符串里的连续数字提取出来参与比较,网上有很多 natCompare 的实现,也可以直接在版本号的 sorter 思路上扩展。
自定义 sorter 还有一个“隐藏功能”:你可以在本地排序模式下模拟远程排序行为。这叫“假远程排序”,本质是借用本地 sortable 的表头点击事件,但实际不依赖本地数组排序,而是自己拦截 onSortColumn 事件然后调接口。但如果接口都写了,你还不如直接 remoteSort: true 来得干净。所以我的建议是:能用 remoteSort 就用远程,sorter 只留给那些确实无法在后端处理、数据量又小的场景。
3.4 默认排序和点击记忆的联动问题
很多时候,用户希望“上次选了哪列排序,下次进来还记住”。这个需求其实要打通两个环节:页面加载时设置 sortName 和 sortOrder,然后在每次排序变化时把这两个值存下来。存哪呢?localStorage 最方便,key 可以按页面路径加前缀区分。
function loadSortPref(pageKey) { const pref = localStorage.getItem('sortPref_' + pageKey); return pref ? JSON.parse(pref) : null; } function saveSortPref(pageKey, sortName, sortOrder) { localStorage.setItem('sortPref_' + pageKey, JSON.stringify({ sortName: sortName, sortOrder: sortOrder })); }初始化时读出偏好,赋给 sortName 和 sortOrder:
let pref = loadSortPref('product_list') || { sortName: 'createTime', sortOrder: 'desc' }; $('#dg').datagrid({ url: 'api/product/list', remoteSort: true, sortName: pref.sortName, sortOrder: pref.sortOrder, onSortColumn: function(sort, order) { saveSortPref('product_list', sort, order); } });这个方案写一次,后面所有列表页都能复用。要注意的是,onSortColumn 在初始化加载时也会触发一次,所以第一次进页面可能马上写入一次偏好,不影响逻辑,但如果你需要严格区分“用户主动点击”和“初始化加载”,可以在 onSortColumn 里加一个标志变量判断。真实项目里个人觉得不必那么严格,因为写入相同的值没有副作用。
4. 排序实战中的五大问题,逐个排查给你看
排序功能跑起来不难,但实际项目里总会冒出各种莫名其妙的现象。我把这些年碰到的高频问题列出来,每个都配上原因和排查方向,基本覆盖了 jEasyUI 排序的绝大多数坑。
4.1 表头点击没反应,到底是哪一步断了
现象是列标题配置了 sortable: true,但点击表头完全没有排序变化,也没有新的请求发出。排查时优先看三点:列有没有设置 field,列是不是被当成 frozen 列但漏配,以及页面有没有报 JS 错误。frozen 列是指固定列,它虽然出现在左侧区域,但数据绑定机制和普通列没有本质区别,frozen 列同样可以设置 sortable,但如果列在 frozen true 列组里且字段值不在原始 rows 数据中,排序自然失效。我遇到最离谱的情境是页面引用了两个版本的 jQuery 或者多个 EasyUI 脚本,组件初始化就报错了,点表头自然没反应,打开浏览器控制台能看到 ReferenceError 或类似错误。这种问题最容易被忽略,建议任何表格类问题都先开控制台看一眼。
另外还有一种情况是同一页面初始化了多次 datagrid,比如弹窗里的表格和页面表格共用了一个 id 选择器,初始化时选中了已存在的那个。排查办法是打印一下$('#dg').datagrid('options')看当前 options 里的字段对不对,一目了然。
4.2 数字列排序变成 1、10、2 的字符串序
这个问题出现的频率极高,几乎每个用自动排序的人都遇到过。现象是“价格”“年龄”“数量”这种数值列,点击排序后 10 排在 2 前面。原因在于本地排序默认按 JavaScript 的字符串比较规则处理,数字被转成字符串,字典序下 "10" 小于 "2" 是正常现象。
解决方案有几种。如果走远程排序,让后端用数据库数值字段排序,就没有这个问题,因为数据库列类型是数值类型,数据库知道按大小比。如果是本地排序,用数字类型的字段值,比如确保 rows 数据里的 price 是 number 而不是 string。如果你从接口拿到的 price 本身就是字符串,在前端做一次转型,比如在 load 成功回调里parseFloat,或者在 column 上配一个自定义 sorter:
{ field: 'price', title: '价格', sortable: true, sorter: function(a, b) { return parseFloat(a) - parseFloat(b); } }这样写之后排序就按数值走了。之所以强调“转型”而不是“直接减”,是因为有些数据里塞了单位或逗号,比如 “1,200 元”,parseFloat 会直接变成 NaN。这种时候要先把非数字字符去掉,再转数值:
function numberSorter(a, b) { const cleanA = parseFloat(String(a).replace(/[^\d.-]/g, '')); const cleanB = parseFloat(String(b).replace(/[^\d.-]/g, '')); return cleanA - cleanB; }4.3 日期列排序不对,看起来是按字符串排的
日期列排序不对的根因和数字列类似:如果日期字段以字符串形式参与排序,比如 “2024-01-02” 和 “2024-1-2”,字符串比较结果就不稳定;如果格式是 “2024/01/02” 和 “2024-01-02” 混着来,更是直接乱掉。解决办法是统一日期格式,在前端处理时把所有日期字段转成时间戳再进行本地排序,或者直接让后端的日期字段按时间类型排序。
我习惯的做法是后端接口把日期 ISO 格式统一返回,比如2024-01-02 10:00:00,前端排序时用Date.parse把字符串转时间戳:
function dateSorter(a, b) { return Date.parse(String(a)) - Date.parse(String(b)); }这套方案在主流浏览器里都有效,但要注意时区问题:如果是2024-01-02T10:00:00Z这种带 Z 后缀的 ISO 字符串,Date.parse 会按 UTC 处理,而显示时又按本地时区,排序结果不会错,没必要太纠结。更坑的是 Excel 导出的数据里有 “2024/1/2 上午10:00” 这种本地化格式,Date.parse 在各浏览器表现不一致,还是提前和后端约定好统一格式,一劳永逸。
4.4 开启了 remoteSort 但自定义 sorter 完全没执行
jEasyUI 文档对 sorter 的说明主要针对本地排序,当remoteSort: true时,点击表头实际上会触发一次远程加载,排序交给后端,前端 sorter 函数不会被调用。如果你在后端已经写了排序逻辑,这不影响什么;但如果你误以为 sorter 会在远程模式下作用于提交给后端的参数,或者对返回数据再次排序,就会出现“我定了 sorter 但一点用都没有”的困惑。
这种场景下正确的做法是,把数据排序逻辑挪到后端。如果后端是别人写的、不好改,前端还想保留自定义排序,就只能在 onSortColumn 里拦截排序事件,手动调整参数或本地排序,加一个分支把 remoteSort 临时置为 false。这种做法比较 hack,不是长久之计,但确实能应付一些“接口暂时不改成”的过渡阶段。
更常见的是另一个变体:remoteSort: true 时,我用 onSortColumn 想在提交前修改 sort 参数,但修改无效。原因是 DataGrid 内部用参数对象保存了 sort 和 order,onSortColumn 触发的时机是排序状态已更新之后,你改 options 里的 sortName 已经来不及直接作用于本次请求。要在请求前改参数,应该用 queryParams 配置项或者重写 loader,而不是依赖 onSortColumn 的返回值。
$('#dg').datagrid({ remoteSort: true, queryParams: { // 固定附加参数,每次请求都会带上 extraParam: 'xxx' }, onSortColumn: function(sort, order) { // 这里 sort 和 order 已确定,但如果你想改本次请求参数: const opts = $('#dg').datagrid('options'); $('#dg').datagrid('load', $.extend({}, opts.queryParams, { sort: sort, order: order, mySort: '自定义' + sort })); } });这属于“手动与自动并存”的过渡方案。我自己的习惯是能改后端就改后端,前端拦截越少越好,否则以后维护的人看代码会疯掉。
4.5 排序参数字段名与后端字段名不一致
这也是个非常隐蔽的问题。比如前端列配置的 field 是createTime(驼峰),数据库字段是create_time(下划线)。前端点击表头时,jEasyUI 发送的sort=createTime,后端如果直接拿这个值拼 SQL,数据库会报字段不存在,或者由于白名单校验被拦截回退到默认排序,表现为某些列排序正常,某些列排序没变化。
解决办法是在前端 at 列配置里额外加一个field用于显示,但在onSortColumn事件里把前端 field 映射为后端字段,再重新拼接请求参数;或者在后端做字段映射表,把createTime映射成create_time。相比之下我更倾向后端做映射,因为前端涉及多语言、按钮等复杂场景时,列配置的调整会影响很多地方,而接口层做一次映射一劳永逸。
关于 jEasyUI 的排序设置,说到底是一套前后端协作的流程。本地排序适合轻量场景,远程排序适合正经管理系统,排序参数的安全和字段对齐是基本盘,自定义 sorter 是补充手段。真正影响体验的从来不是“能不能排序”,而是数据量大时的稳定性、默认排序是否合理、排序状态是否和分页联动正常。这部分我踩过的坑也最多,尤其是 remoteSort 和本地分页混用、字段名驼峰和下划线不匹配这两个问题,几乎是每个项目都会遇到。设排序前先理清数据从哪来、要排谁、后端要不要参与,jEasyUI 这套组件就能很舒服地融入你的系统。