☰
小程序自定义评分组件开发实战:半星、滑动与性能优化
2026/10/17 1:22:46 网站建设 项目流程

1. 评分组件的需求拆解与设计思路

1.1 为什么原生组件不够用

做过小程序电商、内容社区或者本地生活类项目的朋友应该都有体会,评分展示和评分交互几乎是绕不开的需求。商品列表要显示星级、订单完成页要引导用户打分、内容详情页要展示综合评分,这些场景用原生组件能不能做?能,但很别扭。

原生radio-group只能做单选,样式改起来费劲;slider虽然能拖动,但它是连续值,评分通常是 1 到 5 的整数或者半星,用 slider 还得自己做取整和吸附。更关键的是,原生组件的视觉表现和大多数设计稿对不上——设计稿里的星星往往是自定义图标,有半星状态、有动画、有不同尺寸,这些用原生组件拼出来的效果总差那么点意思。

所以自己封装一个评分组件,本质上解决的是三个问题:视觉可控、交互可控、逻辑可复用。视觉可控意味着星星的图标、大小、间距、颜色全部由参数决定;交互可控意味着支持只读展示、支持点击打分、支持滑动打分、支持半星;逻辑可复用意味着一次封装,在商品卡片、评价表单、详情页头部都能直接引,不用重复写样式和事件。

1.2 组件的核心能力边界

在动手之前,我习惯先把组件的边界划清楚,不然写着写着就容易加需求加到失控。这个评分组件我给它定的能力范围是这样的:

  • 展示模式:只读,用于列表和详情页展示已有评分
  • 交互模式:可点击,用于用户主动打分
  • 精度支持:整星、半星两种精度
  • 尺寸支持:通过参数控制星星大小,适配不同场景
  • 自定义图标:支持传入自定义的选中、未选中、半选图标
  • 数量可配:默认 5 星,但支持配置成 10 星等其他数量

超出这个范围的需求,比如动态动画、拖拽连续评分,我建议单独做,不要塞进一个通用组件里,否则组件的复杂度会指数级上升,维护成本很高。

1.3 技术选型:为什么用自定义组件而不是模板

小程序提供了template和Component两种复用方式。template是早期方案,只能复用结构,逻辑还得在页面里写,数据也要手动传,用起来很割裂。Component自定义组件是官方主推的方案,它把结构、样式、逻辑、生命周期全部封装在一起,对外只暴露属性和事件,这才是真正意义上的组件化。

用Component构造器还有一个好处是样式隔离。默认情况下组件的样式不会影响外部,外部的样式也不会污染组件内部,这对于评分组件特别重要——你肯定不希望页面里某个全局的image样式把星星图标给改了。如果需要外部控制内部样式,可以通过externalClasses或者styleIsolation配置来放开,这个后面会细说。

另外,评分组件内部的状态管理比较简单,不需要引入额外的状态管理库。用properties接收外部数据,用data管理内部临时状态(比如滑动过程中的预览值),用triggerEvent向外抛事件,这套机制足够了。

2. 核心细节解析与实操要点

2.1 数据结构设计:评分值怎么存

评分值看起来简单,但精度不同,存储方式就不一样。如果只支持整星,那value就是一个 0 到 5 的整数。但如果要支持半星,value就得是 0.5 的倍数,比如 3.5、4.5。

我的做法是统一用数字存储,整星模式下传入整数,半星模式下传入 0.5 的倍数。组件内部在渲染时,把每个星星的状态算出来:大于等于当前索引加 1 的是满星,等于当前索引加 0.5 的是半星,其余是空星。

这里有个容易踩的坑:浮点数比较。0.1 + 0.2 !== 0.3这个经典问题在评分里也会出现。比如用户传进来 3.5,内部计算3.5 - 3 = 0.5,这个没问题,但如果传进来的是 2.9999999,那就会出问题。所以我在组件里做了一个归一化处理,把传入的值先四舍五入到最近的 0.5 倍数,再参与计算。

normalizeValue(value, allowHalf) { const step = allowHalf ? 0.5 : 1; return Math.round(value / step) * step; }

这段逻辑虽然简单,但能避免很多因为数据来源不同导致的显示异常。比如后端返回的评分是 4.3333,归一化后显示 4.5 或者 4,视觉上就正常了。

2.2 星星状态的计算逻辑

每个星星有三种状态:满星、半星、空星。计算逻辑我放在一个computed类似的函数里,每次value变化时重新计算。

