做过小程序评分的同学应该都有这种经历:需求方一句话“就是个星级评分,拖一拖就行”,落到开发这里往往要折腾大半天。直接用 uni-app 内置的 slider,两分钟能让交互跑起来,但视觉效果怎么看都像个老旧音量条;自己写一个评分滑块,又担心触摸偏移、步长吸附、多端渲染集体翻车。这篇笔记就围绕 uni-app 项目里“评分滑块组件”到底怎么选型展开,自绘实现和原生 slider 组件各自的问题域在哪、上手成本多少、踩坑清单是什么,以及什么场景真正适合走哪条路。
先说结论:如果你的页面只需要一个“能拖动、能取值”的基础滑块,原生 slider 绰绰有余;但如果这个滑块要扮演“评分”角色,要出现星形、半星、文字提示、品牌配色这些细节,原生 slider 永远欠点火候,这时候自绘实现才是更顺手的答案。下面我把自己在 uni-app 上做一个移动端评分滑块的完整复盘写清楚,包含代码、参数、对比表和排坑记录,给后来者省点时间。
1. 为什么评分滑块不能直接“拿来就用”
1.1 评分滑块到底是个什么控件
评分滑块由两部分语义拼起来:一部分是“滑块”,用户按住以后可以左右拖动;另一部分是“评分”,拖出来的结果要对应分数或等级。常见形态是五个星,也可以变成点赞、表情、心形甚至一段文字标签。真正动手以后你会发现,“评分”和“滑块”这两个词放在一起,隐藏着冲突:滑块需要连续反馈,而评分往往需要离散取值。1 分、2 分、3 分之间到底允不允许拖到 2.5 分,需求文档里说得清楚,落代码时就要查一下。
拿生活里的例子来说,普通 slider 就是老式收音机的音量旋钮,你转多少就是多少;评分滑块更像是试卷打分,老师本来想打 88 分,最后填出来的却是“良好”。这里的交互重点不是“多精确”,而是“落到哪个档位”。所以做评分滑块的时候,核心要考虑的是档位的吸附逻辑:是松手才吸附,还是拖动过程中就实时吸附。这两种手感差别很大,直接决定用户觉得“好用”还是“卡顿”。
再进一步,真正消耗开发时间的是视觉。你要在滑块轨道上体现出分数,而不是简单显示一个数字,那就要考虑用星星堆叠、颜色渐变、半星遮盖、动画过渡这些手段。而这些全是原生 slider 不会替你做的事。
1.2 原生 slider 在 uni-app 里到底是什么状态
uni-app 自带<slider>组件,它也是官方文档里配合表单场景的推荐组件。属性挺齐全:min、max、step、value、disabled、activeColor、backgroundColor、blockSize、blockColor、show-value,事件有 change 和 changing。单看能力列表,做一个 0 到 5 的评分似乎毫无压力,甚至 show-value 还能直接把数字显示在旁边。
但注意一个细节:slider 在小程序端是框架封装组件,在 H5 端渲染成典型的 input[type=range],在 App 端又要走原生渲染。你以为同一个组件三端长得一样,实际到了线上偏差很大。blockSize 在某些平台的表现、轨道高度的一致性、拖动时数值更新的频率,都可能有细微不同。这些不同平时不会暴露,一旦你做的是评分这种“视觉敏感”交互,就会被用户一眼看穿。
更关键的是,slider 没有“星级”这么一说。它能把值从 0 拖到 5,但你拿到的是一个线性轨道上的圆点,不是一排星星。想让它变成评分组件,还需要在外面套一层文本或者图标,把数值翻译成用户的视觉语言。
1.3 自绘组件和原生组件的选择本质
与其问“用什么组件”,不如问“我到底要控制多少表现层的东西”。用原生 slider,你控制的是属性;用自绘组件,你控制的是 DOM 结构、CSS、手势计算、事件时机。控制得越多,自由度越高,但代码量、调试范围、踩坑概率也同步上涨。
这就像租房和装修的区别。原生 slider 是拎包入住的公寓,优点在于快,缺点在于风格统一,很难做到品牌化;自绘组件是毛坯房,地基水电都要自己搞,但是最后呈现出来的效果完全可以照着设计稿一比一还原。选择的关键不是哪个更好,而是你的项目能不能为自由度买单。
对于大多数评分场景,我倾向于自绘,因为评分的视觉要求在整个页面里往往是最抢眼的元素之一。一个评分功能做得粗糙,用户对整个产品的信任度都会打折,不值得为了省几小时开发时间放弃视觉还原度。
2. 原生组件方案的落地与边界
2.1 slider 基础用法与参数速查
先看一段最简单的原生 slider 使用示例:
<template> <view class="slider-page"> <slider :value="score" :min="0" :max="5" :step="1" activeColor="#ffb800" backgroundColor="#e5e5e5" blockColor="#ffffff" :blockSize="20" :show-value="true" @change="onSliderChange" @changing="onSliderChanging" /> <view class="score-text">当前分数:{{ score }}</view> </view> </template> <script setup> import { ref } from 'vue' const score = ref(0) function onSliderChange(e) { // 松手后触发,适合做保存、提交逻辑 score.value = e.detail.value } function onSliderChanging(e) { // 拖动中持续触发,适合做实时预览 score.value = e.detail.value } </script>这套代码跑起来很快,但有几个点要提前说清楚。change和changing是两个不同时机的事件,前者是手指松开后触发,后者是拖动过程中持续触发。做评分的时候,建议把提交逻辑放change,把界面预览放changing。如果混在一起,用户还没松手,你已经把分数提交上去了,这种情况在支付前确认信息的表单里会引发很严重的重复提交问题。
blockSize这个属性在某些平台上表现不稳定,尤其是 Android 低版本 WebView 和部分定制 ROM 上,滑块圆点大小可能不是实际设置的数值。如果你想通过放大圆点来迎合评分场景,建议在真机上重点回归。
2.2 把 slider 硬改成评分效果
既然原生 slider 不支持星级,很多人会做一个折中方案:slider 负责取数,旁边用文字或者星形字符展示分数。
<template> <view class="score-box"> <slider :value="score" :min="0" :max="5" :step="1" @changing="onSliderChanging" @change="onSliderChange" /> <text class="score-stars">{{ getStars(score) }}</text> </view> </template> <script setup> import { ref } from 'vue' const score = ref(0) function onSliderChanging(e) { score.value = e.detail.value } function onSliderChange(e) { score.value = e.detail.value } function getStars(value) { const full = Math.floor(value) let str = '' for (let i = 0; i < full; i++) { str += '★' } // 剩余部分用 ☆ 补齐到 5 for (let i = full; i < 5; i++) { str += '☆' } return str } </script>这个方案的最大优点是代码量小,当天就能上线。但等产品验收的时候问题就来了:用户在地铁上单手操作,要拖到 3 分,手指一滑就过了 4;想点某个星星直接打分,slider 没有点击跳转能力;星形字符在 iOS 和 Android 上字体渲染不一样,有的手机上星星间距忽大忽小。
这些还不算最难受的,最难受的是半星支持。产品如果要求“3.5 分”,用原生 slider 做半星就得靠额外处理字符宽度,或者用多张图片拼接,复杂度一下就上去了。到这一步,原生的优势已经不存在了。
2.3 原生方案的三条边界
我复盘下来,原生 slider 方案有三条明确边界:
第一,视觉还原边界。只要设计稿里出现品牌色、渐变、自定义图标、描边、阴影,原生 slider 基本无能为力。你硬写 CSS 样式去覆盖,可能只覆盖了轨道颜色,滑块圆点还是系统默认样式。
第二,交互精准度边界。评分需要“点到某个位置就选中对应星级”的精准反馈,而原生 slider 的拖动逻辑是线性连续移动,松手后才会按 step 吸附。用户想要 3 分却拖到 3.8 再弹回 4,这种手感在评分场景里很出戏。
第三,多端一致性边界。同一份代码在小程序和 H5 上跑,视觉效果经常有差异。某些平台的 slider 会在 activeColor 和 backgroundColor 之外多出一层阴影,某些平台的 block 尺寸与文档不一致。如果页面要覆盖多端,这些细节会让你反复调样式。
一旦你遇到这三条边界里的任何一条,就应该开始考虑自绘实现了。
3. 自绘评分滑块组件设计与实现
3.1 交互模型:点击、拖动、松手三阶段
自绘评分滑块的第一步是定义手势模型。我的设计是:
- touchstart:根据触点的横向位置计算当前分数,并进入“按下状态”。
- touchmove:跟随手指横向移动,持续刷新分数,并阻止页面的横向滚动。
- touchend:保留最后计算的分数值,触发 change 事件。
- 点击:touchstart 和 touchend 的坐标接近时等价于点击,可以直接设置分数。
这个模型兼顾了“拖动评分”和“点击评分”两种习惯。实际测试中,用户第一反应是点击星星,第二反应才是拖动滑块,所以两套逻辑都要实现。
关键点是事件回调里面不要做复杂的动画或者异步请求。touchmove 每帧触发,频率很高,如果你在回调里执行uni.createSelectorQuery().boundingClientRect()这种异步查询,性能会非常差。正确做法是在组件初始化时量一次尺寸,缓存下来,事件回调里直接同步计算。
3.2 坐标映射与分数吸附计算
评分滑块的计算逻辑并不复杂,核心是一个公式:
// 获取滑块组件在页面上的可视区域 const rect = { left: 40, width: 240 } function calcValue(clientX, min, max, step) { let ratio = (clientX - rect.left) / rect.width ratio = Math.min(1, Math.max(0, ratio)) let value = min + (max - min) * ratio value = Math.round(value / step) * step value = Math.min(max, Math.max(min, value)) return value }先说ratio的夹取。用户可能在滑块区域外结束触摸,clientX会超出边界,如果不做夹取,分数会变成负数或者超过最大值。这一步必须放在最前面。
再说Math.round的吸附。假设max=5、step=1,实际手指位置算出 3.4,Math.round(3.4)得到 3;算出 3.6,吸附到 4。如果你的业务允许半星,就把step设成 0.5,这时候 3.7 会吸附到 3.5。吸附逻辑决定了用户的最终手感,建议 step 由外部传入,不要写死。
我在实际项目里遇到过一个问题:Android 某些浏览器对touchmove事件里修改状态会比较谨慎,导致 UI 更新不及时。解决办法是在touchmove里不仅修改响应式变量,还要手动控制高速 DOM 的样式操作。如果你用的是 Vue 3 语法,尽量用 computed 去派生进度和 thumb 位置,让框架自己批处理更新。
3.3 渲染层:星级视觉与进度填充
评分滑块的视觉分两层。底层是未点亮状态,上层是点亮状态,上层宽度用百分比控制,就能自然露出对应的分数。
实现思路如下:
<template> <view class="slider-rate-wrap" @touchstart="handleTouch" @touchmove.stop.prevent="handleTouch" @touchend="handleTouchEnd" > <view class="rate-bg"> <text v-for="i in max" :key="i" class="star" :style="{ fontSize: size + 'px' }" >☆</text> </view> <view class="rate-fill" :style="{ width: fillPercent + '%' }"> <text v-for="i in max" :key="i" class="star" :style="{ fontSize: size + 'px' }" >★</text> </view> <view class="rate-thumb" :style="{ left: thumbPercent + '%' }"> <view class="thumb-value">{{ displayValue }}</view> </view> </view> </template>基础层显示 5 个空心星,覆盖层用绝对定位放在基础层上面,通过fillPercent控制点亮宽度。比如fillPercent = 70%,那前面的星星都会变成实心,最后一个星星根据宽度出现“半颗”效果,视觉比较自然。
这段代码里面最容易被忽视的是“星间间距”。如果你在.star上设置了margin-right: 4px,覆盖层里的星星和基础层里的星星一定要保持完全一样的间距。否则一遇到 50% 宽度时,两层星星会错位,视觉上会看到明显的断层。
3.4 完整组件代码与接入示例
我把一个简化但可直接上手的评分滑块组件代码贴出来:
<script setup> import { ref, computed, onMounted, nextTick, getCurrentInstance } from 'vue' const props = defineProps({ modelValue: { type: Number, default: 0 }, min: { type: Number, default: 0 }, max: { type: Number, default: 5 }, step: { type: Number, default: 1 }, disabled: { type: Boolean, default: false }, size: { type: Number, default: 32 } }) const emit = defineEmits(['update:modelValue', 'change', 'changing']) const displayValue = ref(normalize(props.modelValue)) function normalize(val) { let step = Math.max(props.step, 0.01) let v = Math.round(val / step) * step return Math.min(props.max, Math.max(props.min, v)) } let barRect = null function getValueByClientX(clientX) { if (!barRect) return displayValue.value let ratio = (clientX - barRect.left) / barRect.width ratio = Math.min(1, Math.max(0, ratio)) return normalize(props.min + (props.max - props.min) * ratio) } function handleTouch(e) { if (props.disabled) return const touch = (e.touches && e.touches[0]) || (e.changedTouches && e.changedTouches[0]) if (!touch) return const val = getValueByClientX(touch.clientX) displayValue.value = val emit('changing', val) } function handleTouchEnd(e) { if (props.disabled) return const touch = (e.changedTouches && e.changedTouches[0]) || (e.touches && e.touches[0]) if (!touch) return const val = getValueByClientX(touch.clientX) displayValue.value = val emit('update:modelValue', val) emit('change', val) } const fillPercent = computed(() => { return ((displayValue.value - props.min) / (props.max - props.min)) * 100 }) const thumbPercent = computed(() => { return ((displayValue.value - props.min) / (props.max - props.min)) * 100 }) async function measureBar() { await nextTick() // 在组件内部获取自身位置,需要传入当前组件作用域 const query = uni.createSelectorQuery().in(getCurrentInstance()) query.select('.slider-rate-wrap').boundingClientRect(rect => { if (rect) barRect = { left: rect.left, width: rect.width } }).exec() } onMounted(() => { measureBar() }) </script>模板部分和前面的骨架一致,我再用一个调用示例说明接入方式:
<template> <view class="page"> <rate-slider v-model="score" :size="36" :step="1" @change="submitScore" /> </view> </template> <script setup> import { ref } from 'vue' import RateSlider from '@/components/rate-slider.vue' const score = ref(0) function submitScore(val) { console.log('评分结果', val) // 这里发起请求或跳转 } </script>这段代码里的size、step都可以按场景调整。如果你想把组件放在表单里和别的控件联动,只需要把change事件里的数值提交到表单数据里就够了。
3.5 滑动冲突与边界情况处理
自绘滑块最常踩的坑是“手势冲突”。页面是纵向滚动的,用户横向滑动评分条时,手指的一点点纵向偏移会被页面当成上下滚动,导致评分条抖动或者页面跳动。
处理方式有三个层次:
第一,在touchmove事件上加上.stop.prevent修饰符,阻止事件冒泡和默认行为:
@touchmove.stop.prevent="handleTouch"第二,给组件根节点加上touch-action: none的 CSS 样式,通知浏览器该区域不需要默认手势处理。
第三,如果页面里还有轮播图、横向列表等冲突组件,需要做手势方向判断:当纵向滑动距离明显大于横向距离时,放弃评分条的手势控制,把事件交还给页面滚动。
let startX = 0 let startY = 0 function handleTouchStart(e) { startX = e.touches[0].clientX startY = e.touches[0].clientY } function handleTouchMove(e) { const dx = Math.abs(e.touches[0].clientX - startX) const dy = Math.abs(e.touches[0].clientY - startY) if (dy > dx) return // 纵向滚动优先 // 执行横向滑块逻辑 }这个判断写起来不难,但能避免很多线上反馈的“页面滚不动”问题。我建议任何放页面里的自绘滑块都做这一层保护。
4. 选型对比:到底该选原生还是自绘
4.1 一份可以贴在需求文档里的对比表
| 维度 | 原生 slider 组件 | 自绘评分滑块组件 |
|---|---|---|
| 开发速度 | 快,几行代码可用 | 慢,首次开发半天起步 |
| 视觉自定义 | 低,只能改颜色和圆点大小 | 高,图标、动画、布局完全可控 |
| 半星支持 | 难,需要额外拼图或字符处理 | 容易,通过宽度百分比控制 |
| 多端一致性 | 一般,不同端渲染有差异 | 好,自行控制 DOM 和 CSS |
| 手势精准度 | 可控,但吸附逻辑依赖框架 | 完全可控,可做点击和拖动双模式 |
| 上手门槛 | 低,文档属性直白 | 中,要理解 touch 事件和坐标换算 |
| 维护成本 | 低,官方组件随框架升级 | 中,组件需要自己维护和回归 |
| 适用场景 | 设置页、音量、数值选择 | 评分、点赞、满意度、品牌视觉页面 |
这张表比较理性。你如果只是做一个“从 0 到 100 选择亮度”的功能,完全没有必要自绘;但如果你做的是用户评价页的五星评分,自绘带来的视觉提升和交互手感,会直接影响到用户填写的欲望。
4.2 三个决策公式
我给不了你一条“一刀切”的规则,但可以给三个决策参考:
第一个公式,如果“页面整体风格统一”是硬指标,选自绘。原生 slider 放到一个偏卡片风格、圆角很多的页面里,会让细节看起来很廉价。自绘组件可以通过 CSS 变量、主题配置融入页面。
第二个公式,如果“开发时间小于 0.5 天”是硬指标,选原生 slider。时间排期紧张的时候,原生 slider 至少能保证功能可用。等后续有视觉优化需求,再替换成自绘组件也不迟。
第三个公式,如果“交互反馈必须细腻”是硬指标,基本上只能选自绘。原生 slider 的 touch 反馈通常没有自定义的星级跳动、颜色渐变、数值气泡,这些写得越好,用户越容易产生“这个产品值得信赖”的感受。
4.3 关于性能的实测心得
自绘评分滑块组件本身很轻,一个组件只有几十个 DOM 元素,性能压力几乎可以忽略。我测试过中端 Android 机,连续拖动 5 分钟内没有出现明显的帧率下降。
真正可能影响性能的是你写的动画。比如我一度在 fillPercent 变化时给rate-fill加了transition: width 0.2s ease,看起来丝滑,但在 touchmove 高频触发下,transition 会让宽度变化有延迟感,反而显得“黏”。这种过渡效果更适合放在 touchend 之后,而不是拖动过程中使用。
另一个性能点在于uni.createSelectorQuery()的调用时机。我在 3.1 里强调过不要在事件回调里查询布局,这里再强调一次。正确做法是组件挂载时量一次,如果页面布局可能在运行时变化,比如旋转屏幕、折叠面板展开,再手动调用measureBar()重新测量。
5. 实际项目中的常见坑与排查
5.1 原生 slider 方案的高频问题
用原生 slider 的时候,最常见的报错是event.detail.value取不到值。这通常是因为事件绑定写错了。小程序环境里,@change="onSliderChange"和@change="onSliderChange($event)"都可以,但是如果你在模板里写成了@change="onSliderChange(score)",拿到的是当前状态值而不是事件对象,后面再取detail就会报undefined。
还有一个坑是 slider 的step属性。官方文档说默认 step 是 1,但你如果设置了min=0, max=5, step=0.5,在部分平台会正常支持小数,在另一些平台会强制四舍五入成整数。做半星评分时,建议在change回调里再对值做一次归一化,不要完全信任detail.value。
5.2 自绘评分滑块的高频问题
自绘方案的坑主要集中在触摸和尺寸计算上。
第一个问题是“手指按住滑块但 score 不更新”。排查顺序是:先确认touchstart是否触发,再确认clientX是否拿到,最后确认barRect是否为空。我遇到过的实际原因是组件被放在v-if控制的容器里,组件加载时元素的宽度还是 0,导致barRect.width为 0,后续所有计算都变成 NaN。解决办法是在v-if条件变为 true 后,通过nextTick或者setTimeout重新调用measureBar()。
第二个问题是“H5 端页面跟着手指滚动”。这多半是touch-action没设置。在组件根节点写touch-action: none基本都能解决。如果是在小程序和 App 端,还需要配合事件修饰符.prevent来阻止默认行为。
第三个问题是“星星层错位”。前面我提过,两层星星的 margin、font-size 必须一致。实际开发里我建议用一个常量控制星间间距,不再给每个星星单独写样式,从源头避免错位。
5.3 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 触摸没有反应 | touch 事件未绑定或 disabled 状态为 true | 检查事件绑定,检查 disabled 逻辑 |
| 分数突然变成 NaN | barRect 为空或宽度为 0 | 组件挂载后重新 measureBar |
| 拖动时页面也跟着滚动 | 缺少 touch-action 或事件 prevent | 设置 touch-action: none,加 .prevent |
| 星星层错位 | 两层星星间距、字号不一致 | 用统一变量控制 margin 和 font-size |
| 拖动到末尾分数变为 0 | ratio 没有做 0-1 边界夹取 | 对 ratio 做 clamp |
| H5 端 star 字符锯齿明显 | 字体渲染差异 | 改用图标字体或图片 |
6. 个人实操体会与后续扩展
6.1 我更倾向哪种方案
做了三四个评分组件需求以后,我的默认选择已经变成了“如果没有特殊原因就用自绘”。因为在 uni-app 这种多端框架里,原生 slider 的不确定性被放大了,一处不统一就要加一堆条件判断;而自绘组件只要把触摸计算和视觉层写好,基本能在小程序、H5、App 上稳定表现。
当然,这不是说原生 slider 一无是处。如果只是做一个内部管理后台的数值调整,或者页面整体风格很朴素,原生 slider 仍然是省心的选项。关键是你要在动手前明确“评分”这个词对视觉和交互的要求有多高。
我在实际项目里还有一个习惯:把自绘组件抽成独立的公共组件,放在 components 目录里,并预留min、max、step、size、disabled、theme这些扩展属性。这样不同页面复用的时候,只需要改参数,不用动源码。后续如果产品要把星形换成“满意/一般/不满意”表情,我只需要替换视觉层,手势逻辑可以原样保留。
6.2 这个组件还能怎么扩展
自绘评分滑块最大的好处是“可延展性”。你可以在这个基础上继续加:
一是加入声音和震动反馈。想让评分操作更有手感,可以在touchend触发时调用uni.vibrateShort()做一个短震动,注意真机上不要频繁调用,否则会比较耗电。
二是加入星级之间的动画。比如当用户松手后,让覆盖层宽度从当前值平滑过渡到最近的一个整数星,配合轻微的弹簧动画,视觉上会更高级。
三是支持更细粒度的分数展示。如果你在做医疗挂号、课程评价这类场景,可能需要在滑块上方显示“舒适度 4.6 分”这样的文案,可以直接用displayValue计算展示。
四是做好主题化。把星星颜色、选中颜色、提示气泡配色统一提取成 CSS 变量,配合用户的深色模式需求。uni-app 支持通过媒体查询做深色模式适配,这个组件完全可以在暗色背景下继续使用。