1. 为什么需要自定义时间选择框在Vue3项目中我们经常会遇到需要用户选择时间的场景。虽然Element Plus等UI库提供了现成的el-time-picker组件但在实际业务中标准组件往往无法满足特定需求。比如只需要选择小时和分钟不需要秒需要限制可选时间范围如仅限工作时间需要自定义时间格式和显示样式需要与其他自定义组件联动我最近在一个后台管理系统中就遇到了这样的需求用户需要预约会议室但只需要精确到半小时为单位的时间选择。标准的el-time-picker组件虽然功能强大但包含了太多我们不需要的功能而且样式调整起来也很麻烦。2. 基础时间选择框实现2.1 组件基本结构我们先从最简单的实现开始创建一个基础的TimePicker组件template div classtime-picker select v-modelselectedHour classtime-select option v-forhour in hours :keyhour :valuehour {{ hour.toString().padStart(2, 0) }} /option /select span classtime-separator:/span select v-modelselectedMinute classtime-select option v-forminute in minutes :keyminute :valueminute {{ minute.toString().padStart(2, 0) }} /option /select /div /template script setup import { ref, watch } from vue const hours Array.from({ length: 24 }, (_, i) i) const minutes Array.from({ length: 60 }, (_, i) i) const selectedHour ref(0) const selectedMinute ref(0) /script style scoped .time-picker { display: flex; align-items: center; gap: 4px; } .time-select { padding: 8px; border: 1px solid #dcdfe6; border-radius: 4px; outline: none; } .time-select:focus { border-color: #409eff; } .time-separator { font-weight: bold; } /style这个基础版本已经实现了小时和分钟的选择功能。我们使用两个select元素分别显示小时和分钟选项通过v-model绑定选中的值。2.2 添加双向绑定为了让组件更实用我们需要添加v-model支持script setup import { ref, watch, defineProps, defineEmits } from vue const props defineProps({ modelValue: { type: String, default: 00:00 } }) const emit defineEmits([update:modelValue]) const hours Array.from({ length: 24 }, (_, i) i) const minutes Array.from({ length: 60 }, (_, i) i) const [initialHour, initialMinute] props.modelValue.split(:).map(Number) const selectedHour ref(initialHour) const selectedMinute ref(initialMinute) watch([selectedHour, selectedMinute], ([hour, minute]) { const timeString ${hour.toString().padStart(2, 0)}:${minute.toString().padStart(2, 0)} emit(update:modelValue, timeString) }) /script现在组件可以通过v-model与父组件通信了template TimePicker v-modelselectedTime / /template3. 增强功能实现3.1 时间间隔控制在实际项目中我们经常需要控制时间选择的间隔。比如会议室预约系统通常以15或30分钟为间隔script setup // 其他代码保持不变... const props defineProps({ modelValue: { type: String, default: 00:00 }, minuteStep: { type: Number, default: 1, validator: value [1, 5, 10, 15, 20, 30].includes(value) } }) const minutes Array.from( { length: Math.ceil(60 / props.minuteStep) }, (_, i) i * props.minuteStep ) /script使用时可以指定minuteStep属性TimePicker v-modelselectedTime minute-step30 /3.2 时间范围限制另一个常见需求是限制可选时间范围。我们可以添加min和max属性script setup import { ref, watch, defineProps, defineEmits, computed } from vue const props defineProps({ // 其他props... min: { type: String, default: 00:00 }, max: { type: String, default: 23:59 } }) const [minHour, minMinute] props.min.split(:).map(Number) const [maxHour, maxMinute] props.max.split(:).map(Number) const availableHours computed(() { return hours.filter(hour { if (hour minHour) return false if (hour maxHour) return false return true }) }) const availableMinutes computed(() { const currentHour selectedHour.value return minutes.filter(minute { if (currentHour minHour) return false if (currentHour maxHour) return false if (currentHour minHour minute minMinute) return false if (currentHour maxHour minute maxMinute) return false return true }) }) /script template div classtime-picker select v-modelselectedHour classtime-select option v-forhour in availableHours :keyhour :valuehour :disabledhour minHour || hour maxHour {{ hour.toString().padStart(2, 0) }} /option /select span classtime-separator:/span select v-modelselectedMinute classtime-select option v-forminute in availableMinutes :keyminute :valueminute :disabled (selectedHour minHour minute minMinute) || (selectedHour maxHour minute maxMinute) {{ minute.toString().padStart(2, 0) }} /option /select /div /template3.3 禁用状态和尺寸控制为了与Element Plus等UI库保持一致性我们可以添加disabled和size属性script setup const props defineProps({ // 其他props... disabled: { type: Boolean, default: false }, size: { type: String, default: default, validator: value [small, default, large].includes(value) } }) /script template div classtime-picker :class[ time-picker--${size}, { time-picker--disabled: disabled } ] select v-modelselectedHour classtime-select :disableddisabled !-- 选项 -- /select span classtime-separator:/span select v-modelselectedMinute classtime-select :disableddisabled !-- 选项 -- /select /div /template style scoped .time-picker--small .time-select { padding: 6px; font-size: 12px; } .time-picker--default .time-select { padding: 8px; font-size: 14px; } .time-picker--large .time-select { padding: 10px; font-size: 16px; } .time-picker--disabled { opacity: 0.6; pointer-events: none; } /style4. 高级功能与优化4.1 键盘导航支持为了提升用户体验我们可以添加键盘导航支持script setup import { onMounted, ref } from vue const hourSelect ref(null) const minuteSelect ref(null) const handleKeyDown (e, type) { if (e.key ArrowRight type hour) { e.preventDefault() minuteSelect.value.focus() } else if (e.key ArrowLeft type minute) { e.preventDefault() hourSelect.value.focus() } } onMounted(() { hourSelect.value?.focus() }) /script template div classtime-picker select refhourSelect v-modelselectedHour classtime-select keydowne handleKeyDown(e, hour) !-- 选项 -- /select span classtime-separator:/span select refminuteSelect v-modelselectedMinute classtime-select keydowne handleKeyDown(e, minute) !-- 选项 -- /select /div /template4.2 自定义样式与主题为了让组件更灵活我们可以添加对自定义样式的支持script setup const props defineProps({ // 其他props... primaryColor: { type: String, default: #409eff } }) /script style scoped .time-select:focus { border-color: v-bind(primaryColor); box-shadow: 0 0 0 2px rgba(v-bind(primaryColorRgb), 0.2); } /style script // 在script中添加计算属性 const primaryColorRgb computed(() { const hex props.primaryColor.replace(#, ) const r parseInt(hex.substring(0, 2), 16) const g parseInt(hex.substring(2, 4), 16) const b parseInt(hex.substring(4, 6), 16) return ${r}, ${g}, ${b} }) /script4.3 性能优化当选项很多时渲染大量option元素可能会影响性能。我们可以使用虚拟滚动技术优化script setup import { computed } from vue const visibleOptions 5 // 可见选项数量 const scrollPosition ref(0) const visibleHours computed(() { const start Math.max(0, scrollPosition.value) const end Math.min(hours.length, start visibleOptions) return hours.slice(start, end) }) const handleScroll (e, type) { const select e.target const optionHeight select.querySelector(option)?.offsetHeight || 30 const scrollTop select.scrollTop scrollPosition.value Math.floor(scrollTop / optionHeight) } /script template select v-modelselectedHour classtime-select scrollhandleScroll styleheight: calc(1.5em * 5 16px) /* 5个选项的高度 */ option v-forhour in visibleHours :keyhour :valuehour :style{ height: 1.5em } {{ hour.toString().padStart(2, 0) }} /option /select /template5. 完整组件代码与使用示例5.1 完整组件代码template div classtime-picker :class[ time-picker--${size}, { time-picker--disabled: disabled } ] select refhourSelect v-modelselectedHour classtime-select :disableddisabled keydowne handleKeyDown(e, hour) scrollhandleHourScroll option v-forhour in visibleHours :keyhour :valuehour :disabledhour minHour || hour maxHour :style{ height: 1.5em } {{ hour.toString().padStart(2, 0) }} /option /select span classtime-separator:/span select refminuteSelect v-modelselectedMinute classtime-select :disableddisabled keydowne handleKeyDown(e, minute) scrollhandleMinuteScroll option v-forminute in visibleMinutes :keyminute :valueminute :disabled (selectedHour minHour minute minMinute) || (selectedHour maxHour minute maxMinute) :style{ height: 1.5em } {{ minute.toString().padStart(2, 0) }} /option /select /div /template script setup import { ref, watch, defineProps, defineEmits, computed, onMounted } from vue const props defineProps({ modelValue: { type: String, default: 00:00 }, minuteStep: { type: Number, default: 1, validator: value [1, 5, 10, 15, 20, 30].includes(value) }, min: { type: String, default: 00:00 }, max: { type: String, default: 23:59 }, disabled: { type: Boolean, default: false }, size: { type: String, default: default, validator: value [small, default, large].includes(value) }, primaryColor: { type: String, default: #409eff } }) const emit defineEmits([update:modelValue, change]) const hours Array.from({ length: 24 }, (_, i) i) const minutes Array.from( { length: Math.ceil(60 / props.minuteStep) }, (_, i) i * props.minuteStep ) const [initialHour, initialMinute] props.modelValue.split(:).map(Number) const selectedHour ref(initialHour) const selectedMinute ref(initialMinute) const [minHour, minMinute] props.min.split(:).map(Number) const [maxHour, maxMinute] props.max.split(:).map(Number) const availableHours computed(() { return hours.filter(hour { if (hour minHour) return false if (hour maxHour) return false return true }) }) const availableMinutes computed(() { const currentHour selectedHour.value return minutes.filter(minute { if (currentHour minHour) return false if (currentHour maxHour) return false if (currentHour minHour minute minMinute) return false if (currentHour maxHour minute maxMinute) return false return true }) }) const visibleOptions 5 const hourScrollPosition ref(0) const minuteScrollPosition ref(0) const visibleHours computed(() { const start Math.max(0, hourScrollPosition.value) const end Math.min(availableHours.value.length, start visibleOptions) return availableHours.value.slice(start, end) }) const visibleMinutes computed(() { const start Math.max(0, minuteScrollPosition.value) const end Math.min(availableMinutes.value.length, start visibleOptions) return availableMinutes.value.slice(start, end) }) const hourSelect ref(null) const minuteSelect ref(null) const handleKeyDown (e, type) { if (e.key ArrowRight type hour) { e.preventDefault() minuteSelect.value.focus() } else if (e.key ArrowLeft type minute) { e.preventDefault() hourSelect.value.focus() } } const handleHourScroll (e) { const select e.target const optionHeight select.querySelector(option)?.offsetHeight || 30 const scrollTop select.scrollTop hourScrollPosition.value Math.floor(scrollTop / optionHeight) } const handleMinuteScroll (e) { const select e.target const optionHeight select.querySelector(option)?.offsetHeight || 30 const scrollTop select.scrollTop minuteScrollPosition.value Math.floor(scrollTop / optionHeight) } watch([selectedHour, selectedMinute], ([hour, minute]) { const timeString ${hour.toString().padStart(2, 0)}:${minute.toString().padStart(2, 0)} emit(update:modelValue, timeString) emit(change, timeString) }) onMounted(() { hourSelect.value?.focus() }) const primaryColorRgb computed(() { const hex props.primaryColor.replace(#, ) const r parseInt(hex.substring(0, 2), 16) const g parseInt(hex.substring(2, 4), 16) const b parseInt(hex.substring(4, 6), 16) return ${r}, ${g}, ${b} }) /script style scoped .time-picker { display: flex; align-items: center; gap: 4px; } .time-select { padding: 8px; border: 1px solid #dcdfe6; border-radius: 4px; outline: none; appearance: none; background-image: url(data:image/svgxml;charsetUTF-8,%3csvg xmlnshttp://www.w3.org/2000/svg viewBox0 0 24 24 fillnone strokecurrentColor stroke-width2 stroke-linecapround stroke-linejoinround%3e%3cpolyline points6 9 12 15 18 9%3e%3c/polyline%3e%3c/svg%3e); background-repeat: no-repeat; background-position: right 8px center; background-size: 1em; height: calc(1.5em * 5 16px); overflow-y: auto; } .time-select:focus { border-color: v-bind(primaryColor); box-shadow: 0 0 0 2px rgba(v-bind(primaryColorRgb), 0.2); } .time-separator { font-weight: bold; } .time-picker--small .time-select { padding: 6px; font-size: 12px; height: calc(1.2em * 5 12px); } .time-picker--default .time-select { padding: 8px; font-size: 14px; height: calc(1.5em * 5 16px); } .time-picker--large .time-select { padding: 10px; font-size: 16px; height: calc(1.8em * 5 20px); } .time-picker--disabled { opacity: 0.6; pointer-events: none; } /style5.2 使用示例template div h2基本使用/h2 TimePicker v-modeltime1 / p选择的时间: {{ time1 }}/p h230分钟间隔/h2 TimePicker v-modeltime2 minute-step30 / p选择的时间: {{ time2 }}/p h2限制时间范围(09:00-18:00)/h2 TimePicker v-modeltime3 min09:00 max18:00 / p选择的时间: {{ time3 }}/p h2自定义样式/h2 TimePicker v-modeltime4 sizelarge primary-color#67c23a / p选择的时间: {{ time4 }}/p h2禁用状态/h2 TimePicker v-modeltime5 disabled / /div /template script setup import { ref } from vue import TimePicker from ./components/TimePicker.vue const time1 ref(00:00) const time2 ref(09:00) const time3 ref(12:00) const time4 ref(15:30) const time5 ref(18:00) /script6. 常见问题与解决方案6.1 表单验证集成在实际项目中时间选择器通常需要与表单验证系统集成。我们可以通过暴露验证方法来实现script setup // 在组件中添加验证方法 const validate () { if (props.disabled) return { valid: true } const hour selectedHour.value const minute selectedMinute.value if (hour minHour || hour maxHour) { return { valid: false, message: 请选择${minHour}:00到${maxHour}:59之间的时间 } } if (hour minHour minute minMinute) { return { valid: false, message: 最早可选择${minHour}:${minMinute.toString().padStart(2, 0)} } } if (hour maxHour minute maxMinute) { return { valid: false, message: 最晚可选择${maxHour}:${maxMinute.toString().padStart(2, 0)} } } return { valid: true } } // 暴露方法给父组件 defineExpose({ validate }) /script父组件中可以这样使用script setup import { ref } from vue const timePickerRef ref(null) const handleSubmit () { const result timePickerRef.value?.validate() if (!result.valid) { alert(result.message) return } // 提交表单... } /script template TimePicker reftimePickerRef v-modeltime / button clickhandleSubmit提交/button /template6.2 国际化支持如果需要支持多语言我们可以添加i18n支持script setup import { inject } from vue const props defineProps({ // 其他props... locale: { type: Object, default: () ({ hour: 小时, minute: 分钟, timeSeparator: : }) } }) // 或者从provide/inject获取 const i18n inject(i18n, { t: (key) { const translations { time.hour: 小时, time.minute: 分钟, time.separator: : } return translations[key] || key } }) /script template div classtime-picker select v-modelselectedHour option v-forhour in hours :valuehour {{ hour.toString().padStart(2, 0) }} {{ i18n.t(time.hour) }} /option /select span classtime-separator{{ i18n.t(time.separator) }}/span select v-modelselectedMinute option v-forminute in minutes :valueminute {{ minute.toString().padStart(2, 0) }} {{ i18n.t(time.minute) }} /option /select /div /template6.3 移动端适配在移动设备上select元素的体验可能不够好。我们可以添加一个移动端专用的实现template div div v-ifisMobile classmobile-time-picker button clickshowHourPicker true {{ selectedHour.toString().padStart(2, 0) }} /button span:/span button clickshowMinutePicker true {{ selectedMinute.toString().padStart(2, 0) }} /button MobilePicker v-ifshowHourPicker :optionshours v-modelselectedHour closeshowHourPicker false / MobilePicker v-ifshowMinutePicker :optionsminutes v-modelselectedMinute closeshowMinutePicker false / /div div v-else !-- 桌面端实现 -- /div /div /template script setup import { ref, onMounted } from vue const isMobile ref(false) const showHourPicker ref(false) const showMinutePicker ref(false) onMounted(() { isMobile.value window.innerWidth 768 window.addEventListener(resize, () { isMobile.value window.innerWidth 768 }) }) /script7. 与现有UI库集成7.1 与Element Plus集成为了让我们的组件与Element Plus风格一致可以添加相应的classtemplate div classtime-picker el-input el-input--default :class[ el-input--${size}, { is-disabled: disabled } ] !-- 内部实现 -- /div /template style scoped .time-picker.el-input { display: inline-flex; width: auto; } .time-select { /* 覆盖Element样式 */ background-image: none; padding: 0 8px; border: none; height: 100%; } .time-select:focus { box-shadow: none; } /style7.2 作为Element Plus插件我们可以将组件包装成Element Plus插件使其可以全局注册// time-picker-plugin.js import TimePicker from ./TimePicker.vue export default { install(app, options {}) { app.component(options.name || ElTimePicker, TimePicker) } }然后在main.js中使用import { createApp } from vue import ElementPlus from element-plus import TimePickerPlugin from ./plugins/time-picker-plugin const app createApp(App) app.use(ElementPlus) app.use(TimePickerPlugin, { name: ElCustomTimePicker })8. 测试与调试8.1 单元测试为组件添加单元测试确保功能稳定// TimePicker.spec.js import { mount } from vue/test-utils import TimePicker from ./TimePicker.vue describe(TimePicker, () { it(正确初始化默认值, () { const wrapper mount(TimePicker, { props: { modelValue: 09:30 } }) expect(wrapper.vm.selectedHour).toBe(9) expect(wrapper.vm.selectedMinute).toBe(30) }) it(响应v-model变化, async () { const wrapper mount(TimePicker, { props: { modelValue: 09:30 } }) await wrapper.setProps({ modelValue: 14:45 }) expect(wrapper.vm.selectedHour).toBe(14) expect(wrapper.vm.selectedMinute).toBe(45) }) it(触发update:modelValue事件, async () { const wrapper mount(TimePicker, { props: { modelValue: 09:30 } }) await wrapper.vm.selectedHour 14 await wrapper.vm.selectedMinute 45 expect(wrapper.emitted(update:modelValue)).toBeTruthy() expect(wrapper.emitted(update:modelValue)[0]).toEqual([14:45]) }) it(遵守min/max限制, () { const wrapper mount(TimePicker, { props: { min: 09:00, max: 18:00 } }) expect(wrapper.vm.availableHours).toEqual( expect.arrayContaining([9, 10, 11, 12, 13, 14, 15, 16, 17, 18]) ) }) })8.2 E2E测试使用Cypress添加端到端测试// time-picker.cy.js describe(TimePicker E2E测试, () { beforeEach(() { cy.visit(/) }) it(可以选择时间, () { cy.get(.time-picker).within(() { cy.get(select).first().select(14) cy.get(select).last().select(30) }) cy.get(onChange).should(have.been.calledWith, 14:30) }) it(遵守时间范围限制, () { cy.get(.time-picker[min09:00]).within(() { cy.get(select).first().should(not.contain, option[value8]) cy.get(select).last().should(not.contain, option[value0]) }) }) })9. 性能优化与最佳实践9.1 虚拟滚动优化对于包含大量选项的情况我们可以使用vue-virtual-scroller进一步优化性能script setup import { RecycleScroller } from vue-virtual-scroller import vue-virtual-scroller/dist/vue-virtual-scroller.css const hourItems computed(() { return availableHours.value.map(hour ({ id: hour, value: hour, label: hour.toString().padStart(2, 0) })) }) const minuteItems computed(() { return availableMinutes.value.map(minute ({ id: minute, value: minute, label: minute.toString().padStart(2, 0) })) }) /script template div classtime-picker div classtime-select-wrapper RecycleScroller :itemshourItems :item-size32 key-fieldid classscroller template #default{ item } div classoption :class{ selected: item.value selectedHour } clickselectedHour item.value {{ item.label }} /div /template /RecycleScroller /div span classtime-separator:/span div classtime-select-wrapper RecycleScroller :itemsminuteItems :item-size32 key-fieldid classscroller template #default{ item } div classoption :class{ selected: item.value selectedMinute } clickselectedMinute item.value {{ item.label }} /div /template /RecycleScroller /div /div /template style scoped .time-select-wrapper { height: 160px; width: 60px; border: 1px solid #dcdfe6; border-radius: 4px; overflow: hidden; } .scroller { height: 100%; } .option { height: 32px; display: flex; align-items: center; justify-content: center; cursor: pointer; } .option:hover { background-color: #f5f7fa; } .option.selected { background-color: #409eff; color: white; } /style9.2 可访问性改进为了满足WCAG标准我们需要改进组件的可访问性template div classtime-picker rolegroup aria-labelledbytime-picker-label span idtime-picker-label classvisually-hidden选择时间/span div classtime-select-wrapper label forhour-select classvisually-hidden小时/label select idhour-select v-modelselectedHour aria-label小时 keydownhandleKeyDown !-- 选项 -- /select /div span aria-hiddentrue:/span div classtime-select-wrapper label forminute-select classvisually-hidden分钟/label select idminute-select v-modelselectedMinute aria-label分钟 keydownhandleKeyDown !-- 选项 -- /select /div /div /template style .visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0; } /style9.3 按需加载优化如果组件较大可以考虑按需加载// 在父组件中 const TimePicker defineAsyncComponent(() import(./components/TimePicker.vue) )或者使用unplugin-vue-components自动导入// vite.config.js import Components from unplugin-vue-components/vite export default defineConfig({ plugins: [ Components({ dirs: [src/components], extensions: [vue], include: [/\.vue$/, /\.vue\?vue/], resolvers: [ (name) { if (name TimePicker) { return { importName: default, path: src/components/TimePicker.vue } } } ] }) ] })10. 总结与扩展思路在实现Vue3自定义时间选择框的过程中我们逐步构建了一个功能完善、性能优良的组件。这个组件具有以下特点支持小时和分钟的选择可自定义时间间隔提供时间范围限制功能支持多种尺寸和自定义主题色实现了键盘导航和移动端适配包含完整的表单验证和可访问性支持进行了性能优化和测试覆盖在实际项目中你可能会需要进一步扩展这个组件。以下是一些可能的扩展方向添加秒选择如果需要更精确的时间选择可以添加第三个选择器时间范围选择实现开始时间和结束时间的选择预设快捷选项如现在、整点等快捷选择按钮与日期选择器组合创建一个完整的日期时间选择器**主题系统集成