在 Vue 3 表单开发中,时间选择是一个常见但容易被忽视细节的模块。大多数项目直接使用组件库的日期时间选择器,但当需求明确要求 12 小时制显示、同时允许用户精确到秒时,原生控件和部分组件默认配置往往无法直接满足。本文围绕一个自定义 Vue 3 时间选择器的实现过程,梳理 12 小时制与 24 小时制之间的转换关系,并给出包含秒数选择、上午下午切换、双向绑定的完整方案。

原生时间控件的限制与需求拆解
浏览器提供的 <input type="time"> 在桌面端会弹出系统时间选择面板,但它是否采用 12 小时制完全取决于操作系统或浏览器的区域设置,开发者无法通过标准属性强制指定。虽然 step 属性可以影响分钟或秒的步进,但不同浏览器对秒级输入的展示和交互并不一致,移动端表现差异更大。因此,如果需要用户界面统一显示上午下午,并且能够明确选择秒数,原生控件就不适合直接作为最终方案。
Vue 3 的组合式 API 为这类场景提供了很好的封装基础。我们可以把显示状态与数据值拆开:对外通过 modelValue 接收标准 24 小时制字符串,例如 14:05:30,对内维护一个包含小时、分钟、秒和时段的对象。这样外部表单提交的数据仍然规范,内部展示和交互则可以完全按照 12 小时制来设计。
在实际实现前,需要明确几个关键点:小时在 12 小时制下只能取 1 到 12,分钟和秒数范围都是 0 到 59,时段只有 AM 和 PM 两个值。12 AM 在 24 小时制中对应 0 点,12 PM 对应 12 点,这是最容易出错的转换边界,后面会专门说明。
12 小时制与 24 小时制的换算逻辑
时间选择组件的核心逻辑在于数值换算。内部状态建议用一个对象表示,例如 { hour: 2, minute: 5, second: 30, period: 'PM' },对外输出的字符串则通过转换函数生成。24 小时制转 12 小时制时,先判断小时数是否大于等于 12,决定时段为 PM 还是 AM,然后把小时数对 12 取余;取余结果为 0 时需要显示为 12。反过来,12 小时制转 24 小时制时,如果时段是 AM 且小时为 12,则实际小时为 0;如果时段是 PM 且小时不为 12,则实际小时需要加 12。
下面的 JavaScript 代码分别实现了两个方向的转换,并且使用 padStart 保证分钟和秒数始终为两位数字。为了减少外部依赖,这里全部使用普通函数,不引入任何日期库。
function pad(value) {
return String(value).padStart(2, '0');
}
function to24HourValue(hour, minute, second, period) {
let h = Number(hour);
if (period === 'AM' && h === 12) {
h = 0;
}
if (period === 'PM' && h !== 12) {
h += 12;
}
return pad(h) + ':' + pad(minute) + ':' + pad(second);
}
function to12HourState(value) {
const parts = value.split(':');
let h = Number(parts[0]);
const minute = Number(parts[1]);
const second = Number(parts[2]);
const period = h >= 12 ? 'PM' : 'AM';
h = h % 12;
if (h === 0) {
h = 12;
}
return { hour: h, minute: minute, second: second, period: period };
}
这里有一个容易被忽略的细节:to12HourState 假设传入值一定是完整的 HH:mm:ss 格式。如果外部传入了空字符串或 null,就会在 split 之后访问不存在的索引,导致运行时错误。因此组件初始化时需要先做一次格式化或兜底处理,例如默认给 00:00:00。
封装 Vue 3 时间选择组件
有了转换函数后,就可以在 Vue 3 的 <script setup> 中组装组件。组件对外使用 defineProps 和 defineEmits 实现 v-model 双向绑定,对内使用 reactive 保存 12 小时制状态。模板里需要三个下拉框分别选择小时、分钟和秒,再加一个按钮切换 AM 和 PM。小时下拉框的选项从 1 到 12,分钟和秒下拉框的选项从 0 到 59。
下面是一个完整的 Vue 单文件组件示例。模板中为了避免选择项数量过多影响阅读,分钟和秒数使用 v-for 循环生成,实际项目中如果希望更快的渲染性能,也可以提前生成常量数组。代码里通过 watch 监听外部 modelValue 的变化并同步到内部状态,同时在内部状态变化时通过 emit 更新外部值。
<template>
<div class="time-picker">
<select v-model="state.hour" aria-label="小时">
<option v-for="h in 12" :key="h" :value="h">{{ h }}</option>
</select>
<span>:</span>
<select v-model="state.minute" aria-label="分钟">
<option v-for="m in 60" :key="m" :value="m - 1">{{ pad(m - 1) }}</option>
</select>
<span>:</span>
<select v-model="state.second" aria-label="秒">
<option v-for="s in 60" :key="s" :value="s - 1">{{ pad(s - 1) }}</option>
</select>
<button type="button" @click="togglePeriod">{{ state.period }}</button>
</div>
</template>
<script setup>
import { reactive, watch } from 'vue';
const props = defineProps({
modelValue: {
type: String,
default: '00:00:00'
}
});
const emit = defineEmits(['update:modelValue']);
function pad(value) {
return String(value).padStart(2, '0');
}
function to24HourValue(hour, minute, second, period) {
let h = Number(hour);
if (period === 'AM' && h === 12) {
h = 0;
}
if (period === 'PM' && h !== 12) {
h += 12;
}
return pad(h) + ':' + pad(minute) + ':' + pad(second);
}
function to12HourState(value) {
const safeValue = value || '00:00:00';
const parts = safeValue.split(':');
let h = Number(parts[0]);
const minute = Number(parts[1]);
const second = Number(parts[2]);
const period = h >= 12 ? 'PM' : 'AM';
h = h % 12;
if (h === 0) {
h = 12;
}
return { hour: h, minute: minute, second: second, period: period };
}
const state = reactive(to12HourState(props.modelValue));
watch(() => props.modelValue, (newValue) => {
Object.assign(state, to12HourState(newValue));
});
watch(state, () => {
emit('update:modelValue', to24HourValue(state.hour, state.minute, state.second, state.period));
});
function togglePeriod() {
state.period = state.period === 'AM' ? 'PM' : 'AM';
}
</script>
上面的示例代码中,watch(state, ...) 没有显式设置深度监听,但在 Vue 3 中 reactive 对象作为监听源时,默认会深度监听其属性变化。这样当用户在任意下拉框中修改小时、分钟或秒时,watch 都会触发并发出更新事件。为了避免外部值同步回来时造成重复发送,可以在 watch(() => props.modelValue, ...) 里加一个标志位,这里为了保持示例简洁没有展开,但在复杂表单中建议补充。
需要特别说明的是,click 事件的 togglePeriod 函数只修改了时段,而时段变化会触发对 state 的监听,所以不需要在 togglePeriod 里再手动调用 emit。如果后续把按钮改成 select 或其他控件,只要仍然是修改 state.period,响应式系统就能自动处理更新。
使用 Element Plus 快速实现与对比
如果项目已经引入 Element Plus,那么可以直接使用 <el-time-picker> 组件来支持 12 小时制和秒数选择。它的 format 属性控制输入框显示格式,value-format 控制绑定值的格式。要让输入框显示为 12 小时制并带秒,可以把 format 设置为 hh:mm:ss A,同时把 value-format 设置为 HH:mm:ss,这样显示和存储就能分离。格式串中的大小写不能混用:hh 表示 12 小时制小时,HH 表示 24 小时制小时,A 表示 AM 或 PM。
<template>
<el-time-picker
v-model="timeValue"
format="hh:mm:ss A"
value-format="HH:mm:ss"
placeholder="请选择时间"
/>
</template>
<script setup>
import { ref } from 'vue';
const timeValue = ref('14:05:30');
</script>
这种方案的优点是代码量少、交互体验完整,适合中后台项目快速开发。缺点也很明显:如果项目没有使用 Element Plus,只为了一个时间选择器就引入整个组件库并不划算;而且它的样式较固定,做深度定制时可能需要覆盖不少 CSS 变量。自定义组件则更轻量,也能完全按照 UI 设计稿调整下拉框和按钮样式。
下面用表格对比三种实现方式在 12 小时制、秒数选择和双向绑定方面的支持情况。需要说明的是,原生时间输入框虽然可以通过 step 属性获得一定秒级控制,但缺乏稳定的上午下午切换,因此只适合对显示制式不敏感的简单场景。
| 实现方式 | 12 小时制 | 秒数选择 | 双向绑定 |
|---|---|---|---|
| 原生时间输入框 | 受系统影响,不可控 | 部分支持,体验不稳定 | 需手动处理 |
| 自定义 Vue 3 组件 | 完全可控 | 完全可控 | 通过 v-model 实现 |
| Element Plus 组件 | 通过 format 配置 | 通过 format 与 step 配置 | 原生支持 v-model |
易错点与测试建议
实现这个组件时,最容易出错的地方集中在 12 点边界和空值处理。12 AM 转换为 24 小时制必须是 00:xx:xx,12 PM 转换后必须是 12:xx:xx。如果代码里先统一加 12 再判断 AM,就会把 12 AM 错误地转成 12:xx:xx。同样,24 小时制的 0 点如果直接用 h % 12 而不处理 0 的情况,界面会显示 0 点,而不是 12 AM。
另一个容易忽略的是秒数的保留。当用户先把秒数选为 45,然后切换小时或时段时,秒数不应该被重置。这要求内部状态更新时只覆盖对应字段,而不是每次重新生成整个对象。使用 Object.assign(state, to12HourState(newValue)) 同步外部值时,如果外部值没有秒数或格式不完整,也可能导致秒数异常,因此最好在进入组件前统一格式化,或在解析函数中加入默认值。
测试时建议覆盖以下场景:初始值为空字符串或 null 时组件能否正常显示 12:00:00 AM;连续快速切换 AM 和 PM 是否会出现数值跳变;分钟和秒下拉框的值是否始终为两位数字;外部修改 modelValue 后界面是否同步;内部修改后父组件的绑定值是否更新。对这类小型组件来说,借助 Vitest 和 Vue Test Utils 写几个交互测试成本不高,但能显著降低表单提交时的时间格式错误。
Vue 3 时间选择器12小时制秒数选择修改时间:2026-10-03 17:45:13