1. 评分展示在小程序里到底难在哪
做过电商、外卖、知识付费类小程序的人都有一个共识:评分展示看起来简单,做起来全是细节。你随便打开一个商品详情页,上面那排星星,背后至少牵扯到四五个技术决策——数据精度怎么定、半颗星怎么渲染、星星数量能不能动态调整、不同屏幕尺寸下会不会错位、用户快速滑动时会不会闪烁。
我最早接触这个需求是在一个本地生活服务类的小程序项目里,当时产品经理甩过来一句话:“就一个五星评分,你半天搞不定?”结果我整整调了一天半。问题不在于画星星,而在于评分数据的精度和视觉呈现之间的映射关系。比如后端返回的是4.3分,前端要显示四颗半星还是四颗星加一个部分填充?半颗星到底是真半颗还是四舍五入?如果评分是3.7呢?这些规则不定义清楚,后面全是返工。
这篇文章就是把我踩过的坑、试过的方案、最终沉淀下来的做法完整拆一遍。不管你是刚接触小程序开发的新手,还是做过几个项目但每次遇到评分展示都要重新查文档的老手,下面这些内容都能直接拿去用。核心关键词就几个:动态评分、五角星展示、半颗星、自定义长度,我会围绕这四个点把方案讲透。
先说清楚适用场景:商品评分、店铺评分、用户等级、任务完成度、满意度调查,凡是需要把一串数字变成星星的地方,这套逻辑都通用。技术栈以微信小程序原生WXML+WXSS+JS为主,但思路同样适用于uni-app、Taro等跨端框架。
2. 方案选型:为什么我不推荐用图片做星星
2.1 三种主流方案的对比
在动手写代码之前,先要把技术路线定下来。我见过太多项目在这一步选错了方向,后面越做越痛苦。目前市面上做星星评分展示,主流就三条路:
| 方案 | 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 图片方案 | 用PNG图片切图,整星、半星、空星各一张 | 实现简单,视觉可控 | 无法动态调色,缩放模糊,多套主题要换图 | 固定样式、不换肤的小项目 |
| 字体图标方案 | 用iconfont的星形字符 | 矢量清晰,可调色 | 半颗星需要叠加裁剪,对齐麻烦 | 需要换色但不需要半星的场景 |
| 纯CSS方案 | 用CSS绘制星形或字符+裁剪 | 灵活,可动态,无外部依赖 | 需要理解裁剪和层叠逻辑 | 需要半星、动态长度、换色的场景 |
我的结论很明确:只要涉及半颗星和自定义长度,优先选纯CSS方案。原因后面会详细展开,这里先给一个直观的理由——图片方案在遇到“4.3分”这种数据时,你只能显示四颗半或者四颗,精度是死的;而CSS方案可以做到任意精度,比如精确到十分之一颗星。
2.2 半颗星的精度问题:四舍五入还是截断
这是产品逻辑层面必须先拍板的事。后端返回4.3分,前端怎么显示?我总结了几种常见规则:
- 四舍五入到半颗:4.3 → 4.5,4.2 → 4.0。视觉上比较“好看”,但会虚高。
- 向下取整到半颗:4.3 → 4.0,4.7 → 4.5。偏保守,用户觉得评分偏低。
- 精确到十分之一颗:4.3 → 四颗星加30%填充。最真实,但视觉上需要更精细的裁剪。
提示:如果你的项目是电商类,建议用“四舍五入到半颗”,因为用户对评分的感知是模糊的,半颗星的粒度足够。如果是数据看板或后台系统,建议用精确填充,避免信息失真。
我个人的经验是,在需求评审阶段就把这个规则写进文档,并且让产品经理确认。我吃过亏——上线后产品说“4.3怎么显示四颗半,应该显示四颗”,改起来虽然不难,但测试回归的成本很高。
2.3 自定义长度的含义与实现思路
“自定义长度”这个词,在不同项目里含义不一样。有的是指星星数量可以变,比如三分制、五分制、十分制;有的是指星星的大小可以随容器宽度自适应;还有的是指评分条的长度可以动态调整。
我这次要讲的是星星数量可配置这个维度。比如:
- 普通商品评分:5颗星
- 难度等级:3颗星
- 综合评分:10颗星
如果每换一个数量就改一次代码,那维护成本太高。正确的做法是把星星数量做成一个参数,通过属性传入,组件内部根据数量动态渲染。这样一套组件可以复用到所有场景。
3. 核心实现:从数据结构到渲染逻辑
3.1 数据结构设计:评分对象怎么定义
在写渲染逻辑之前,先把数据结构定好。我推荐用这样一个对象来描述评分状态:
// 评分数据模型 const ratingData = { score: 4.3, // 实际评分 maxScore: 5, // 满分值 starCount: 5, // 星星总数 allowHalf: true, // 是否允许半星 readonly: true // 是否只读 };基于这个模型,计算出每颗星的状态。核心算法是这样的:
function computeStars(score, starCount, maxScore) { // 每颗星代表的分数 const perStar = maxScore / starCount; // 实际应该亮的星数(浮点数) const litStars = (score / maxScore) * starCount; const stars = []; for (let i = 0; i < starCount; i++) { const starValue = litStars - i; if (starValue >= 1) { stars.push('full'); // 整星 } else if (starValue >= 0.5) { stars.push('half'); // 半星 } else if (starValue > 0) { stars.push('partial'); // 部分填充 } else { stars.push('empty'); // 空星 } } return stars; }这个算法的关键在于把浮点数的亮星数拆解成每颗星的状态。比如4.3分、5颗星、满分5分,litStars = 4.3,那么:
- 第1颗:4.3 - 0 = 4.3 ≥ 1 → full
- 第2颗:4.3 - 1 = 3.3 ≥ 1 → full
- 第3颗:4.3 - 2 = 2.3 ≥ 1 → full
- 第4颗:4.3 - 3 = 1.3 ≥ 1 → full
- 第5颗:4.3 - 4 = 0.3 → 0.3 < 0.5,但 > 0 → partial
所以最终是四颗整星加一颗30%填充的星。如果你只想支持半星,把partial那档去掉,0.3就归为empty。
3.2 半颗星的CSS实现原理
半颗星怎么画?这是整个方案里最核心的技术点。我试过三种做法:
做法一:用两个层叠的容器,底层是空星,上层是填充星,通过宽度裁剪。
.star-wrapper { position: relative; display: inline-block; width: 40rpx; height: 40rpx; } .star-bg { position: absolute; top: 0; left: 0; width: 100%; height: 100%; color: #ddd; } .star-fill { position: absolute; top: 0; left: 0; height: 100%; overflow: hidden; color: #ffb800; }上层容器的宽度用百分比控制,比如50%就是半颗星,30%就是30%填充。这个方案的优点是精度任意可调,缺点是每颗星要渲染两个字符,节点数翻倍。
做法二:用CSS的linear-gradient做背景裁剪。
.star { display: inline-block; font-size: 40rpx; background: linear-gradient(90deg, #ffb800 50%, #ddd 50%); -webkit-background-clip: text; color: transparent; }这个方案代码最简洁,但兼容性有坑。在小程序的部分基础库版本里,background-clip: text的支持不稳定,尤其是安卓低端机。我实测下来,iOS基本没问题,安卓有概率出现整颗星变透明的情况。
做法三:用SVG或canvas绘制。
这个方案最灵活,但开发成本最高,而且在小程序里canvas的层级问题很烦人。除非你有非常复杂的自定义图形需求,否则不推荐。
我最终选择的是做法一,因为它的兼容性最稳,精度可控,而且调试直观。节点数翻倍的问题,在星星数量不超过10颗的情况下完全可以接受。
3.3 自定义长度的动态渲染
星星数量可配置,意味着不能把星星写死在WXML里。要用wx:for动态渲染:
<view class="star-group"> <view class="star-wrapper" wx:for="{{stars}}" wx:key="index" > <text class="star-bg">★</text> <view class="star-fill" style="width: {{item.fillPercent}}%" > <text>★</text> </view> </view> </view>这里有个细节:fillPercent的计算要在JS里完成,不要在WXML里做运算。小程序的WXML表达式能力有限,复杂运算容易出错,而且不利于调试。
function buildStars(score, starCount, maxScore) { const perStar = maxScore / starCount; const litStars = (score / maxScore) * starCount; const result = []; for (let i = 0; i < starCount; i++) { const remain = litStars - i; let fillPercent = 0; if (remain >= 1) { fillPercent = 100; } else if (remain > 0) { fillPercent = Math.round(remain * 100); } result.push({ index: i, fillPercent: fillPercent }); } return result; }这样每颗星都有一个0到100的填充百分比,WXML里直接绑定style即可。这个方案的好处是精度完全由数据决定,你想精确到1%都行。
4. 完整实操:从零搭一个评分组件
4.1 组件目录结构与配置
我习惯把评分做成一个独立的自定义组件,这样在多个页面复用的时候不用复制代码。目录结构如下:
components/ star-rating/ index.js index.json index.wxml index.wxssindex.json里声明组件:
{ "component": true, "usingComponents": {} }4.2 组件属性定义与默认值
在index.js里定义对外暴露的属性:
Component({ properties: { score: { type: Number, value: 0, observer: 'onScoreChange' }, maxScore: { type: Number, value: 5 }, starCount: { type: Number, value: 5 }, size: { type: Number, value: 40 // 单位rpx }, activeColor: { type: String, value: '#ffb800' }, inactiveColor: { type: String, value: '#e0e0e0' }, readonly: { type: Boolean, value: true } }, data: { stars: [] }, lifetimes: { attached() { this.buildStars(); } }, methods: { onScoreChange() { this.buildStars(); }, buildStars() { const { score, maxScore, starCount } = this.data; const stars = []; const litStars = (score / maxScore) * starCount; for (let i = 0; i < starCount; i++) { const remain = litStars - i; let fillPercent = 0; if (remain >= 1) { fillPercent = 100; } else if (remain > 0) { fillPercent = Math.round(remain * 100); } stars.push({ index: i, fillPercent: fillPercent }); } this.setData({ stars }); } } });这里有几个设计决策值得说明:
为什么用observer而不是在attached里统一处理?因为score是动态的,用户可能在页面上通过交互改变评分,observer能保证数据变化时自动重新计算。
为什么size用Number而不是String?因为要在WXSS里做动态计算,Number类型更方便拼接。如果你传的是"40rpx"这样的字符串,后面做乘法运算会很麻烦。
为什么readonly默认是true?大部分场景下评分是展示用的,只读是常态。需要交互的时候显式传false,这样更安全。
4.3 WXML模板与样式细节
WXML部分:
<view class="star-rating" style="--star-size: {{size}}rpx;"> <view class="star-item" wx:for="{{stars}}" wx:key="index" > <text class="star-icon star-bg" style="color: {{inactiveColor}}; font-size: {{size}}rpx;" >★</text> <view class="star-mask" style="width: {{item.fillPercent}}%;" > <text class="star-icon star-active" style="color: {{activeColor}}; font-size: {{size}}rpx;" >★</text> </view> </view> </view>WXSS部分:
.star-rating { display: flex; align-items: center; flex-direction: row; } .star-item { position: relative; display: inline-block; width: var(--star-size); height: var(--star-size); margin-right: 4rpx; } .star-icon { position: absolute; top: 0; left: 0; line-height: 1; font-family: sans-serif; } .star-bg { position: relative; z-index: 1; } .star-mask { position: absolute; top: 0; left: 0; height: 100%; overflow: hidden; z-index: 2; white-space: nowrap; } .star-active { position: relative; }这里的关键点在于star-mask的宽度控制。mask容器设置了overflow: hidden,里面的星星字符宽度是固定的,当mask宽度小于星星宽度时,就只显示左边一部分,形成填充效果。
注意:star-mask里的text不能换行,所以要加white-space: nowrap。另外,mask的宽度是百分比,但父容器star-item的宽度是固定的var(--star-size),所以百分比是相对于星星宽度的,这个逻辑要理清楚。
4.4 页面中的调用方式
在页面里使用组件:
{ "usingComponents": { "star-rating": "/components/star-rating/index" } }<star-rating score="{{4.3}}" max-score="{{5}}" star-count="{{5}}" size="{{48}}" active-color="#ff6b00" />这样渲染出来的就是四颗整星加一颗30%填充的星,颜色是橙色,大小是48rpx。
5. 踩坑记录与排查技巧
5.1 星星对不齐的三种原因
这是最常见的问题,我遇到过至少三次。原因通常有三种:
第一种:字体基线不一致。星星字符在不同字体下的基线位置不同,导致垂直方向错位。解决办法是给star-icon设置line-height: 1,并且用flex布局的align-items: center来对齐。
第二种:mask容器的宽度计算有偏差。如果star-item的宽度和star-icon的实际渲染宽度不一致,mask的百分比就会算错。解决办法是确保star-item的宽度等于字体大小,并且字体本身没有额外的左右边距。
第三种:rpx和px混用。小程序的rpx在不同设备上换算比例不同,如果组件内部有的地方用rpx有的地方用px,就会出现累积误差。我的建议是组件内部统一用rpx,只在需要和系统API交互时才转px。
5.2 半颗星显示成整颗或空颗
这个问题通常是精度处理逻辑写错了。我见过一个常见的错误写法:
// 错误写法 if (remain >= 0.5) { fillPercent = 100; // 这里应该是50,不是100 }半颗星应该是50%填充,不是100%。另外,如果你用的是四舍五入到半颗的规则,逻辑要改成:
// 四舍五入到半颗 const rounded = Math.round(litStars * 2) / 2;然后再用rounded去计算每颗星的状态。这个细节很容易忽略,但产品验收的时候一眼就能看出来。
5.3 动态修改评分不生效
如果你在页面上通过按钮改变评分,发现星星没变化,大概率是observer没触发。检查两点:
- 传给组件的score是不是通过setData更新的?直接修改this.data.score不会触发observer。
- observer的函数名有没有写对?properties里的observer字段是字符串,指向methods里的方法名。
还有一种情况是score的类型不匹配。比如你传的是字符串"4.3",但properties里定义的是Number,小程序会尝试转换,但有时候转换失败会导致observer不触发。建议在传值之前就做好类型转换。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 星星垂直错位 | 字体基线不一致 | 检查line-height和align-items | 设置line-height:1,用flex居中 |
| 半星显示为整星 | 填充百分比计算错误 | 打印fillPercent值 | 半星应为50%,检查阈值判断 |
| 评分变化不刷新 | observer未触发 | 检查setData和类型 | 确保用setData更新,类型匹配 |
| 星星大小不一致 | rpx和px混用 | 检查样式单位 | 统一使用rpx |
| 安卓上星星变透明 | background-clip兼容性 | 在安卓真机测试 | 改用mask裁剪方案 |
| 星星数量不对 | starCount未传入 | 检查属性传递 | 确认star-count的值 |
6. 进阶玩法:让评分组件更通用
6.1 支持点击交互的评分
只读展示只是基础,很多场景需要用户自己打分。这时候要把readonly设为false,并且给每颗星绑定点击事件。核心逻辑是根据点击位置判断用户想打几分。
onStarTap(e) { if (this.data.readonly) return; const index = e.currentTarget.dataset.index; const { starCount, maxScore } = this.data; // 点击第index颗星,默认给整颗 const newScore = ((index + 1) / starCount) * maxScore; this.setData({ score: newScore }); this.triggerEvent('change', { score: newScore }); }如果需要支持半星点击,就要判断点击位置在星星的左半边还是右半边。这需要获取点击坐标,计算相对位置,逻辑会复杂一些。我的建议是如果产品没有明确要求半星点击,就不要做,因为用户体验上很难精确点到半颗。
6.2 评分动画与过渡效果
给星星加一点动画,体验会好很多。最简单的做法是给fillPercent加transition:
.star-mask { transition: width 0.3s ease; }这样评分变化时,填充宽度会平滑过渡。但要注意,首次渲染时不要有动画,否则会看到星星从零开始填充的闪烁。解决办法是用一个flag控制,首次渲染时禁用transition。
6.3 多主题与换肤支持
如果你的小程序支持深色模式,星星颜色也要跟着变。通过属性传入activeColor和inactiveColor是最直接的方式。更优雅的做法是用CSS变量:
.star-rating { --star-active: #ffb800; --star-inactive: #e0e0e0; }然后在页面级别覆盖这些变量。这样换肤的时候只需要改一处,不用逐个组件传参。
6.4 性能优化:减少setData次数
星星数量多的时候,每次评分变化都会触发一次setData,如果星星有几十颗,可能会有性能问题。优化思路是只更新变化的星星,而不是整个stars数组。不过在实际项目中,星星数量通常不超过10颗,这个优化意义不大。如果你真的遇到性能瓶颈,可以考虑用wxs来做渲染层的计算,减少逻辑层和渲染层的通信。
我在一个后台管理项目里做过测试,10颗星的评分组件,连续快速改变评分100次,setData的总耗时在可接受范围内。所以除非你的星星数量超过20颗,否则不用过度优化。
7. 一些个人体会
这套评分组件我在三个项目里用过,从电商到教育到后台系统,基本没怎么改过核心逻辑。最大的感受是:前期把数据精度规则定清楚,后面能省掉大量返工。我见过太多团队在“4.3分显示几颗星”这个问题上反复拉扯,最后代码里堆了一堆if-else,维护起来很痛苦。
另外,纯CSS方案虽然节点数多一些,但它的灵活性和兼容性是真的好。我试过用图片方案做一个换肤功能,结果要准备三套图,每套图还要考虑不同分辨率,最后果断换回CSS方案。
如果你正在做小程序评分展示,建议先把这篇文章里的数据结构设计和填充算法抄过去,跑通之后再根据项目需求调整精度规则和交互方式。组件化之后,后面再有类似需求,直接引入就行,五分钟搞定。