做后台管理系统,基本逃不掉范围筛选这个需求。价格区间、年龄区间、库存区间、评分区间,几乎每个列表页都要来一套。Element Plus 提供了单个数字输入框 el-input-number,范围选择器也有,但那是日期用的 el-date-picker,数字范围没有现成方案。我在同时维护几个后台模块的时候,被复制粘贴改字段名的操作搞烦了,干脆抽了一个基于 Vue3 + Element Plus 的数字范围输入框组件。做完之后,后续新页面接范围筛选基本就是一行标签的事,这篇文章把从设计到落地的完整过程拆开讲清楚,包括 v-model 双向绑定选型、精度控制、校验配合、事件透传,以及集成到搜索栏和表单里的坑。
1. 为什么非要封装一个独立组件
1.1 后台管理系统里的高频场景
先说场景。我手头的项目是一个管理后台,里面涉及商品管理、订单查询、会员运营、库存盘点几个模块。每个模块都逃不掉类似的筛选条件:订单总金额在某个区间、会员年龄在某个区间、库存余量在某个区间。这些条件看起来简单,就是两个数字输入框夹一个横杠,但真正做起来,细节远比表面复杂。
比如商品价格筛选,可能要限制最大值不能超过商品类目的价格上限,还要处理小数位数,部分商品价格是两位小数,部分只保留整数。订单金额筛选则要面对大数字,动不动就是几万几十万,输入框如果太窄,数字显示会挤压变形。会员年龄筛选逻辑简单,但常常和性别、城市等条件组合在一起,重置筛选时要同步清空。库存筛选更特殊,负值没有任何意义,最小值必须限制为 0。
还有一类场景是配置类页面,比如积分规则、佣金比例,经常需要设置一个范围上限和下限。这些页面数量少,但逻辑要求高,校验不通过时要给出明确提示,不能等着用户自己发现数值反了。
如果在每个页面都直接放两个 el-input-number,刚开始没问题,量一上来就难受了。同样一段校验逻辑在商品页写一遍,在订单页写一遍,在会员页再写一遍,第三遍的时候我就知道该抽组件了。
1.2 直接在页面里放两个输入框的痛点
不封装组件,直接在页面里堆两个 el-input-number,短期看着没毛病,但项目一长,问题很具体。
第一个痛点是逻辑重复。边缘情况太多了:最大值小于最小值怎么处理,用户只填了左边界没填右边界算不算合法,输入框被清空后值是 null 还是 undefined,这些逻辑每个页面都得想一遍。你在这个页面想起来了,在另一个页面未必想得起来,于是行为不一致。同一个筛选条件,商品列表允许空值,订单列表却因为某个同事用了 0 来兜底,查出来的数据直接不对。
第二个痛点是校验分散。范围校验的本质是“start 必须小于等于 end”,这个约束应该集中在组件里做,而不是散落在各个表单的 validate 方法中。页面多了之后,你很难保证每一处都写得一样。
第三个痛点是布局和样式难统一。两个输入框中间是横杠还是波浪线,宽度怎么分配,小尺寸下如何排列,不同页面各写各的 CSS,最后后台界面长得五花八门。
第四个痛点是维护成本。假如产品提一个新需求:范围输入框支持“只填一边就查询”,你就得跑到所有页面里去改。如果你当时没有预留扩展点,这就是一个个改字段名、改事件命名的重复劳动。抽成组件之后,只需要在组件内部加一个 allowSingle 的 prop,所有引用页面自动获得能力。
1.3 组件要解决的核心问题清单
动手封装之前,我列了一个问题清单,明确这个组件到底要解决什么:
- 双向绑定:外部能拿到最小值、最大值,也能主动设置、重置这两个值。
- 边界限制:传入全局 min / max,两个输入框都不能越过边界。
- 精度控制:支持整数和小数,小数位数统一处理,避免出现 0.30000000000000004 这种烂事。
- 大小与控件:跟随项目的 design token,支持 large / default / small,控件按钮可关闭。
- 校验反馈:start 大于 end 时要有视觉提示,但默认不阻断输入,由业务决定是否拦截。
- 扩展性:支持透传,支持回车事件,方便接入搜索栏。
- 配合表单:能与 el-form 的校验机制正常工作,重置时能同步。
清单列完,组件要长什么样就很清晰了。接下来是技术选型上最难的一个问题:内外状态怎么通信。
2. v-model 方案怎么设计最顺手
2.1 三种双向绑定姿势对比
Vue3 组件通信比 Vue2 灵活不少,范围输入框这种双值组件,具体选哪种绑定方式,我认真对比过三种。
第一种是 v-model 绑定一个数组,父组件写v-model="rangeValue",内部维护[start, end]。这种写起来最简洁,但问题也最明显。数组是引用类型,内部改一个元素并不会触发父组件对数组引用的重新赋值,需要整个替换数组才能让父组件感知变化。在 el-form 里做动态校验时,还要自己处理数组的不可变更新,多少有点别扭。另外外部如果想单独指定初始的最小值或最大值,还得解构数组,API 不够语义化。
第二种是整体绑定一个对象,父组件写v-model="rangeObj",内部发update:modelValue时把整个对象重新派发。这个比数组好一点,因为对象的属性名可以定义成 start 和 end,语义更清楚。但问题没有变,父组件依然要整体替换对象,而且对象里多塞几个字段时,组件内部和外部都容易误改不该改的属性。
第三种是 Vue3 的多 v-model 写法,父组件写v-model:start="startValue" v-model:end="endValue"。这个方案我最终选了,原因有几个:
- start 和 end 是独立的值,父组件可以单独监听、单独修改,比如重置时只需要把 start 和 end 都置空;
- 配合 el-form 的 model 更自然,表单 model 里就是平铺的
priceStart和priceEnd两个字段,不用在 validator 里解构数组; - 内部实现清晰,每个输入框对应一个 prop 和 emit,不需要做映射关系;
- 调试工具里看状态非常直观,不会出现
modelValue: [Object]这种让人抓狂的展示。
如果你是从 Vue2 转过来的,可能会觉得v-model:start这种写法有点奇怪,但它在 Vue3 里非常成熟,官方文档就是拿“全名绑定”做例子的。实际开发中,搜索栏和表单场景下,父组件需要精确控制每一个值,这套方案是最稳的。
2.2 props 与 emit 的完整定义
组件用 TypeScript 写,开发体验更好。props 定义如下:
interface RangeInputProps { start?: number | null end?: number | null min?: number max?: number precision?: number step?: number size?: 'large' | 'default' | 'small' controls?: boolean disabled?: boolean startPlaceholder?: string endPlaceholder?: string separator?: string syncWhenOverlap?: boolean }其中start和end允许为 null,因为输入框被清空后,值就是 null,不能拿 0 来兜底,否则会出现最小值自动变成 0 的诡异问题。min和max用来控制两个输入框各自的上下边界。precision控制小数位,step控制步进。syncWhenOverlap是我后加的选项,开启后如果 start 大于 end,组件会强制把 end 同步为 start,适合价格区间这种不允许逆序的场景。
事件的emit定义:
const emit = defineEmits<{ (e: 'update:start', value: number | null): void (e: 'update:end', value: number | null): void (e: 'change', value: RangeValue): void (e: 'start-blur', value: number | null): void (e: 'end-blur', value: number | null): void }>()update:start和update:end是配合模板v-model:start、v-model:end的命名约定,Vue3 会自动识别。change事件在任意一侧数值变化时触发,一次性把最新的 start 和 end 都暴露给父组件,方便做联动查询。start-blur和end-blur则是单独暴露失焦事件,搜索栏场景下经常需要“输入完失焦即查询”。
2.3 内外状态怎么保持同步
有了 props 和 emit,组件内部还是需要一份“可写的副本”,用来配合 el-input-number 的双向绑定。我用了两个 ref 作为内部状态,然后用 watch 保持和外部 props 同步。
const currentStart = ref<number | null>(props.start ?? null) const currentEnd = ref<number | null>(props.end ?? null) watch(() => props.start, (val) => { currentStart.value = val ?? null }) watch(() => props.end, (val) => { currentEnd.value = val ?? null })这里有一个新手经常踩的坑:props 直接传入 new Date() 或一个函数时没问题,但如果是异步数据,比如列表页进入后先从接口拿预设筛选条件,再赋给 start,组件的 watch 就不会触发。我在设计上要求父组件把 start 和 end 当作“受控值”,组件内部不维护持久化的私有状态,每次 props 变化都重新同步。
如果父组件用ref绑定但一直没有赋新值,组件内部 currentStart 还是在用户输入时变化了,这没问题,因为组件同时 emit update:start 给父组件,父组件通过 v-model:start 会更新自己的 ref。关键是父组件在事件回调里不能重新赋一个错误的值,否则会覆盖用户输入。
最后还有一个 useState 的思想:用户输入时,v-model 直接改 currentStart,再通过 handleStartUpdate 同步 emit 出去,这个顺序很重要,不要反过来。先 emit 再改本地值,在 el-input-number 的异步更新机制下,偶尔会出现输入框闪烁的视觉问题。
3. 基于 Element Plus 的核心实现细节
3.1 模板结构:两个数字输入框加一个分隔符
组件模板最核心的部分就是两个 el-input-number 夹一个分隔符。直接上代码:
<template> <div class="range-input" :class="{ 'range-input--disabled': disabled, 'range-input--invalid': isInvalid }" > <el-input-number v-model="currentStart" class="range-input__number" :min="min" :max="max" :precision="precision" :step="step" :size="size" :controls="controls" :disabled="disabled" :placeholder="startPlaceholder || '最小值'" :value-on-clear="null" :aria-label="startPlaceholder || '最小值'" @update:model-value="handleStartUpdate" @blur="handleStartBlur" @keydown.enter="handleEnter" /> <span class="range-input__separator" aria-hidden="true">{{ separator }}</span> <el-input-number v-model="currentEnd" class="range-input__number" :min="min" :max="max" :precision="precision" :step="step" :size="size" :controls="controls" :disabled="disabled" :placeholder="endPlaceholder || '最大值'" :value-on-clear="null" :aria-label="endPlaceholder || '最大值'" @update:model-value="handleEndUpdate" @blur="handleEndBlur" @keydown.enter="handleEnter" /> </div> </template>这里要特别强调value-on-clear="null"这个属性。el-input-number 在用户把输入框清空之后,默认行为是解析为 null 或者 undefined,不同 Element Plus 版本可能不一致。如果不清空时是 0,清空时是 null,父组件在比较时会莫名多出一个 0 值,数据查询就错了。显式设置value-on-clear="null"后,清空行为完全可控。
min和max我直接透传给了两个输入框,这样即使组件自身没有校验逻辑,Element Plus 的基础边界限制也已经生效。注意这里有个细节:如果全局 min 是 0,start 和 end 都不能小于 0,比如库存范围是合理的,但如果业务上允许“只填最大值,最小值负数无意义”,这个设计就没问题。
3.2 数字精度与边界控制
数字输入框最常见的坑是浮点误差。用户输入 0.1,看起来是 0.1,但 JS 内部可能是 0.100000000000000005。el-input-number 的 precision 属性可以指定小数位数,用户输入超出精度时会自动四舍五入。组件把 precision 透传下去,这一步一定要做。
但光透传还不够,因为 start 和 end 可能来自接口、来自 URL 参数、来自表单回显,这些数据本身可能带有超长小数。我的做法是在组件内部加一个 normalize 辅助函数,所有出入组件的值都统一经过它:
function normalizeNumber(val: number | null | undefined, precision: number): number | null { if (val === null || val === undefined || Number.isNaN(val)) { return null } if (!Number.isFinite(val)) { return null } const factor = Math.pow(10, precision) return Math.round(val * factor) / factor }在 handleStartUpdate 中先 normalize 再写入 currentStart:
function handleStartUpdate(val: number | null | undefined) { const normalized = normalizeNumber(val, props.precision) currentStart.value = normalized syncOverlap() emitStart(normalized) emitChange() }这里还要注意一个边界:如果用户输入的数值大于 max 或小于 min,el-input-number 在 blur 时会自动修正,但修改后的结果不一定走 update 事件。保险起见,我在 blur 事件里也做一次同步:
function handleStartBlur() { currentStart.value = normalizeNumber(currentStart.value, props.precision) emitStart(currentStart.value) emit('start-blur', currentStart.value) }这样即使 Element Plus 在 blur 时改了值,组件也能拿到正确的最终值并同步给父组件。
3.3 校验逻辑与错误提示
范围输入框最关键的校验逻辑是 start 不能大于 end。我的处理方式是:组件默认不拦截,只负责给出视觉反馈,最终是否阻断由业务层决定。
组件内部维护一个计算属性:
const isInvalid = computed(() => { if (currentStart.value === null || currentEnd.value === null) { return false } return currentStart.value > currentEnd.value })当 isInvalid 为 true 时,给根 div 加上range-input--invalid类,样式里把两个输入框的边框统一变为主题色 red,用户一眼就能发现问题。
但如果业务希望自动纠错,我提供了syncWhenOverlap选项。开启后,在每次值变化时判断当前值是否越界,越界就把另一个值同步过来:
function syncOverlap() { if (!props.syncWhenOverlap) return if (currentStart.value === null || currentEnd.value === null) return if (currentStart.value > currentEnd.value) { currentEnd.value = currentStart.value emitEnd(currentEnd.value) } }需要注意,同步动作要同时更新内部 currentEnd 和外部 props.end,否则父组件还是旧值,刷新一次筛选条件后数据就会错乱。
很多同学会在这里纠结要不要把“非法状态”通过事件抛给父组件。我试过,但后来发现其实没必要。父组件在拿到 start 和 end 时,自己就能判断合法性,比如 el-form 的 validator 里直接读 form.priceStart 和 form.priceEnd 比较即可,组件再抛一个 invalid 事件反而多了一套状态同步的负担。这也是我倾向于“组件只做展示和基础交互,业务逻辑留给调用方”的原因。
3.4 样式定制的几个关键点
Element Plus 的样式变量体系很方便,组件里我不写死颜色,全部用 Element Plus 的 CSS 变量。
.range-input { display: inline-flex; align-items: center; width: 100%; gap: 8px; } .range-input__number { flex: 1 1 0; min-width: 0; } .range-input__separator { flex-shrink: 0; color: var(--el-text-color-secondary); font-size: 14px; user-select: none; } .range-input--invalid .el-input-number { --el-input-border-color: var(--el-color-danger); --el-input-hover-border-color: var(--el-color-danger); --el-input-focus-border-color: var(--el-color-danger); } .range-input--disabled .range-input__separator { color: var(--el-text-color-placeholder); }这里有个样式细节值得说:el-input-number 默认宽度是 150px,在 Flex 布局中不是很容易自适应。如果你直接把两个组件塞进 flex 容器,它们会各自占据固定宽度,中间分隔符就被挤压。我给两个输入框都设置了flex: 1 1 0和min-width: 0,这样它们会平分剩余空间,在手机端或窄弹窗中也能自动收缩。
如果你希望左框和右框宽度不同,可以给组件传一个ratio之类的 props,不过我实测下来,绝大多数场景左右等宽就够了。
4. 组件的事件透传与可用性打磨
4.1 内部事件如何透传给外部
el-input-number 自身支持 focus、blur、change、keydown 等事件。组件把它们统一处理后,以明确的事件名抛给外部,而不是依赖 Vue3 的 attribute fallthrough。
为什么不用自动透传?因为范围输入框有两个子输入框,外部传入的 @blur 如果直接透传到根 div,你根本分不清是左框还是右框触发的失焦,拿到的事件对象也没有 value 信息,父组件还得自己去查当前值。与其这样,不如显式定义事件,把 field 信息带出去。
最终我保留了start-blur、end-blur和change三个事件。start-blur和end-blur除了传值,还可以配合搜索场景做“失焦即查询”。如果你需要 focus 事件,可以照着加一个start-focus、end-focus,代码很简单,但业务里真正需要 focus 的场景相对少,我暂时没加。
对于回车事件,我统一抛了一个enter事件:
function handleEnter() { emit('enter', { start: currentStart.value, end: currentEnd.value }) }搜索栏里最常见的交互是填完范围直接回车查询,有了这个事件,父组件只需要写@enter="handleSearch",不用再关心焦点在左框还是右框。
4.2 键盘操作和无障碍改善
数字输入框的键盘交互,Element Plus 默认支持方向键上下调节数字,以及 Tab 切换焦点。这一点保持不变,但我做了两个补充。
第一个补充是 aria-label。范围输入框在表单里如果没有 label,屏幕阅读器用户听到的就只是“编辑文本”,根本不知道这个框是干嘛的。组件内部为左右输入框分别设置了 aria-label,默认读“最小值”和“最大值”,也支持从 props 传入自定义 placeholder 时同步更新。
第二个补充是错误提示的无障碍处理。当 isInvalid 为 true 时,我给根元素添加了aria-invalid="true":
<div class="range-input" :class="{ 'range-input--invalid': isInvalid }" :aria-invalid="isInvalid ? 'true' : 'false'" >屏幕阅读器会感知到这个输入组处于错误状态。如果你需要更详细的错误信息文案,可以再配合aria-describedby指向错误提示元素,不过这会增加 props 复杂度,我目前没有做得很重。
另外一个可用性细节是分隔符。它纯粹是视觉装饰,屏幕阅读器读出来反而干扰,所以我加了aria-hidden="true",确保读屏用户不会听到“横杠”这种噪音。
5. 实战:集成进搜索栏和表单校验
5.1 在搜索栏里快速接入
搜索栏集成是最典型的使用场景。我的项目里搜索栏通常是一个表单,顶部是各种筛选条件,底部是查询和重置按钮。接入组件后,代码长这样:
<el-form :model="queryForm" inline> <el-form-item label="价格区间"> <RangeInput v-model:start="queryForm.priceStart" v-model:end="queryForm.priceEnd" :min="0" :precision="2" :step="1" size="default" placeholder="最低价" placeholderEnd="最高价" @enter="handleSearch" /> </el-form-item> ... </el-form>搜索栏的核心逻辑是:值改变时防抖触发查询,或者失焦触发查询,或者回车触发查询。我一般用 enter 事件 + 一个清空按钮就够了,不建议 change 事件里直接调接口,因为用户用上下键连续调节数字时,change 会触发很多次,接口压力比较大。如果一定要监听,可以在外面加 debounce。
这里还要注意搜索栏重置按钮。el-form 有 resetFields 方法,但 resetFields 只会重置 form 初始值,如果你的 form 初始值是priceStart: null,reset 后组件会通过 watch 把内部值同步为 null,这个流程是通的。但如果你在 initData 里已经把 form 的 priceStart 设成了某个默认区间,reset 后也会回到那个默认区间,可能不是产品想要的“全部清空”。所以重置逻辑最好自己写:
function handleReset() { queryForm.priceStart = null queryForm.priceEnd = null handleSearch() }5.2 配合 el-form 做业务校验
需要在表单里做范围校验时,比如“结束时间不能小于开始时间”,我推荐在 el-form 的 rules 里写自定义 validator,直接读取表单绑定值。
const rules = { priceStart: [ { validator: validateRange, trigger: 'change' } ], priceEnd: [ { validator: validateRange, trigger: 'change' } ] } function validateRange(_rule: any, _value: any, callback: any) { const start = formRef.value?.priceStart const end = formRef.value?.priceEnd if (start === null || end === null) { callback() return } if (start > end) { callback(new Error('最小值不能大于最大值')) return } callback() }注意这里 trigger 不能是 blur。el-input-number 在用户输入过程中,值可能已经超过了 range,但还没有触发 blur。如果你只在 blur 时校验,用户输入“200”作为最小值,但最大值还是“100”,在输入过程中不会立即报错,只有失焦才报,体验有点滞后。change 会在值变化时触发,反馈更及时。
这里还有一个小技巧:如果你不想给左右两个输入框分别写 validator,可以直接给一个不存在的字段配置 validator,然后把组件值和表单其他字段一起校验。我实际项目中就是给表格里的隐藏字段排个序,但最稳妥的还是上面这种。
5.3 表单回显和异步数据的同步
表单回显是另一个容易踩坑的地方。比如编辑配置页,进入页面后先从接口拿到已有范围{ min: 10, max: 50 },再渲染组件。由于 props 是异步传入的,组件初始化时拿到的 props.start 为 null,然后 watch 会监听到 new value,同步到内部 currentStart,这没问题。
但有一种情况是在同一页面里切换不同数据源,比如切换不同商品查看价格范围。如果你用同一个组件实例,切换商品时需要更新 v-model:start 和 v-model:end,组件通过 watch 正确响应,这个流程是通的。麻烦的是如果你在 watch 里做了一些业务处理,比如判断 start 是否合法并 emit 事件,切换数据源时还可能多触发一次 change,导致接口多查询一次。如果遇到这个问题,可以在 watch 回调里加一个 skipChange flag,或者对 change 事件做防抖。
对于列表页场景,搜索栏组件通常不缓存历史值,重置后 start 和 end 都置 null,问题不大。只有配置类页面会涉及回显,测试的时候重点看切换数据源后组件是否出现旧值残留。
6. 踩坑记录与单元测试
6.1 常见问题速查表
实际开发中遇到的问题,我整理成了一张表,基本覆盖了这个组件的典型坑:
| 现象 | 根本原因 | 解决方案 |
|---|---|---|
| 输入 0 后立刻变成空 | 清空值默认为 undefined 或value-on-clear没设 | 显式设置value-on-clear为 null |
| 0.1 + 0.2 显示成 0.30000000000000004 | 数字精度问题 | 设置 precision,内部统一 normalize |
| 左右输入框宽度不一致或挤压分隔符 | el-input-number 默认固定宽度 | CSS 设为 flex: 1 1 0,min-width: 0 |
| 重置表单后组件仍显示旧值 | watch 没监听 props 变化,或父组件 reset 逻辑不对 | 用 watch 同步 props,重置时显式置 null |
| 用户把 start 输得比 end 大,没有任何提示 | 缺少 isInvalid 状态 | 组件内增加越界判断和红色边框 |
| 上下键调节时接口频繁查询 | change 事件触发次数过多 | 父组件加防抖,或改用 @enter/@blur 查询 |
| 组件在抽屉里渲染后宽度异常 | 抽屉容器宽度未初始化时组件已渲染 | 给外层容器设置最小宽度,或在抽屉 open 后再渲染 |
6.2 用 Vitest 写点基本测试
组件写完,我补了几个 Vitest 测试,主要覆盖核心逻辑,不求全覆盖,但关键路径不能挂。
import { mount } from '@vue/test-utils' import { describe, it, expect } from 'vitest' import RangeInput from './RangeInput.vue' describe('RangeInput', () => { it('渲染两个数字输入框', () => { const wrapper = mount(RangeInput) expect(wrapper.findAll('.el-input-number')).toHaveLength(2) }) it('start 更新时同步 emit update:start 和 change', async () => { const wrapper = mount(RangeInput, { props: { start: 10, end: 20 } }) const startInputs = wrapper.findAll('input') await startInputs[0].setValue(15) const updateStart = wrapper.emitted('update:start') expect(updateStart).toBeTruthy() expect(updateStart[0][0]).toBe(15) const change = wrapper.emitted('change') expect(change[0][0]).toEqual({ start: 15, end: 20 }) }) })写测试的时候有个注意点:el-input-number 的 input 事件触发的值不一定立刻反映在 v-model 上,所以要利用 setValue 之后等 nextTick。如果遇到断言失败,先检查是不是异步时序问题,不要急着改测试逻辑。
6.3 扩展思路:从一对输入框到更多形态
组件基本稳定后,我给后续留了几个扩展方向。第一个支持单独只填一边,比如“价格不超过多少”的单边搜索,这时候可以让另一边自动置空,并把 isInvalid 判断改为“如果单边填了就忽略另一边”。
第二个是支持传 debounce 参数,组件内部值稳定后再 emit change,省得在外面包函数。我暂时没做,因为防抖粒度在不同业务里不一样,让父组件控制更灵活。
第三个是支持更多的展示形态,比如范围滑条加输入框的混合模式。不过这个改动比较大,目前的双输入框已经是覆盖绝大多数场景的形态,滑条更多是 C 端交互,后台管理系统用输入框反而更快更准。
组件化开发就是这样,痛点足够明显时才值得抽象,而抽象出来的边界要足够清晰,不能什么都往组件里塞。
我个人的体会是,数字范围输入框这类“看起来小、用起来多、细想全是坑”的组件,特别适合在项目中期专门抽一次。早点做,后面每个页面都能省下重复逻辑的维护成本;晚点做,复制粘贴的代码已经在十几个文件里生根发芽,重构成本成倍上升。如果你也在维护后台管理系统,遇到类似的需求,别犹豫,直接把这个组件做出来,收益比你想的大得多。