
做过小程序评分的同学应该都有这种经历需求方一句话“就是个星级评分拖一拖就行”落到开发这里往往要折腾大半天。直接用 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[typerange]在 App 端又要走原生渲染。你以为同一个组件三端长得一样实际到了线上偏差很大。blockSize 在某些平台的表现、轨道高度的一致性、拖动时数值更新的频率都可能有细微不同。这些不同平时不会暴露一旦你做的是评分这种“视觉敏感”交互就会被用户一眼看穿。更关键的是slider 没有“星级”这么一说。它能把值从 0 拖到 5但你拿到的是一个线性轨道上的圆点不是一排星星。想让它变成评分组件还需要在外面套一层文本或者图标把数值翻译成用户的视觉语言。1.3 自绘组件和原生组件的选择本质与其问“用什么组件”不如问“我到底要控制多少表现层的东西”。用原生 slider你控制的是属性用自绘组件你控制的是 DOM 结构、CSS、手势计算、事件时机。控制得越多自由度越高但代码量、调试范围、踩坑概率也同步上涨。这就像租房和装修的区别。原生 slider 是拎包入住的公寓优点在于快缺点在于风格统一很难做到品牌化自绘组件是毛坯房地基水电都要自己搞但是最后呈现出来的效果完全可以照着设计稿一比一还原。选择的关键不是哪个更好而是你的项目能不能为自由度买单。对于大多数评分场景我倾向于自绘因为评分的视觉要求在整个页面里往往是最抢眼的元素之一。一个评分功能做得粗糙用户对整个产品的信任度都会打折不值得为了省几小时开发时间放弃视觉还原度。2. 原生组件方案的落地与边界2.1 slider 基础用法与参数速查先看一段最简单的原生 slider 使用示例template view classslider-page slider :valuescore :min0 :max5 :step1 activeColor#ffb800 backgroundColor#e5e5e5 blockColor#ffffff :blockSize20 :show-valuetrue changeonSliderChange changingonSliderChanging / view classscore-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 classscore-box slider :valuescore :min0 :max5 :step1 changingonSliderChanging changeonSliderChange / text classscore-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的吸附。假设max5、step1实际手指位置算出 3.4Math.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 classslider-rate-wrap touchstarthandleTouch touchmove.stop.preventhandleTouch touchendhandleTouchEnd view classrate-bg text v-fori in max :keyi classstar :style{ fontSize: size px } ☆/text /view view classrate-fill :style{ width: fillPercent % } text v-fori in max :keyi classstar :style{ fontSize: size px } ★/text /view view classrate-thumb :style{ left: thumbPercent % } view classthumb-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 classpage rate-slider v-modelscore :size36 :step1 changesubmitScore / /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.preventhandleTouch第二给组件根节点加上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取不到值。这通常是因为事件绑定写错了。小程序环境里changeonSliderChange和changeonSliderChange($event)都可以但是如果你在模板里写成了changeonSliderChange(score)拿到的是当前状态值而不是事件对象后面再取detail就会报undefined。还有一个坑是 slider 的step属性。官方文档说默认 step 是 1但你如果设置了min0, max5, step0.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 逻辑分数突然变成 NaNbarRect 为空或宽度为 0组件挂载后重新 measureBar拖动时页面也跟着滚动缺少 touch-action 或事件 prevent设置 touch-action: none加 .prevent星星层错位两层星星间距、字号不一致用统一变量控制 margin 和 font-size拖动到末尾分数变为 0ratio 没有做 0-1 边界夹取对 ratio 做 clampH5 端 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 支持通过媒体查询做深色模式适配这个组件完全可以在暗色背景下继续使用。