做后台管理系统这些年,跟表格较劲的时间不少。最近手头一个课程管理模块又遇到了那个经典需求:列表里要用星星展示课程评分,同时表头能点击按评分排序。说白了就是layui的table表格里,把评分字段渲染成rate星级评分,还要带着sort排序一起工作。这个需求看着不算复杂,代码量也不大,但实际落地的时候坑不少——最典型的就是templet模板把评分渲染成了星星HTML,排序时却直接对着那串HTML字符串比较,结果完全对不上。这篇文章我把完整的实现思路拆开,从列配置、templet渲染、纯前端排序到后端联调排序都走一遍,顺便把我踩过的问题一起列出来,给准备做同类功能的朋友一个能直接参考的版本。
1. 需求场景与整体思路拆解
1.1 这类需求最常出现在哪
需要给数据做评分的后台模块,十有八九会遇到这个功能。比如课程平台里每门课的学员评价星级、商品管理后台的买家满意度、服务商家的口碑分,管理员进了列表页第一反应就是看哪些课程或商品评分高、口碑好,这时候表格里能展示星星,表头还能按评分升降序排列,基本属于标配。
另外一个容易被忽略的场景是运营侧。管理员不光是查看,还要拿着评分排序结果去做奖惩、做活动邀约、做内容推荐。所以这不只是前端“显示一下”就完了,排序结果最好能跟后端数据联动,保证在分页情况下排的是全量数据而不是某一页。
我最早接到类似需求是在一个内部培训系统的课程管理页,当时我不假思索就在前端做了个纯排序,结果翻到第二页发现排序乱了,原因是表格开了分页,layui的前端排序默认只排当前页数据。所以后来我做这个功能,第一步永远是问自己:这批数据是一次性拉全,还是接口分页返回?
1.2 先确定前端排序还是后端排序
这个选择直接决定代码怎么写。
前端排序:数据一次性加载到表格里,没有配置url,直接在列上写sort: true,点击表头layui内部自动排。优点就是简单、不增加请求。缺点是只能处理当前已加载的数据。如果后台列表不分页,数据量也就几十上百条,用这个完全够。
后端排序:表格接接口数据,配置了url,分页开启。点击表头时需要向服务端发请求,由后端按排序字段重新查询返回。这样排的是全量数据,翻页后状态依然正确。缺点是要多写一点代码:监听sort事件、reload表格、传排序参数,后端还要接收参数。
我的处理惯例是:只要表格配置了url且page为true,无脑走后端排序。哪怕当前数据量不大,后续量上来不用返工。如果你的列表是本地一次性渲染的静态数据,那就走前端排序。
1.3 为什么用layui的rate星星而不是自定义图案
评分展示的视觉方案其实不少,可以画进度条、写数字、用自定义图标。但星星是用户最容易理解的形式,五星制评分已经是几乎所有电商平台、课程平台培养出来的用户心智。layui生态里刚好有rate组件,它的星星用的是字体图标,跟表格里其他图标的风格一致,不需要我再额外引入图片或SVG。
需要注意,rate在页面里是一个完整的动态组件,默认需要初始化实例。但表格里每一行的星星如果都要初始化组件,成本高还容易出问题。实际中我通常的做法是:不是在templet里实例化rate组件,而是直接把星标的HTML结构拼出来。因为表格里大多数场景只需要展示,不需要用户点击星星打分,拼HTML的方式简单可控,也不牵扯表格重载导致的组件重复初始化问题。
2. 核心配置解析:sort跟templet的联动关系
2.1 列配置里的关键参数逐个说
表格里评分列的核心配置长这样:
{ field: 'rate', // 绑定的数据字段 title: '评分', // 表头显示名称 width: 180, // 列宽 align: 'center', // 对齐方式,星星在列里居中才好看 sort: true, // 开启排序 templet: function(d) { return renderRate(d.rate); // 把数值渲染成星星HTML } }这几个配置各管一件事:
field指定了这列对应数据对象里的哪个属性,排序时layui拿的就是这个属性的值。sort: true让表头出现可点击排序的箭头状态。没有这一项,表头点了没反应。templet是一个模板渲染函数,返回的HTML会替换掉这一列默认显示的纯文本。它只影响“显示效果”,不影响底层数据结构。align: 'center'对星星列挺重要。如果默认左对齐,五颗星星挤在左边,后面空一截,视觉上很别扭。居中后跟分数数字组合展示更舒服。
这里有一个非常关键的认知:templet改变的是这一列展示出来的内容,但sort排序比较的是原始字段值,不会拿templet返回的HTML字符串去排序。所以我上面代码里即使templet把4.5分渲染成一串很长的星星HTML,点击排序时layui实际比较的还是d.rate这个4.5。这一点一开始没搞清楚的话,会出现很多诡异的“看起来没生效”的现象。
2.2 渲染星星的三种写法
写法一:函数拼接HTML(我最常用)
function renderRate(rate) { rate = Number(rate) || 0; rate = Math.max(0, Math.min(5, rate)); // 限制在0到5之间 var fullStars = Math.floor(rate); var hasHalf = rate - fullStars >= 0.5; var html = ''; for (var i = 1; i <= 5; i++) { if (i <= fullStars) { html += '<i class="layui-icon layui-icon-rate-solid" style="color:#ffb800;"></i>'; } else if (hasHalf && i === fullStars + 1) { html += '<i class="layui-icon layui-icon-rate-half" style="color:#ffb800;"></i>'; } else { html += '<i class="layui-icon layui-icon-rate"></i>'; } } return html; }这个函数里做了三件小事:把字符串数字转成数值,把越界的评分限制在0到5之间,根据数值算出满星数和是否需要半星。我在多个项目里都是这套逻辑,简单、不依赖版本。
写法二:模板字符串
有的项目里大家喜欢用模板字符串写templet,比如templet: '<div>{{= d.rate }}</div>'。但对于评分列,我不推荐这种写法,因为评分值需要先处理成星星,模板字符串做这种逻辑转换不够直观,一旦加半星、边界判断,模板里会堆一堆表达式,不好维护。
写法三:表格渲染完成后再初始化rate组件
如果你确实需要用户直接在表格里点击星星打分,可以这样:templet里先输出一个空的div占位,然后在table的done回调里循环初始化rate组件。
done: function(res) { // 遍历表格的每一行,找到评分列对应元素,初始化rate组件 }这种方式能做出来,但我个人不太建议在表格里直接做打分交互。表格行密度高,星星点击区域小,误操作率高,而且一旦表格reload或者翻页,所有rate组件都需要重新初始化,处理不好还会出现重复实例。真需要打分功能,我都会用弹层打开一个独立的表单页面,在弹层里用原生rate组件,体验更好,代码也更干净。
2.3 理解sort的底层比较规则
layui表格的中文文档里对排序讲得不算细,我实际测试下来的结论是:前端排序时,layui会把field字段对应的值取出来,按照JavaScript默认的比较方式排。数值类型直接按大小处理,字符串类型按Unicode编码逐个字符比较。
这个机制有个经典坑:接口返回的rate是字符串“4.5”时,排序看起来正常,但如果出现两位数的评分,比如某个数据是“10”,那字符串排序会把“10”排在“4”前面,因为“1”的编码比“4”小。如果rate固定是0到5之间的评分,一位数居多,字符串排序勉强能看,但我见过有人把“100分制”的分数也这样排,结果整个排序乱掉。
所以我的强烈建议是:在数据源头就把rate处理成数字。后端接口返回时就确保字段值是数值类型;如果是前端mock数据,也注意别加引号。这样前端排序不需要任何额外处理。
如果因为接口限制拿到的就是字符串,那就在sortMethod里做转换。layui的列配置支持自定义排序方法:
{ field: 'rate', sort: true, sortMethod: function(a, b, type) { var diff = Number(a) - Number(b); return type === 'asc' ? diff : -diff; } }这样点击表头时,会自动用这个函数里的规则替代默认的字符串比较,把值先转成数字再排。
3. 实操过程完整实现
3.1 初始化表格和模拟数据
我统一用layui 2.8.x的写法来演示。第一步先把表格基本结构和数据准备好。
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>表格星级排序示例</title> <link rel="stylesheet" href="https://unpkg.com/layui@2.8.0/dist/css/layui.css"> </head> <body> <div style="padding: 20px;"> <table id="courseTable" lay-filter="courseTable"></table> </div> <script src="https://unpkg.com/layui@2.8.0/dist/layui.js"></script> <script> layui.use(['table', 'jquery'], function() { var table = layui.table; var $ = layui.jquery; // mock数据,注意rate是数字类型 var dataList = [ {id: 1, courseName: '前端工程化实战', rate: 4.5, students: 120}, {id: 2, courseName: 'UI设计系统入门', rate: 3.0, students: 90}, {id: 3, courseName: '数据结构与算法', rate: 5.0, students: 150}, {id: 4, courseName: '数据库设计与优化', rate: 4.0, students: 80}, {id: 5, courseName: '操作系统原理', rate: 3.5, students: 200} ]; table.render({ elem: '#courseTable', id: 'courseTable', data: dataList, cols: [[ {field: 'id', title: 'ID', width: 80, align: 'center'}, {field: 'courseName', title: '课程名称', minWidth: 200}, {field: 'rate', title: '评分', width: 200, sort: true, align: 'center', templet: function(d) { return renderRate(d.rate); }}, {field: 'students', title: '学习人数', width: 120, sort: true, align: 'center'} ]] }); }); function renderRate(rate) { rate = Number(rate) || 0; rate = Math.max(0, Math.min(5, rate)); var fullStars = Math.floor(rate); var hasHalf = rate - fullStars >= 0.5; var html = '<span class="rate-cell">'; for (var i = 1; i <= 5; i++) { if (i <= fullStars) { html += '<i class="layui-icon layui-icon-rate-solid" style="color:#ffb800;"></i>'; } else if (hasHalf && i === fullStars + 1) { html += '<i class="layui-icon layui-icon-rate-half" style="color:#ffb800;"></i>'; } else { html += '<i class="layui-icon layui-icon-rate"></i>'; } } html += '<span style="margin-left:6px;color:#999;font-size:12px;">' + rate.toFixed(1) + '</span>'; html += '</span>'; return html; } </script> </body> </html>这里我故意在评分后面加了一个小数文本,方便用户看清具体的分数。星星是给直觉看的,数字是给精确信息看的,两者组合是评分列比较实用的形态。
3.2 纯前端排序:一行代码都不用多写
在上面的配置里,rate列已经有sort: true,数据又是本地一次性渲染的,所以现在点击“评分”表头就已经能排序了。layui内部会自动切换asc、desc、空三种状态,显示箭头图标,并重排当前数据。
如果你想进页面就默认按评分降序排,不需要用户再点一下,可以在table.render里加一个initSort:
table.render({ elem: '#courseTable', id: 'courseTable', data: dataList, initSort: {field: 'rate', type: 'desc'}, // 默认按评分降序 cols: [[...]] });initSort里的field必须对应列配置里的field,type是asc或desc。加上之后,表格第一次渲染就会带上表头的降序箭头,数据按评分从高到低排列。这在实际项目中几乎是必配的,因为产品经理通常希望用户打开的瞬间就能看到“最高分在前”。
纯前端排序适合数据量不大的场景。实测中如果本地数据有几千条,点击排序会有轻微卡顿,但能接受。再往上走,就建议换成后端排序了。
3.3 后端排序:监听sort事件才是核心
后端排序里,sort: true依然要加,但更重要的是监听表格的sort事件,并把排序条件交给服务端。
layui.use(['table', 'jquery'], function() { var table = layui.table; var $ = layui.jquery; table.render({ elem: '#courseTable', id: 'courseTable', url: '/api/course/list', page: true, autoSort: false, // 关闭layui自动排序,由sort事件统一处理 initSort: {field: 'rate', type: 'desc'}, cols: [[ {field: 'id', title: 'ID', width: 80, align: 'center'}, {field: 'courseName', title: '课程名称', minWidth: 200}, {field: 'rate', title: '评分', width: 200, sort: true, align: 'center', templet: function(d) { return renderRate(d.rate); }}, {field: 'students', title: '学习人数', width: 120, sort: true, align: 'center'} ]] }); // 监听排序事件 table.on('sort(courseTable)', function(obj) { // obj.field: 当前排序列的字段名 // obj.type: asc / desc / null(不排序) table.reload('courseTable', { initSort: obj, // 把当前排序状态同步到表头箭头 where: { sortField: obj.field, sortOrder: obj.type }, page: {curr: 1} // 排序后回到第一页 }); }); });这段代码里我加了autoSort: false,它的作用是关闭layui在点击表头时自动发起的排序请求。如果不关,layui会自己把排序参数塞进请求里,但参数名是它的固定规则,后端未必按那个名字解析。关了之后,所有请求参数完全由where里我自定义的sortField和sortOrder控制,后端对接起来更明确。
服务端要做的很简单:接收sortField和sortOrder,把它拼到查询逻辑里。比如Java后端可能就是:
SELECT * FROM course ORDER BY rate DESC LIMIT ?, ?(这里的rate就是sortField,DESC对应sortOrder。)
注意一点:排序字段不要直接拼进SQL造成注入风险,最好用一个白名单map做映射,只允许后端预定义的字段参与排序。比如只允许rate、students、id这几个字段传进来,其他字段一律忽略。
还有分页参数。layui默认会传page和limit,后端直接用来做分页就行。加了page: {curr: 1}的意思是排序操作后,重新从第一页加载,这样用户在第五页点排序时,不会继续停留在第五页看一个重新排序后的局部结果。
3.4 把“评分+人数”综合排序做进去
真实业务里,评分排序往往不是单一维度。比如一个课程4.5分只有2个人学,另一个课程4.3分有500人学,单纯按评分排,前者永远在前面,但这显然不是管理者想看的“好课”。常规做法是引入一个综合分,比如综合分 = 评分 * 0.7 + 人数排名权重 * 0.3,算好之后按综合分排序。
我在前端实现过一种简单的版本。接口返回原始字段,表格渲染后,在done回调里给每条数据追加一个score字段,再把这个字段作为排序列:
done: function(res) { res.data.forEach(function(item) { // 综合分:评分占70%,学习人数占30%,也可以自定义规则 item.score = (Number(item.rate) * 0.7 + Number(item.students) * 0.3).toFixed(2); }); }列配置里加一列:
{field: 'score', title: '综合评分', width: 220, sort: true, align: 'center', templet: function(d) { return renderRate(d.rate) + '<span style="margin-left:8px;color:#888;">' + d.score + '</span>'; }}这样表格里展示的还是星星加数字,但排序依据已经变成了综合分。这个思路可以延伸到很多场景:评分加销量、评分加好评率、评分加更新时间等。计算综合分可以在前端做,也可以在后端做。数据量大时一定要在后端算,前端算只适合本地数据。
4. 常见问题与排查技巧实录
4.1 点击表头排序完全没反应
问题现象:表头没有箭头,或者点击后什么都不发生。
排查顺序我建议这样来:
- 列上有没有写
sort: true?漏了这句话表头根本不会进入排序状态。 - 表格是否配置了url和page?如果是,且设置了
autoSort: false,那么点击表头本身不会自动刷新数据,必须监听table.on('sort(...)')并在里面table.reload,否则点击后看起来就是“没反应”。 - 检查
lay-filter的值是否匹配。table.on('sort(courseTable)')里的courseTable必须跟表格的lay-filter="courseTable"完全一致,少一个字母都监听不上。
这三个是我遇到最多的原因,排查效率很高。
4.2 星星渲染成一堆小方块或者乱码
星星实际是字体图标,渲染异常基本都是字体文件没加载出来,或者CSS没引对。检查一下页面有没有正确引入layui.css。如果用了CDN,确认网络能访问到字体文件。另外类名容易写错,我经常手滑把layui-icon-rate-solid写成layui-icon-rate-solied,这种错不会报任何错误,就显示一个空白方块。
还有一个小细节:有些精简版layui会把不用的图标样式删掉,如果自制了精简包,确认layui-icon-rate、layui-icon-rate-solid、layui-icon-rate-half这几个类名对应的伪元素content还在。
4.3 排序结果跟页面看到的不一致
这个前面也提到过,核心原因就是数据类型。接口返回的rate如果是字符串“4.5”,前端排序按字符串比较,结果可能有偏差,尤其是出现两位数字的时候。排查方法很简单:在console里打印一两条数据,看rate类型是number还是string。
如果后端不好改,就在前端用sortMethod包一层Number转换。另一个容易被忽视的是:如果templet里对评分做了Math.floor之类的处理,但排序用的是原始分数,页面上看到的星数相同,排序却可能分先后。比如4.4分和4.2分显示的都是4颗星,但4.4会排在4.2前面。实际上这不是bug,而是排序粒度比显示粒度更细,如果你希望“显示几颗星就按几颗星排”,需要把用于排序的字段也改成取整后的值,比如在数据预处理时直接对rate取整。
4.4 分页情况下,每页内部排序了,但整体顺序没变
典型的前端排序配合后端分页导致的问题。数据只加载了几十条,前端排序只能排这几十条,翻到第二页又是另一批数据,整体看顺序是乱的。
解决办法只有一条:换成后端排序,也就是第3.3节那套逻辑。这个坑我在第一个项目就踩过,当时被测试怼了半天,后来学乖了,只要开了分页,排序一律走后端。
4.5 表格刷新后,评分列的星星不见了
这个问题一般出现在用“写法三”初始化rate组件的场景。表格reload之后,原本的rate实例还在,但对应的DOM已经被新表格替换掉,于是星星区域空白或者报错。
我现在的建议是:展示型评分一律用纯HTML拼接,不要在done回调里做rate组件初始化。如果必须做成可交互打分,单独抽个弹层页面,在弹层里用原生rate组件,避开表格刷新带来的生命周期问题。
4.6 排序状态在翻页后丢失
点了一次表头降序,翻到第二页,发现表头箭头消失,排序状态没保存。处理方式是在table.reload时把initSort带上,比如后端排序方案里那样:
table.reload('courseTable', { initSort: obj, where: { sortField: obj.field, sortOrder: obj.type }, page: {curr: 1} });这样每次请求都带着排序条件,翻页时箭头也正常显示。
5. 实操心得与避坑建议
这个功能做完,我自己的几点体会是:评分字段一定要在数据源头就固定成数字类型,这能省掉后面一整套排序兼容问题;展示型评星星就用纯HTML拼,省心且不依赖组件生命周期;排序方案的选择在动手前就定好,开了分页就别再用前端排序硬扛。
另外建议在联调阶段做一次“脏数据测试”,给接口塞几条评分超出0到5范围的记录,比如6分、-1分、null,看看前端渲染会不会崩。我在renderRate里做了Math.max和Math.min兜底,就是吃过这个亏之后补上的。
如果后续想再扩展,可以考虑把评分筛选也一起做进去,用一个下拉框让管理员只看3星以上的课程,再配合排序,后台筛数据效率会高很多。这个方向跟今天的排序功能共用一套字段配置,扩展起来很顺手。