computeStars(value, count, allowHalf) { const stars = []; for (let i = 0; i < count; i++) { const threshold = i + 1; if (value >= threshold) { stars.push('full'); } else if (allowHalf && value >= threshold - 0.5) { stars.push('half'); } else { stars.push('empty'); } } return stars; }

这个逻辑的关键在于判断顺序:先判断满星,再判断半星,最后才是空星。如果顺序反了,半星会被误判成空星。另外allowHalf为 false 时,半星分支直接跳过,所有非满星都是空星,这样整星模式下就不会出现半星。

2.3 半星图标的实现方式

半星怎么显示,这是评分组件里最考验细节的地方。常见的有三种方案:

第一种是准备三套图标,满星、半星、空星各一张图。这种方案最简单,但需要设计资源支持,而且半星图标和满星空星的风格要统一,不然拼在一起很违和。

第二种是用 CSS 裁剪。用一张满星图,通过overflow: hidden和width: 50%把右半边裁掉,露出左半边。这种方案不需要额外的设计资源,但要求星星图标本身是左右对称的,而且裁剪的边界要精确到像素,不同尺寸下可能需要微调。

第三种是用两层叠加。底层放空星,上层放满星,上层的宽度根据半星状态设为 50%。这种方案最灵活,不依赖图标对称性,而且动画过渡也容易做。

我最终选的是第三种,因为它对图标的要求最低,而且兼容性最好。具体实现是在星星容器里放两个image,一个空星一个满星,满星图层的宽度用行内样式控制。

<view class="star-item"> <image class="star-icon" src="{{emptyIcon}}" /> <image class="star-icon star-fill" src="{{fullIcon}}" style="width: {{item === 'full' ? '100%' : item === 'half' ? '50%' : '0'}}" /> </view>

这里有个细节要注意:两个image必须绝对定位重叠,否则会占两倍宽度。star-fill的overflow要设为hidden,这样宽度变化时才会裁剪而不是缩放。

2.4 点击与滑动交互的处理

点击打分相对简单,每个星星绑定tap事件,根据点击位置判断是整星还是半星。如果是半星模式,点击星星左半边算半星,右半边算整星。

handleTap(e) { const index = e.currentTarget.dataset.index; const { allowHalf, count } = this.data; let newValue = index + 1; if (allowHalf) { const { left, width } = e.currentTarget; const isLeftHalf = e.detail.x - left < width / 2; newValue = isLeftHalf ? index + 0.5 : index + 1; } this.setData({ value: newValue }); this.triggerEvent('change', { value: newValue }); }

滑动打分就复杂一些。需要监听touchmove事件,根据手指的横坐标算出对应的评分值。这里的关键是要拿到星星容器的整体位置和宽度,然后做比例换算。

handleTouchMove(e) { const { left, width } = this.data.containerRect; const touchX = e.touches[0].clientX; const ratio = Math.max(0, Math.min(1, (touchX - left) / width)); let newValue = ratio * this.data.count; newValue = this.data.allowHalf ? Math.ceil(newValue * 2) / 2 : Math.ceil(newValue); this.setData({ value: newValue }); }

滑动打分有个体验上的坑:如果每次touchmove都setData,在低端机上会卡顿。我的优化是加一个节流,或者只在值变化时才setData。另外滑动结束时要触发一次change事件,而不是每次移动都触发,否则外部监听会收到大量冗余事件。

提示:滑动打分在只读模式下要禁用,否则用户滑动页面时会误触。可以通过disabled属性控制,或者在touchmove里判断当前模式。

3. 完整实操过程与核心环节实现

3.1 组件目录结构与文件职责

一个规范的自定义组件目录应该是这样的:

components/ rate/ rate.js // 组件逻辑 rate.json // 组件配置 rate.wxml // 组件结构 rate.wxss // 组件样式

rate.json里必须声明"component": true,否则小程序会把它当普通页面处理。如果需要外部传入样式类,还要在这里配置externalClasses。

{ "component": true, "externalClasses": ["custom-class"] }

rate.js用Component构造器,properties定义对外属性,data定义内部状态,methods定义事件处理函数。这里我把所有可配置项都列出来:

属性名类型默认值说明
valueNumber0当前评分值
countNumber5星星总数
allowHalfBooleanfalse是否允许半星
disabledBooleanfalse是否只读
sizeNumber40星星尺寸,单位 rpx
gutterNumber8星星间距,单位 rpx
fullIconString内置满星图标路径
emptyIconString内置空星图标路径
colorString#FFB800选中颜色(图标为字体时生效)

3.2 WXML 结构的编写要点

