Vue3手写时间选择框:从零实现双列滚动组件
1. 从需求到落盘为什么我选择了手写这个时间选择框先别急着吐槽“Vue3生态里不是一抓一大把时间选择器吗”我当时接到这个需求的时候也是这么想的。但当你真的打开某个轻量级 UI 库的文档想找一个只选小时和分钟、不要秒、不要日期面板的组件时你会发现选项其实很少。很多库的时间选择器是跟日期选择器绑死的你引入它往往意味着同时引入一大坨日期逻辑另一些库倒是支持formatHH:mm但样式跟项目设计稿差了十万八千里你要在深层的样式隔离里一层层::v-deep去改改完还容易在下次升级组件库时被覆盖掉。这个需求的本质并不复杂一个输入框点击后弹出一个面板面板里有两列——左边是小时00-23右边是分钟00-59滚动或点击选中后回填到输入框。它没有日期、没有秒、没有复杂的范围联动但它需要一个干净的交互闭环、一个可控的样式体系以及足够轻量的体积。于是我就决定自己写。我的目标非常明确在 Vue3 组合式 API 下不依赖任何第三方 UI 库从零实现一个带有小时、分钟双列滚动选择的时间选择框组件。它支持键盘操作、支持双向绑定v-model、支持自定义插槽、支持业务方常见的快捷选择比如“整点”“半小时”、并且能在表单校验里正常工作。这篇文章我会把从设计思路到最终落地的完整过程拆开来讲包括核心代码的编写思路、为什么每个关键的交互要那样处理、以及我在实际开发中踩过的几个比较典型的坑。如果你也在做类似的轻量组件或者想加深对 Vue3 组件封装的理解这篇应该能给你一些参考。2. 组件设计先想清楚交互闭环再动手写代码2.1 拆解需求这个组件究竟要做什么在写第一行代码之前我把需求拆成了四层第一层输入框展示层。用户看到一个输入框里面展示当前选中的时间值。没选中时显示占位符选中后显示HH:mm格式的文本。第二层弹出面板层。点击输入框弹出独立面板面板左侧是小时列表右侧是分钟列表。列表支持鼠标悬停预览、点击选中、滚动跟随定位。第三层交互回填层。无论是通过点击还是滚动最终要有一个“确认选中”的时机把当前高亮的值写回输入框并关闭面板。同时v-model要同步更新。第四层扩展能力层。比如支持禁用某个时间段、支持快捷选项、支持在表单里配合校验、支持回车确认、支持点击外部关闭。这些不一定是第一版就要做的但设计时要预留位置。这个拆解过程非常重要。因为很多初学者一上来就写模板写着写着发现这里要加状态、那里要处理事件最后组件膨胀到几百行。先想清楚层与层之间的边界后面每一步推进都会快很多。2.2 技术选型为什么用封装的子组件而不是一个巨型单文件这个组件如果全部塞进一个.vue文件里大约会到 300 行以上包含模板、脚本、样式混在一起。当然不是不行但后期的可维护性会很差。我的做法是拆成三个部分父组件TimePicker.vue负责整体状态是否展开、当前展示值、小时和分钟的临时选中值、弹层管理、对外暴露v-model和各类配置项。滚动选择列组件TimeColumn.vue封装单列的滚动选择逻辑小时列和分钟列复用同一套组件只是传入的数据范围和渲染格式不同。工具函数time-utils.js处理时间格式化、补零、校验、比较大小等纯函数逻辑。这样拆的好处是小时和分钟的联动逻辑在父组件里统一管理而单列内部的滚动、点击高亮、键盘上下移动等交互全部收敛在TimeColumn内部互不干扰。后续如果想扩展秒列只需要在父组件里再加一个TimeColumn实例传入0-59再加一个format拼接逻辑即可。提示组件拆分的粒度不是越细越好。如果每个子组件的交互都要和父组件大量同步状态拆分的价值就会大打折扣。像“小时列”和“分钟列”这种高度同构、只是数据范围不同的场景才是适合复用的典型场景。2.3 状态设计区分“显示值”和“确认值”是关键这可能是整个组件里最容易踩坑的地方。很多人在实现的时候只用一个timeValue状态贯穿全程用户滚动小时列表时立刻修改这个值。表面上看很好但随之而来的是一堆问题用户只是把鼠标滚到 12 上还没点击输入框里的值就变成 12:00 了用户想取消这次选择发现值已经被改掉了。我的做法是拆成三个状态displayValue输入框中当前显示的文本也是个字符串格式是HH:mm这是外界v-model的真实映射。selectingHour/selectingMinute弹出面板中用户正在浏览、但还未确认的小时和分钟是临时值。isOpen面板是否展开。交互规则是点击输入框 → 打开面板 → 用当前displayValue初始化selectingHour和selectingMinute→ 用户在列上滚动或点击修改这两个临时值 → 当点击“确定”按钮、按下回车、或者点击列上的某个值后才把临时值组合成新的HH:mm字符串写入displayValue同时触发update:modelValue事件关闭面板。这样设计的好处是用户可以随意浏览所有时间而不会污染已选值只有明确的确认动作才会真正生效。这个交互模式和原生日期选择器的“选定后自动回填并关闭”略有不同但在这个场景下更稳妥尤其适合表单场景用户往往需要反复确认再提交。3. 从外到里搭架子先跑通弹层再做滚动交互3.1 基础结构输入框 条件渲染的弹层第一版我先不考虑滚动只做一个“点击输入框弹出静态面板点击某个小时或分钟回填”的版本。因为滚动交互的复杂度主要在于列表定位如果连弹层都没跑通就去写滚动会分不清问题出在哪一层。模板核心结构大概是这样的template div classtp-time-picker refcontainerRef input classtp-input :valuedisplayValue readonly :placeholderplaceholder focusopenPanel keydownonInputKeydown / Teleport tobody div v-ifisOpen classtp-panel :stylepanelPosition TimeColumn :itemshourOptions :selectedselectingHour selectonSelectHour / TimeColumn :itemsminuteOptions :selectedselectingMinute selectonSelectMinute / div classtp-panel-footer button typebutton clickconfirmSelect确定/button button typebutton clickcancelSelect取消/button /div /div /Teleport /div /template这里有三个细节值得展开说明。第一个细节是Teleport tobody。如果不做传送门弹层就会被嵌在组件内部某个带overflow: hidden或transform的祖先节点里导致面板被裁切或定位错乱。传送到body后面板的定位就只依赖输入框的绝对坐标简单可控。第二个细节是readonly。输入框不接受用户手动输入因为时间格式的输入校验逻辑很麻烦——你允许用户敲一个9:5还是强制09:05键盘上下键该怎么响应这些需要额外处理。干脆做成只读用户只能通过面板选择。第三个细节是面板展开后输入框和面板之间的坐标对应关系。这个我在后面的定位章节详细讲这里先记住一个原则永远不要用position: absolute相对输入框定位因为面板被传送到了body下已经不是输入框的子元素了。正确的做法是获取输入框的getBoundingClientRect()再用fixed定位。跑通这一版后至少验证了三件事点击展开和收起、点击面板外部关闭、选值后回填输入框。接下来才进入真正有挑战的部分——滚动选中列。3.2 TimeColumn 的交互设计滚轮滑动与点击高亮TimeColumn接收两个主要参数一个项数组items和当前的选中值selected。它内部维护一个“当前高亮索引”highlightIndex这个索引对应面板里中间那条高亮带显示的值。它的模板是一个固定高度比如 200px的容器内部有一个transform: translateY(...)的列表template div classtp-column :style{ height: columnHeight px } wheel.preventonWheel div classtp-column-mask-top / div classtp-column-mask-bottom / div classtp-column-highlight / div classtp-column-list :style{ transform: translateY(${listOffset}px) } div v-for(item, index) in items :keyitem classtp-column-item :class{ is-active: index highlightIndex } :style{ height: itemHeight px, lineHeight: itemHeight px } clickhandleClickItem(index) {{ item }} /div /div /div /template核心逻辑在onWheel里。每次滚轮事件我根据event.deltaY的正负方向决定是增加还是减少highlightIndex然后 clamp 在[0, items.length - 1]区间内。这样每次滚动一格对应一个值比直接根据deltaY的大小换算要稳定得多——因为不同鼠标、不同触控板的deltaY差异非常大如果直接累加用户体验会忽快忽慢。const onWheel (event) { const direction event.deltaY 0 ? 1 : -1 updateHighlight(highlightIndex.value direction) }updateHighlight会做三件事更新highlightIndex、更新列表的translateY偏移量、把当前高亮的值通过emit(preview, value)通知父组件。预览值也就是父组件里的selectingHour/selectingMinute。这里有个很重要的点滚轮只改变预览值不确认值。这一步对应我们前面讲的“临时值状态设计”。列表的偏移量计算公式是const listOffset computed(() -1 * (highlightIndex.value - 2) * itemHeight.value)这里-2的含义是让高亮带始终定位在可见区域的第 3 个位置前 2 个作为缓冲。如果高亮索引是 2偏移量是 0那么第 3 项正好落在高亮条的位置往上滚动列表往上移高亮条对应的值一直在变。这个“偏移量 (当前索引 - 头部缓冲数量) * 行高”的思路是整个滚动视觉的核心理解了它后面无论加什么动画都好办。3.3 为什么用 translateY 滚动而不是直接设置 scrollTop如果你之前做过类似的滚动列表你可能会问为什么不用scrollTop把容器设置成overflow: auto直接操作scrollTop highlightIndex * itemHeight不是更简单吗确实更简单但我没用它原因有两点。第一点是可控性。用scrollTop时浏览器会介入滚动尤其在触控板或快速连滚的情况下会有惯性、回弹你很难精确保证“高亮索引 5 就停在第 5 个位置”。而且当你需要在点击某个值时立即跳转scrollTop的赋值有时会因为浏览器内部的滚动动画而延迟。translateY则完全由 JS 控制我说让它停在哪它就必须停在哪。第二点是性能。translateY只触发合成层的变化不触发重排滚动 60 个div的列表完全无压力。如果用scrollTop浏览器要处理滚动容器内部的布局计算虽然现代浏览器优化得不错但这个场景下没必要冒这个险。当然translateY方式也有一个要注意的地方列表首尾需要补充空元素。因为偏移量计算会把列表往上移当高亮索引接近 0 时如果列表是从第一个元素开始的顶部会露出空白。我的处理是在列表开头和结尾各加 2 个透明的占位div让首尾值也能平滑滚到高亮带的位置。注意占位元素的高度必须严格等于itemHeight否则偏移量计算会错位。我建议用一个常量ITEM_HEIGHT 36统一存储在 config 文件里不要在各处硬编码这个数值。3.4 点击选中与滚轮选中的统一入口组件里点击一个列表项会触发handleClickItem(index)逻辑很简单把highlightIndex直接设为index然后同时执行“更新预览值”和“确认值”两步。很多实现里点击和滚轮的行为是两套代码这没问题但要注意一个边界滚轮滚过某项时那项只是预览点击某项时应立即确认并关闭面板。我的做法是让handleClickItem内部调用一次confirmSelect从而复用父组件的确认逻辑。const handleClickItem (index) { setHighlightIndex(index) emit(select, items[index]) }父组件在onSelectHour和onSelectMinute里更新selectingHour/selectingMinute而是否关闭面板则由“点击的是分钟列”来决定——因为小时列点击后用户往往还想继续选分钟直接关闭会很恼火。具体的交互规则是点击小时列只更新小时并保持面板展开点击分钟列则更新分钟并立即确认回填关闭面板。这个逻辑在父组件里用select的回调来区分。4. 小时与分钟的联动别让 23:59 这个边界值坑到你4.1 动态生成小时与分钟的选项数组小时列表固定是 0 到 23分钟列表固定是 0 到 59。这里有个隐藏的粒度问题分钟列表是每个 1 分钟一个选项还是每 5 分钟、15 分钟一个选项业务方通常会提两种需求一种是精确到分钟60 个选项全列出来另一种是只允许选 5 的倍数、10 的倍数这样列表短、选择快。我会把粒度抽成minuteStep属性默认是 1但允许外部传 5、10、15、30。生成逻辑放到工具函数里export function generateOptions(min, max, step 1) { const options [] for (let i min; i max; i step) { options.push({ value: i, label: String(i).padStart(2, 0) }) } return options }这里要注意的是minuteStep必须是60的约数否则最后一段会出现一个到不了 59 的残缺值。5 和 10 都是 60 的约数没问题但 7 就不是你如果传 7生成到 56 就停了而 56 到 59 之间的时间全部无法选中。所以我在props里加了校验如果不是 60 的约数直接console.warn并在运行时修正为 5。4.2 格式化与回填v-model的字符串契约组件对外暴露的v-model值用什么类型字符串09:30是最直观的但要注意几个情况如果外部传入的值是9:30少一个前导零组件接受它但内部展示时统一转成09:30。如果外部传入的是Date对象我们只取它的getHours()和getMinutes()。如果外部传入的是undefined/null组件展示空占位符。所以我写了一个normalizeTimeValue函数在任何外部值进入组件时先做一层归一化。显示时用formatTimeDisplay补零确认时用buildTimeString拼接。export function formatTime(hour, minute) { return ${String(hour).padStart(2, 0)}:${String(minute).padStart(2, 0)} }4.3 一个容易忽略的边界滚动到 23、59 后再往下滚这个坑我在第一次实现的时候踩了。用户把小时滚到 23继续往下滚你猜会发生什么按常规直觉列表到底了应该停住不动。但如果你只用了clamp而没有处理“跨列联动”用户会觉得组件“卡住了”——因为他想表达的是“23 之后应该是 0”这是一个循环的直觉。很多大佬在做时间组件时都会支持这个循环滚动但我在第一版选择不做。原因很简单时间是线性递增的并不是环形结构23:59 之后是第二天 00:00它已经超出了“同一天”的选择范畴。强制做环形只会让用户困惑为什么我滚到 23 再滚一下会跳回 0今天是第二天了吗我最终的处理方式是在滚轮事件里判断如果当前已经是最大值且方向是向下给一个轻微的回弹颤动然后阻止继续滚动。这样用户能感知到底部边界又不会生硬地卡死。配合这个设计我在列底部加了一个“到底了”的渐变遮罩视觉上给用户一个“下面是留白”的暗示。这个渐变遮罩用 CSS 实现成本很低但对交互完成度的提升很明显。5. 键盘交互、外部点击关闭与指令式表单集成5.1 给输入框加上完善的键盘操作面板用鼠标操作没问题但键盘支持是很多开发者容易忽略的。我做了这组键位约定Enter如果面板展开确认当前预览值并关闭如果面板未展开打开面板并把当前值设为预览值。Escape关闭面板回退到打开前的displayValue丢弃所有临时预览值。面板展开状态下ArrowUp/ArrowDown在小时列、分钟列之间切换焦点Tab切换列的焦点。这个实现的难点在于焦点管理。输入框获得焦点后你按下ArrowDown事件首先落在输入框上这时需要把焦点转移到当前活动的列上否则后续的键位监听都拿不到。我的做法是给输入框绑一个keydown处理器在处理器里根据isOpen状态决定是打开面板并preventDefault还是调用TimeColumn暴露的focus方法。const onInputKeydown (e) { if (!isOpen.value) { if (e.key Enter || e.key ArrowDown) { e.preventDefault() openPanel() nextTick(() hourColumnRef.value.focus()) } } else { if (e.key Escape) { cancelSelect() } } }5.2 点击外部关闭监听 document 的 mousedown 而不是 click实现“点击面板外部关闭”时有一个经典顺序问题如果你在输入框上绑了focus去打开面板又在document上绑click去关闭面板那么点击输入框时两个事件会同一次交互内触发——click是mousedown和mouseup之后才触发的而面板是focus时打开的所以click触发时面板已经存在它会把刚打开的面板又关掉。对策是监听mousedown而不是click。因为mousedown发生在focus之前你可以在mousedown里判断点击目标是否在组件容器内如果不在标记一个“待关闭”状态等focus打开面板后再通过nextTick把这个标记消费掉不执行关闭。const onDocMouseDown (e) { if (!containerRef.value.contains(e.target) !panelRef.value.contains(e.target)) { isOpen.value false } }这个逻辑说起来绕但实际遇到时的确很头疼。它会让你怀疑自己是不是把focus和click的顺序搞混了。给一个简单的判断标准如果你发现面板刚展开就自动关闭十有八九是用了click而不是mousedown或者没有正确处理事件发生的先后顺序。5.3 适配表单从 ModelValue 到校验事件的桥接这个组件最终是要表单里用的所以v-model只是基础的协议。如果想配合表单校验库还需要提供一个blur事件让校验库能感知到用户“失焦”这个动作。对外传递change事件含义是“用户确认了一个新值”。对输入框设置name和required属性透传方便表单序列化和校验提示。我在emits里定义了两个额外事件emit(blur) emit(change, displayValue.value)表单页不需要额外引入这个组件的内部实现只要监听change就能拿到用户最终选中的时间文本配合校验规则比如必填、范围、格式就能完成表单校验闭环。6. 已经能用还不够扩展“快捷选项”与“范围禁用”6.1 增加快捷选择整点、半小时、当前时间一个时间选择器如果没有快捷选项多多少少有点瘸腿。我的方案是在面板底部加一排小型按钮由外部传入shortcuts数组来动态渲染const shortcuts [ { label: 当前时间, value: () new Date() }, { label: 09:00, value: 09:00 }, { label: 12:00, value: 12:00 }, { label: 18:30, value: 18:30 }, ]每个快捷项点击后直接confirmSelect并关闭面板。用函数形式定义value是为了支持“当前时间”这种动态值用字符串定义是为了支持静态值。这样可以保持扩展弹性又不引入过度设计。6.2 时间范围禁用在生成选项时做手脚禁用需求通常是两类禁用某个具体的时间点或者禁用早于/晚于某个时间点的范围。我对后者的实现方式是传入disabledHours/disabledMinutes函数函数签名是(hour, minute) boolean。在TimeColumn渲染items时对每个 item 调用一次禁用判断为 true 的项加上is-disabledclass并阻止点击。这里要注意的是“联动禁用”的场景比如禁用了 22:00 之后的时间那么当小时滚到 23 时分钟列表的 0-59 应该全部禁用。如果不在生成选项时联动用户就会看到小时 23 可以选但所有分钟都是灰的体验非常割裂。我的做法是把禁用判断放到父组件里用computed同时计算小时和分钟的禁用集合const minuteDisabledMap computed(() { const map {} minuteOptions.value.forEach((m) { map[m.value] isTimeDisabled(selectingHour.value, m.value) }) return map })这样当selectingHour变化时分钟列表会立刻响应禁用状态。7. Vue3 独有的响应式细节与性能优化7.1 用 shallowRef 而不是 ref 管理高频滚动的索引滚轮滚动时highlightIndex会非常频繁地变化。如果每次变化都触发 Vue 的深度响应式依赖追踪虽然这个量级不会造成瓶颈但有一个更优的做法——用shallowRef声明高亮索引和列表偏移量const highlightIndex shallowRef(0) const listOffset shallowRef(0)shallowRef只对.value的赋值做响应式追踪不对对象内部属性做深层代理。由于这里只涉及数字的赋值用shallowRef能减少不必要的代理开销。配合v-memo如果列表项足够复杂可以进一步减少更新时的 diff 开销。不过要强调的是这个优化在 60 个元素的列表里其实感知不到差异真正的收益是代码语义更清晰——它告诉后来者“这里只需要浅层响应式不要放深层对象”。7.2 Teleport 加 fixed 定位面板定位的实现细节面板通过 Teleport 到 body 后fixed定位的坐标怎么算我写了一个updatePanelPosition函数const updatePanelPosition () { const rect inputRef.value.getBoundingClientRect() panelStyle.value { position: fixed, top: ${rect.bottom 4}px, left: ${rect.left}px, width: ${rect.width}px, zIndex: 1000 } }这里有几个细节窗口滚动、窗口缩放时面板的定位需要重新计算。我在openPanel后绑定了scroll和resize监听关闭时移除。如果页面有多个时间选择器每个组件的面板都要独立计算不能共用一个全局变量。width跟随输入框宽度避免面板过宽或过窄造成视觉不协调。为了防止面板超出视口底部需要做一次top Math.min(top, window.innerHeight - panelHeight - 8)的修正。7.3 组件卸载时的清理别让全局监听泄漏这是一个非常致命但也非常容易被忽略的地方。很多开发者绑定了document.addEventListener(scroll, handler)却忘了在beforeUnmount里移除。尤其在 SPA 里组件不断创建销毁监听器积累到一定数量后会出现内存泄漏和事件风暴。我的做法是把所有全局监听的绑定和解绑集中在一处onMounted(() { document.addEventListener(mousedown, onDocMouseDown) window.addEventListener(scroll, updatePanelPosition, true) window.addEventListener(resize, updatePanelPosition) }) onBeforeUnmount(() { document.removeEventListener(mousedown, onDocMouseDown) window.removeEventListener(scroll, updatePanelPosition, true) window.removeEventListener(resize, updatePanelPosition) })这里scroll监听要传true捕获阶段否则组件内部某些元素的局部滚动事件不会冒泡到 window导致面板不跟随滚动。8. 踩坑实录这些问题都是真实发生过的8.1 弹层宽度塌陷明明设置了 min-width 为什么还是变窄了第一次实现时我信心满满地用输入框的offsetWidth给面板设置宽度。结果在某个页面里面板莫名其妙变窄了 20 像素。反复检查后发现问题出在 CSS 盒模型上输入框的offsetWidth包含border和padding但面板的box-sizing是border-box且有自己的内边距直接赋值导致整体收缩。解决方式是统一用getBoundingClientRect().width并在面板样式里也用border-box保证内外宽度一致。8.2 输入框只读后点击事件失效给输入框加readonly后你会发现 iOS 上部分浏览器的click事件不触发。查了下发现是readonly输入框对点击事件的处理存在兼容性差异。我的补救方案是给它额外绑定pointerdown作为兜底在pointerdown里判断如果面板未展开就调用openPanel。不过要注意pointerdown和mousedown不能同时绑同一个函数否则会出现打开又关闭的死循环。我最终的写法是pointerdown里只打开不关闭关闭逻辑只交给document的mousedown。8.3 分钟列点击瞬间面板关闭导致事件丢失这是一个比较隐蔽的 bug用户点击分钟列后如果“确认并关闭”和“点击事件冒泡到 document 的 mousedown 关闭”同时发生会导致点击行为被判定为“点击外部”而中断。虽然最终结果往往也是关闭了但会偶发出现值没有正确回填的情况。解决思路是在document的mousedown处理器里判断“当前点击是否发生在面板内部”。如果面板里触发了某项的点击event.target一定在面板容器内此时不执行关闭逻辑交给按钮自身的处理逻辑去关。这个小问题排查起来真的费了不少时间因为它是偶发的而且只在快速点击时出现。调试技巧是加一个console.log打印mousedown和click的触发序列一眼就能看出顺序问题。8.4 滚动穿透面板内滚轮滚动时页面也跟着滚面板区域滚动时如果鼠标滚轮在面板上方页面背景也会跟着滚动用户体验很差。给容器的wheel事件加.prevent修饰符能解决大部分问题但有个极端情况面板内已经滚到顶部或底部继续滚时浏览器还是会触发页面滚动。我在onWheel里加了边界判断const onWheel (e) { if ((highlightIndex.value 0 e.deltaY 0) || (highlightIndex.value items.length - 1 e.deltaY 0)) { return } e.preventDefault() }这样既保证了列表内部的滚动正常又不会让页面跟着乱滚。8.5 初始化时外部值晚于组件挂载有些业务场景里v-model的值是异步请求回来的组件挂载时displayValue还是空。如果你在mounted里直接用初始值去生成选项的选中索引就会拿不到值导致面板打开后显示的选中位置是错的。我的方案是使用watch监听传入的modelValue一旦外部值变化就重置内部状态watch(() props.modelValue, (val) { const { hour, minute } parseTimeString(val) selectingHour.value hour selectingMinute.value minute })这一步不做的话会有一个很反直觉的现象输入框显示12:30打开面板却高亮在00:00。9. 渲染优化列表项的虚拟化思路9.1 什么情况下需要虚拟滚动小时加分钟最多也就是24 60 84个 DOM 节点根本不需要虚拟滚动。但如果你把minuteStep降到 1又加上秒列0-59也就是 144 个节点浏览器依然毫无压力。所以严格来说这个组件不存在虚拟滚动的必要。但从架构上我还是在TimeColumn内部做了一层“只看可视区”的优化思路把transform偏移量计算出来后根据 高度、可见数量上下各加 2 个缓冲项只渲染这部分 DOM。具体实现就是把计算好的visibleItems传给渲染层const visibleItems computed(() { const start Math.max(0, highlightIndex.value - 2) const end Math.min(items.length, highlightIndex.value visibleCount.value 2) return items.slice(start, end) })如果真的选了“秒级 最小粒度”这种极端场景这个思路能直接平滑升级不需要重写组件。9.2 每次渲染的成本控制即使不虚拟化我也会刻意避免在滚动过程中触发不必要的重新渲染。方法是不把highlightIndex直接绑定到每个 item 的 class 判断上而是只对“高亮带”本身和被高亮的项做精确绑定。用 Vue 的响应式机制来说如果每个 item 的 class 都依赖highlightIndex那么滚动一格Vue 就要重新计算 60 个 item 的 class 绑定。虽然开销不大但滚轮高频触发时会产生无谓的工作量。优化措施是只用highlightIndex计算列表的transform而 item 的高亮 class 用“当前项是否等于选中值”来判断选中值只在确认时更新滚动过程中不变。这样滚动过程只改变 transform不触发大多数 item 的更新。10. 实际使用效果评估它到底值不值得手写10.1 代码量与性能的账整个组件下来父组件约 180 行、两个子组件总共约 220 行、工具函数约 60 行加样式文件约 500 行。作为对比如果引入一个大型组件库并按需引入时间选择器光 JS 可能就要多出 30KB 以上。如果你的项目里只有这一处需要时间选择手写的体积收益明显。性能上展开面板的耗时几乎可以忽略打开到渲染完成在 16ms 以内。滚动跟手度取决于translateY的流畅程度体感非常顺。10.2 对代码设计能力的提升手写组件最大的价值不在于省了那几十 KB而在于你真正理解了组件设计里那些微妙的东西什么是临时状态、什么是确认状态、事件触发的先后顺序怎么影响交互结果、怎么在不用scrollTop的情况下做出滚动效果。这些理解在以后遇到任何复杂组件需求时都会派上用场。10.3 什么时候不建议手写如果你的项目里已经有成熟组件库而且时间选择器只是其中一个非常标准的需求我建议直接用组件库的现成方案。手写一个轮子通常是在这种情况下才值得现成的组件不满足定制需求、引入成本太高、或者你希望通过实现它来加深对框架本身的理解。我这次手写是因为设计稿对交互有强制要求——面板内要加“当前时间”快捷入口、要自定义高亮样式、要跟全项目的圆角阴影体系一致。用现成库改样式反而比直接写一个更费时。11. 如果再让我做一次我会怎么优化11.1 移动端触控滚动的支持目前的实现主要针对桌面端鼠标滚轮。移动端要支持的话需要补充touchstart/touchmove/touchend的处理用手势位移换算成索引偏移。这个我还没做但架构上是可扩展的——只要在TimeColumn内部再维护一个touchStartY差值换算成Math.round(delta / itemHeight)再交给updateHighlight就行。11.2 面板动画的平滑度现在的实现里滚动是一格一格跳的。如果用户快速滚轮会感觉不够顺滑。优化方案是给translateY加过渡动画——transition: transform 80ms ease-out——这样但每次变化之间会有轻微滑动感。不过要注意直接在transform上绑定过渡会把“点击后跳转”也做成动画视觉效果在快速连点时反而不利索。更精细的做法是区分“滚动步进”和“跳转定位”两种模式给它们不同的过渡时长。11.3 多语言与格式扩展目前只支持HH:mm和H:mm两种展示格式。如果要支持HH.mm、hh:mm a12 小时制加 AM/PM最好把格式化逻辑抽成策略模式。尤其 12 小时制下小时范围是 1-12 而不是 0-23需要额外处理 AM/PM 切换。这个扩展我建议等真实需求出现时再加现在加属于过度设计。12. 把组件发布到团队仓库前我还做了这些事最后一个很实际的建议如果你决定要写这种类型的通用组件别让它只躺在某一个业务的 components 目录里。把它整理成一个独立目录配上 README写清楚用法、属性表、事件表然后发布到团队的私有包仓库里。这样下一个接手的人不用面对一段只有一个注释为“时间选择组件”的黑盒代码。我做了一份最简文档属性名类型默认值说明modelValueString-双向绑定值格式 HH:mmdisabledBooleanfalse是否禁用minuteStepNumber1分钟粒度需为 60 的约数placeholderString请选择时间输入框占位文案shortcutsArray[]快捷选项配置disabledHoursFunction-禁用小时判断函数disabledMinutesFunction-禁用分钟判断函数事件表也不再赘述核心就是update:modelValue、change、blur三个。文档写好未来的维护成本会低很多别人接手的幸福感也会高很多。我在实际写这个组件的过程中最深的感受是“看似简单的组件”会在细节处不断挑战你对框架交互底层的理解。每一步设计决策都不是为了炫技而是为了让使用它的人少踩一个坑。插槽、事件、响应式、Teleport、生命周期这些知识点单独拎出来都能背但把它们正确组合起来解决一个真实需求才是真正的能力所在。这也是我一直觉得手写小组件值得推荐的原因。