做管理后台时遇到一个表单需求:排班时间只需要精确到小时和分钟,UI稿上是一个双列滚轮,点击输入框弹出,选完自动回填。当时组件库里要么太重、要么样式根本改不动,我干脆在Vue3里自己写了一个小时、分钟的时间选择框组件。整个实现过程花了两天,踩了几个坑,也把思路彻底理清了,今天整理成这篇实践记录,供同样需要自定义时间选择器的朋友参考。
这个组件适合以下场景:表单里要用时间但不想引整包组件库;UI对弹出层、滚轮样式有特殊要求;或者想把小时和分钟的联动逻辑完全掌握在自己手里。本文会从组件拆分、滚轮交互实现、弹出层挂载、样式定制到常见问题排查,一步步讲清楚,代码尽量精简,可以直接抄进项目里改。
1. 为什么不用现成的时间选择组件
1.1 需求分析:我要的是一个什么样的时间选择框
先明确产品需求,这一点很关键。这次项目的表单是这样:用户要选择两个时间点,比如“09:30 到 17:50”,粒度只到分钟,不需要秒,也不涉及跨天。交互上UI要求是桌面端弹出层,里面左右两列,小时一列、分钟一列,滚动方式跟移动端那种滚轮选择器差不多。选完之后,输入框显示格式化的字符串,弹层自动关闭。
这个需求听起来简单,但展开后有几个核心点:第一,小时范围固定0到23,分钟范围0到59,数据本身是静态的;第二,需要处理滚动吸附,也就是滚动松手后停在最近的整项上,不能出现“09:3”这样不上不下的中间态;第三,选中值要实时反馈到输入框,并且支持程序化设置初始值;第四,弹出层要跟随输入框出现,并且不能遮挡视口周边的重要操作区。
我把这些点列出来之后,第一反应是直接用现成的组件。但仔细想了一下,问题不少。
1.2 方案对比:原生、组件库与自研的取舍
先说原生方案:用<input type="time">。这个方案最大的优点是零成本,但缺点也很明显——不同浏览器的UI风格差异很大,Chrome在桌面上是步进器加下拉面板,Safari是滚轮,Firefox在某些平台直接是文本输入。UI稿要的是固定双列滚轮样式,原生方案根本没法统一,更别提样式定制了。如果只是想快速做个能用的功能,这个可以,但要做好“每个浏览器长的不一样”的心理准备。
组件库方案呢?当时项目里其实引了Element Plus,它自带el-time-picker,功能很全,支持快捷键、范围选择、秒、步进,还有各种格式化参数。但问题出在样式改造上:UI稿的弹层要窄一些、圆角更大、小时和分钟的列高和外部表单控件一致,用组件库的样式覆盖需要写很多深层选择器去改内部结构,而且el-time-picker内部用的是自研的滚动面板,想调整到和UI稿完全一致,成本不低。另一个隐性问题是对打包体积不敏感的人可能无所谓,但团队里有老哥专门查过,时间选择相关的组件树进了页面会让首屏多不少字节,在体感上也会有影响。
自研方案的好处正好对上痛点:组件的DOM结构我完全可控,样式想怎么改就怎么改;小时分钟两列展示和联动逻辑自己写,完全匹配产品设计;代码量其实不大,核心组件两百行出头就能做到。缺点是滚轮交互有一定门槛,需要处理好滚动检测、吸附、初始定位这些细节。但这部分正好是前端基本功,写一次后面可以复用很久。
权衡下来的结论是:如果项目里只出现一两次,而且时间格式要求随意,直接用原生input或者组件库没问题;但如果UI强调视觉一致性、弹出层交互细节多,或者公司内部有轻量组件沉淀的需求,自研是更合理的路径。我做这个组件还有一个理由:几个不同项目都在用同一个设计规范,自己写一次,打包成私有组件,后续复用和维护成本摊薄得很快。
2. 组件拆分与数据流设计
2.1 整体结构:面板组件与弹出层组件分开
动手写之前,我先把组件拆成了两层。第一层是一个内部滚轮面板组件,叫TimeColumnPanel,职责是渲染小时列或分钟列,并负责滚动交互和选中项高亮;第二层是对外的TimePicker组件,负责整体弹层展示、输入框回填、打开关闭逻辑。这样拆的好处是:小时和分钟可以复用同一个面板组件,只是传入的列表数据和步进参数不同;以后如果要扩展秒列,也是一样的套路,不用动弹出层逻辑。
有人可能会问,为什么不在TimePicker里直接渲染两列?因为那样的话,小时列和分钟列的滚动检测逻辑就混在一起了,而且每个列都要单独维护滚动状态,代码会显得臃肿。抽成独立面板后,每个面板自己管自己的滚动位置,父组件只需要读它的选中值即可,思路清晰很多。
<!-- 调用方 --> <TimePicker v-model="timeValue" />对外暴露的接口很简单,就一个v-model,绑定一个"HH:mm"格式的字符串。内部实现里,TimePicker会把字符串拆成hour和minute两个数字,分别传给小时面板和分钟面板,面板再各自管理滚动定位。
2.2 Props与事件契约设计
组件之间通信,先把契约定好。TimeColumnPanel的Props这样设计:
| 参数 | 类型 | 说明 |
|---|---|---|
values | number[] | 全部可选项,比如小时列传入0-23,分钟列传入0-59 |
selected | number | 当前选中值 |
step | number | 滚动步进,分钟列常用1或5 |
format | (val: number) => string | 格式化函数,小时和分钟列会各自处理两位显示 |
它向外抛出一个update:selected事件,子面板滚动选中某个值时,通知父组件。TimePicker负责收集小时和分钟的更新事件,合并成完整时间字符串更新到v-model。
这里有个设计细节值得展开:为什么要用format函数而不是面板内部写死格式化逻辑?因为小时列和分钟列的显示规则虽然都是补零,但语义不同。小时可以显示成“09”,也可以在某些业务需求下显示成“9时”,分钟则一般总是两位数。把格式化逻辑通过Props传入,面板组件就成了一个纯展示加交互的容器,数据显示规则完全由调用方控制,复用性高很多。
父子组件的事件交互如下:
<!-- TimeColumnPanel 内部 --> <button v-for="item in values" :key="item" class="tp-panel__item" :class="{ 'tp-panel__item--active': item === selected }" @click="handleItemClick(item)" > {{ format(item) }} </button>每个可选项用button渲染,这在可访问性上比div好,键盘Tab、回车都能直接触发选中。面板对外只做一件事:用户点击了某一项,就抛出update:selected。
2.3 为什么选中项要反过来驱动面板定位
刚写第一版时,我踩了一个方向上的坑:以为面板只需要监听“用户滚动”这一种输入来源,于是直接在面板滚动时更新选中值。但实际使用中,还有另一种输入来源——外部传入的selected变化。比如首次打开时,输入框里已经有“14:30”,面板打开后两列要定位到14和30;再比如用户清空表单时,弹出层里的面板可能要重置到00:00。
如果面板的定位状态由两个方向的数据共同决定,就必须指定谁是“唯一数据源”。我的做法是:用户滚动只驱动选中值的变化,而数据源selected变化时,面板通过watch重新计算滚动位置。这样,用户滚动的时候我不会因为“选中值 -> 滚动位置”的监听回调再次调整滚动,造成滚动抖动。这个单向数据流的设计,把容易出bug的双向循环斩断了。
3. 核心实现:滚动吸附与选中检测
3.1 面板DOM结构与样式准备
滚轮面板的HTML结构不复杂,关键在于滚动特性。我采用了原生滚动加scroll-snap的方案,而不是像某些组件库那样用手势位移配合动画实现。原生滚动的优势是移动端惯性滚动体验好,代码也没有复杂的触摸计算,唯一要处理的是滚动停止后做吸附和选中检测。
结构如下:
<div class="tp-panel" ref="panelRef"> <div class="tp-panel__scroll"> <div class="tp-panel__item" v-for="item in values" :key="item"> {{ format(item) }} </div> </div> </div>.tp-panel__scroll是一个可滚动容器。为了让选项在滚动中和滚动后都能停留在中间区域,我给这个容器设置了scroll-snap-type: y mandatory,每个选项设置scroll-snap-align: center。这样滚动松手后,浏览器会自动把某项吸附到竖直居中位置,体验很接近移动端的滚轮选择器。
样式上的关键参数:
.tp-panel__scroll { height: 216px; overflow-y: auto; scroll-snap-type: y mandatory; padding: 90px 0; /* 让第一项和最后一项也能滚动到居中的关键 */ } .tp-panel__item { height: 36px; line-height: 36px; scroll-snap-align: center; }简单算一下:面板可视区高度216px,每项高36px,那么同时能看到6项。如果滚动容器不加padding,第一项和最后一项无法滚动到中间位置(因为滚动到顶后就不能再往上了)。为了让两端也能居中,我给容器上下各加90px的padding,也就是(216 - 36) / 2,这样首项和末项滚动到边界时刚好也处于可视区中央。这个细节如果不处理,用户会发现前后两项永远停在顶部或底部,体验很割裂。
3.2 滚动事件里的选中项计算
接下来说最核心的滚动检测。原理是这样的:滚动容器在任意时刻,都有一组滚动偏移量scrollTop;容器高度已知,每项高度已知,我可以算出当前可视区中央位置对应哪个选项。
计算公式可以理解为:当前居中的那一项在列表里的索引,等于“滚动偏移量加上半个可视区高度之和,除以单项目高度后取整”。用公式表示就是:
const itemHeight = 36 const centerOffset = scrollTop + scrollHeight / 2 const activeIndex = Math.round((centerOffset - paddingTop) / itemHeight)其中paddingTop是容器内边距90px,之所以要减掉,是因为scrollTop是从内容区顶部开始计算的,但我们想要的中央位置是从整个内容(包括padding)的物理位置映射到选项索引。
我写了一个函数来实时获取当前选中项:
function getActiveIndex() { const scrollEl = scrollWrapRef.value const scrollTop = scrollEl.scrollTop const centerInContent = scrollTop + scrollEl.clientHeight / 2 // 因为上下padding相同,直接用中心位置减单项目一半高度就能映射索引 return Math.floor((centerInContent - itemHeight / 2) / itemHeight) }这里用floor而不是round,为了保证在两项中间边界时取的是偏上那项,配合scroll-snap的居中吸附,实际误差很小,用户感知不到。
滚动过程中需要不断更新高亮样式。我在scroll事件里算完索引后,定位对应的item元素,给它设置高亮class。高亮样式是最典型的中间项放大加深,上下项淡出,视觉上有梯度和纵深感。
3.3 滚动停止后的精确吸附
原生scroll-snap能解决大部分吸附,但我发现有些情况下,尤其是触控板的精准滚动或者鼠标滚轮的极小步进,会停在两个选项之间。为了百分百精确,我在scroll事件上加了一个防抖处理:滚动停止200毫秒后,手动调用scrollTo把选中项精确滚动到居中位置。这个兜底逻辑能保证永远有且只有一项高亮。
实现里有几个细节要注意。防抖结束后执行吸附,要读取当前计算出来的activeIndex,然后直接设置:
function snapToIndex(index) { const scrollEl = scrollWrapRef.value scrollEl.scrollTo({ top: index * itemHeight, behavior: 'smooth' }) }不需要加padding计算,因为scrollTo的目标是内容坐标,而内容坐标从0开始就是第一项位置。但要注意,这里没有考虑padding,因为容器的padding是在内容区外,内容区内第一个选项的位置就是0。这个定位和前面算索引时的坐标体系不一样,容易记混,我后来加了注释才避免绕晕。
3.4 初始定位与外部值变化回写
组件首次挂载时要定位到初始选中项,此时selected可能有值也可能没值。我在onMounted里用nextTick等待DOM渲染完成后,直接把滚动位置设置到对应索引:
onMounted(() => { nextTick(() => { const index = values.value.indexOf(selected.value) if (index > -1) { snapToIndex(index, false) // 第二个参数控制是否平滑滚动 } }) })注意初次定位不要加平滑动画,否则打开弹层时会看到滚轮从顶部“唰”地滚下来,很难看。改用behavior: 'auto'瞬间定位,保持静止。
对外部selected变化的监听,同样先和当前滚动位置对应的索引比对,不一致才滚动过去,减少无意义的滚动触发。
分钟列表还有一个特殊逻辑:当step=5时,可选项是[0, 5, 10, ...55],而当前分钟值如果是23,我需要把它向下取整到最近的20,或者向上取整到25。这个取整逻辑放在TimePicker组件里,而不是面板里,因为面板只负责“列表里有什么项就展示什么项”,选择粒度由数据生成时决定。
3.5 小时与分钟的变化联动取舍
联动逻辑是这个组件里最具业务属性的部分。我遇到的实际需求是:用户在设置“开始时间”和“结束时间”时,如果结束时间的小时小于开始时间的小时,表单校验会不通过。对于这种场景,要不要在小时变化时自动重置分钟?
我的结论是:默认不做自动重置。原因很简单,用户可能故意选择“10:20到10:45”,分钟应该保持不变。如果小时一变就把分钟清成0,反而打断了用户输入节奏。更合理的做法是,联动逻辑通过配置项开启,并且联动行为只是“检查非法后修正”,而不是“无脑重置”。
实现上,我给TimePicker增加一个可选PropminTime和maxTime字符串。当存在约束时,小时列滚动到某个值后,父组件会判断当前分钟是否落在合法区间内;不合法就自动修正到区间边缘。这个逻辑数据量不大,完全可以在选值合并时做校验。真正的业务约束建议仍然放在表单校验层做,组件只在交互层做基本兜底。
4. 弹出层封装与挂载细节
4.1 用Teleport避免定位和层级问题
弹出层最容易遇到的是父容器overflow: hidden导致浮层被裁剪,或者组件树中某个祖先设置了position和z-index导致弹层层级错乱。这次我直接用<Teleport to="body">把弹出层传送到body下,从根本上绕开裁剪问题。这个方案在Vue3里特别顺手,也不需要自己管理portal节点。
传送出去之后,定位信息就要自己算了。我通过输入框的getBoundingClientRect()拿到它的坐标,再结合弹出层的尺寸,算出position: fixed下的top和left。这里的关键是弹出层的宽度建议至少和输入框一样宽,视觉上才会贴合。
还有一个细节是边缘适配。如果输入框离视口底部太近,弹出层默认向下展开会被挤出屏幕。我做了个判断:当输入框底部到视口底部的距离小于弹出层高度时,就向上展开,也就是bottom取视口高度减输入框顶部坐标的值。这样几乎不会出现弹层出界的情况。
const rect = inputRef.value.getBoundingClientRect() const panelHeight = 260 const spaceBelow = window.innerHeight - rect.bottom const showAbove = spaceBelow < panelHeight popupStyle.value = showAbove ? { bottom: window.innerHeight - rect.top + 8 } : { top: rect.bottom + 8 }4.2 点击外部关闭、清空与确认操作
弹出层的关闭时机一般有两个:外部点击,或者用户确认选择。外部点击我采用在document上挂click监听的模式,在mousedown阶段记录点击目标,在click阶段判断目标是否在弹层内部。为什么不直接用click判断?因为弹层内部操作往往是“点击面板项”加“点击确认按钮”两步,中途如果目标已经移出弹层,容易出现误关。记录mousedown目标可以避免这种情况:用户按下时在弹层内,就算最后click目标变化,也不关闭。
组件内部还会维护一个“临时选中值”。用户滚动小时列和分钟列时,更新的是临时值,输入框不变;直到点击确认按钮,才把临时值合并写入v-model并关闭弹层。这样用户滚动半天不满意,直接点击外部关闭,原来的值不会被破坏。
清空操作在业务表单里很常见,我在弹出层底部加了一个“清空”按钮,点击后把值置为空字符串并关闭弹层。对v-model的语义来说,空字符串表示未选时间。
4.3 阻止滚动穿透
移动端浏览器里,弹层滚动时手指稍微一滑,弹层滚到底后会把背后页面的滚动带动起来,这就是滚动穿透。两个方案可以解决:一个是在弹层打开时给body加overflow:hidden,并锁定touchmove事件;另一个是在滚动容器上检查滚动边界,到底或到顶时阻止默认行为。
我用的是后者里比较稳的做法:在滚动容器的touchmove事件中,判断scrollTop是否命中边界,没有更多滚动空间就preventDefault()。不过要注意,这个事件需要绑定在容器的原生DOM上,且不能声明成被动监听,否则preventDefault无效。
function onTouchMove(e) { const el = scrollWrapRef.value if (el.scrollTop <= 0 && e.deltaY < 0) e.preventDefault() if (el.scrollTop + el.clientHeight >= el.scrollHeight && e.deltaY > 0) e.preventDefault() }5. 样式定制、常见问题与扩展方向
5.1 用CSS变量让面板适应项目主题色
组件如果只满足当前项目需求,那样式爱怎么写怎么写。但我想让它稍微通用一点,于是把主要的视觉参数抽成了CSS变量:--tp-item-height控制选项高度、--tp-active-color控制选中高亮色、--tp-font-size控制字体大小。这样不同业务项目不用改组件源码,只需在全局样式中覆盖变量就能换肤。
面板中间选中项的高亮样式,除了颜色之外,还做了放大和字体加粗。放大我用transform: scale(1.05)而不是改font-size,避免布局抖动;字体加粗则会让选中项的宽度稍微变化,需要预留一点水平padding,否则会有1到2像素的跳动。这个细节在暗色主题下更明显,我补了个等宽字体渲染,减少跳动。
5.2 高频踩坑记录与排查方式
我把自己实现过程中踩过的坑和对应的排查思路整理成一张表,方便快速定位问题:
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 首项和末项无法居中 | 滚动容器没有上下padding | 容器上下各加(容器高-选项高)/2的padding |
| 选中项高亮和滚动位置不匹配 | 计算索引时坐标体系没统一 | 确认用的是内容坐标还是元素偏移坐标,统一减掉padding |
| 鼠标滚轮滚动时高亮闪烁 | scroll事件频繁触发,防抖过短 | 防抖时间调到200ms左右,并加上滚轮事件节流 |
| 快速点击确认时值没更新 | 临时值还没有从面板同步到父组件 | 确认按钮的点击处理改为读取最新的nextTick值 |
| 弹出层出现在错误位置 | 未考虑页面滚动偏移 | 使用position: fixed并基于getBoundingClientRect实时计算 |
| 移动端弹层弹出时背景跟着滚 | 滚动穿透 | 在容器touchmove边界处preventDefault |
| 打开弹层瞬间滚动动画跳动 | 初始定位用了平滑滚动 | 初次挂载定位用behavior: 'auto' |
其中滚动跳动这个坑最隐蔽。第一次写的时候,onMounted里直接用smooth滚动定位,弹层打开后肉眼可见滚轮从顶部滚到指定位置,看起来就像动画卡顿。改成auto之后问题消失。如果你做的弹层本身有进场动画,也要注意滚动定位和进场动画并发执行时的视觉冲突,可以通过requestAnimationFrame或延迟定位来解决。
5.3 后续扩展:秒、12小时制和范围选择
这个组件的设计目前只服务到分钟。如果哪天需求变成需要秒,可以沿用同一个TimeColumnPanel,再加一列秒面板,注意数据量变成60项,面板高度会高一点,适配好高度即可。更常见的扩展是12小时制,也就是额外加一列AM/PM,这时小时的值要映射到0-11和12-11的显示逻辑,我建议把显示格式和真实值分离,面板仍然传真实的24小时数值,format函数负责展示成带AM/PM的文本。
范围选择是最重的扩展方向,需要同时维护开始时间和结束时间两套状态,弹层里变成四列(开始小时、开始分钟、结束小时、结束分钟),联动逻辑会变复杂。如果做这一版,建议把联动判断提成一个独立的校验函数,而不是塞进面板组件里,保持面板纯粹。
5.4 一个小技巧:键盘上下键支持
最后分享一个提升体验的小技巧:给滚动面板的每一项都加上tabindex="-1",并监听容器的keydown事件,当焦点在弹层内时,按上下方向键会更新选中索引并滚动到对应位置。这个能力对桌面端用户特别友好,尤其是填写表单键盘操作多的时候,时间选择变成“按方向键调整,按回车确认”,效率高不少。实现起来只需在keydown里判断ArrowUp和ArrowDown,然后调用snapToIndex并更新选中值即可。
我后来把这套组件沉淀到了团队内部组件库里,后续几个项目直接复用,只改CSS变量就适配了不同视觉主题。有些人可能觉得自己写时间选择器是重复造轮子,但我的体会是,当一个交互在项目中反复出现、且对样式和细节有明确要求时,把轮子做成自己的,维护成本和定制成本都远低于长期覆盖第三方样式。如果你也在做类似的需求,不妨照着上面的思路先搭一个最小版本,跑通交互后再迭代样式和扩展功能。