结构上我用一个外层容器包裹所有星星,每个星星是一个view,内部放两个image做叠加。外层容器需要绑定touchmove和touchend来实现滑动打分。

<view class="rate-container custom-class" style="--star-size: {{size}}rpx; --star-gutter: {{gutter}}rpx;" bindtouchmove="handleTouchMove" bindtouchend="handleTouchEnd" > <view wx:for="{{stars}}" wx:key="index" class="star-item" >observers: { 'value, count, allowHalf': function(value, count, allowHalf) { const normalized = this.normalizeValue(value, allowHalf); const stars = this.computeStars(normalized, count, allowHalf); this.setData({ stars, innerValue: normalized }); } }

3.3 WXSS 样式的关键细节

样式部分有几个关键点。首先是星星的排列,用flex布局,间距用margin-right控制,最后一个星星去掉右边距。

.rate-container { display: flex; align-items: center; } .star-item { position: relative; width: var(--star-size); height: var(--star-size); margin-right: var(--star-gutter); } .star-item:last-child { margin-right: 0; } .star-icon { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } .star-fill { overflow: hidden; transition: width 0.2s ease; }

star-fill的transition是可选的,加上之后点击打分会有个平滑的填充动画,体验更好。但如果是滑动打分,这个过渡反而会显得拖沓,所以我在滑动时会把transition去掉,滑动结束再加回来。

注意:image组件默认有 320x240 的尺寸,必须显式设置宽高,否则会撑破布局。用mode="aspectFit"保证图标不变形。

3.4 事件系统与外部通信

组件对外暴露两个事件:change和touchmove。change在评分值最终确定时触发,touchmove在滑动过程中触发,方便外部做实时预览。

triggerChange(value) { this.triggerEvent('change', { value }); }

外部页面这样使用:

<rate value="{{score}}" allow-half bind:change="onRateChange" />
onRateChange(e) { console.log('当前评分:', e.detail.value); this.setData({ score: e.detail.value }); }

这里有个双向绑定的问题。小程序自定义组件不支持v-model那样的语法糖,需要手动在change事件里更新外部数据。如果外部不更新,组件内部的value和外部传入的value就会不一致。我的做法是组件内部维护一个innerValue,点击时先更新innerValue让 UI 立即响应,同时抛出事件让外部决定是否更新。如果外部更新了value,observers会再次触发,把innerValue同步过来。

3.5 只读模式与交互模式的切换

只读模式其实就是在交互事件里加判断。disabled为 true 时,handleTap和handleTouchMove直接 return,不做任何处理。同时样式上可以加一个pointer-events: none或者降低透明度,给用户视觉上的反馈。

handleTap(e) { if (this.data.disabled) return; // ... }

但这里有个细节:pointer-events: none会导致tap事件完全不触发,这样连change事件都不会有,适合纯展示场景。如果希望只读模式下仍然有点击反馈但不改变值,那就不要用pointer-events,而是在逻辑里拦截。

4. 常见问题与排查技巧实录

4.1 星星图标显示不全或变形

这是最常见的问题,八成是image的尺寸没设对。小程序的image组件默认宽度 320px、高度 240px,如果你只设了宽度没设高度,图片会按默认高度渲染,导致拉伸或者裁剪。

解决办法是给image显式设置width和height,并且用mode="aspectFit"让图片保持比例缩放。如果图标本身有留白,可能还需要调整padding或者用mode="scaleToFill"强制填充。

还有一种情况是图标路径写错了。小程序的image支持本地路径和网络路径,本地路径要用相对路径或者绝对路径,不能用~开头的别名。如果图标放在组件的images目录下,路径应该是./images/star.png或者/components/rate/images/star.png。

4.2 半星显示位置偏移

半星用宽度裁剪实现时,如果图标本身不是左右对称的,裁剪出来的半星会偏左或偏右。比如一个星星图标左边有 2px 的透明边距,裁剪 50% 后,实际显示的星星就不在正中间。

解决办法有两个:一是让设计把图标做成左右对称的,透明边距均匀;二是不用宽度裁剪,改用clip-path精确控制裁剪区域。clip-path在小程序里的兼容性还可以,但低版本基础库可能不支持,需要做降级处理。

.star-fill.half { clip-path: inset(0 50% 0 0); }

4.3 滑动打分与页面滚动冲突

评分组件如果放在可滚动页面里,滑动打分时页面也会跟着滚,体验很糟糕。解决办法是在touchmove里调用catchtouchmove而不是bindtouchmove,阻止事件冒泡。

<view catchtouchmove="handleTouchMove">

但catchtouchmove会完全阻止页面滚动,如果用户只是想滚动页面而不是打分,就会觉得卡住了。所以更好的做法是加一个判断:只有当手指在评分区域内移动时才阻止滚动,移出区域后恢复。

handleTouchMove(e) { if (this.data.disabled) return; const touchX = e.touches[0].clientX; const { left, right } = this.data.containerRect; if (touchX < left || touchX > right) { this.setData({ sliding: false }); return; } this.setData({ sliding: true }); // 计算评分... }

4.4 数据更新不同步

前面提到过,组件内部innerValue和外部value可能不同步。典型场景是外部在change事件里做了异步校验,校验不通过就不更新value,但组件内部已经变了。

解决办法是组件内部不要直接改value,而是改innerValue,然后在observers里监听value的变化,把innerValue重置为value。这样外部不更新value时,组件会在下一次value变化时恢复。

observers: { 'value': function(value) { const normalized = this.normalizeValue(value, this.data.allowHalf); this.setData({ innerValue: normalized, stars: this.computeStars(normalized, this.data.count, this.data.allowHalf) }); } }

4.5 常见问题速查表

问题现象可能原因排查方向解决方案
星星不显示图标路径错误检查image的src改用绝对路径或网络图
星星变形未设宽高检查image样式显式设置width/height
半星偏移图标不对称检查图标透明边距用clip-path精确裁剪
滑动卡顿频繁setData检查touchmove逻辑加节流或值变化才更新
页面跟着滚事件冒泡检查bind/catch用catchtouchmove
值不同步外部未更新检查change处理内部用innerValue过渡
点击无反应disabled为 true检查属性传值确认是否需要只读
半星不生效allowHalf未开检查属性配置传入allow-half

4.6 性能优化与体验提升技巧

评分组件本身不复杂,但在长列表里大量使用时,性能问题就会暴露。比如商品列表有 100 个商品,每个商品都有评分组件,如果每个组件都做复杂的计算和setData,页面就会卡。

我的优化经验是:只读模式下不做任何交互监听。bindtap、bindtouchmove这些事件绑定在只读模式下全部去掉,减少事件系统的开销。可以在 WXML 里用条件渲染,只读时渲染一个简化版的结构。

<block wx:if="{{disabled}}"> <view class="rate-container"> <image wx:for="{{stars}}" wx:key="index" src="{{item === 'full' ? fullIcon : emptyIcon}}" /> </view> </block> <block wx:else> <!-- 完整交互版 --> </block>

另外,星星的状态数组stars不要在每次渲染时重新计算,而是在observers里算好存到data里。setData的数据量也要控制,只传变化的部分,不要整个stars数组重新传。

还有一个体验上的小技巧:点击打分时加一个轻微的缩放动画,让用户感觉到反馈。用 CSS 的transform: scale()配合transition就能实现,成本很低但效果很好。

.star-item:active { transform: scale(0.9); }

这个:active伪类在小程序里支持得不错,但要注意它只在按住时生效,松开就恢复。如果要做更复杂的动画,比如星星弹跳,那就需要animation或者wx.createAnimation了,但我觉得对于评分组件来说,简单的缩放足够了,过度动画反而显得花哨。

4.7 无障碍与兼容性考量

小程序的无障碍支持虽然不如原生 App 完善,但基本的aria-label还是可以加的。给评分容器加一个aria-label,说明当前评分和总分,方便读屏用户理解。

<view class="rate-container" aria-label="评分 {{innerValue}} 分,共 {{count}} 分">

兼容性方面,CSS 变量在小程序基础库 2.0 以上都支持,clip-path在 2.9 以上支持,aspectFit模式一直都有。如果项目需要兼容很低的基础库版本,那就得用更保守的写法,比如不用 CSS 变量,直接写行内样式;不用clip-path,用宽度裁剪。

我在实际项目里一般会把基础库版本要求写在组件的 README 里,让使用者心里有数。如果确实要兼容老版本,那就准备两套样式,用wx.getSystemInfoSync()判断基础库版本动态切换。

这个评分组件从最初的一个简单展示,到后来支持半星、滑动、自定义图标,前后迭代了大概四五个版本。踩过的坑主要集中在半星显示和滑动冲突上,这两个问题一旦解决,剩下的就是参数配置和样式微调了。如果你也在做类似组件,我的建议是先把只读模式做稳,再逐步加交互,不要一上来就追求大而全,否则调试成本会很高。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询