
去年做后台管理系统的筛选区改造时我被一个看似不起眼的需求卡住了价格区间。“最低价到最高价”两个数字输入框听起来十分钟就能搞定。我最初也是直接在页面上放两个el-input-number中间加一个分隔符代码量确实不大。可当“价格区间”变成“年龄范围”“数量区间”“金额范围”再加上最小值不能大于最大值、必填校验、清空重置、禁用态……这些逻辑就变成好几份长得差不多、却在细节上互相打架的复制粘贴代码。为了结束这种状态我用 Vue3 Element Plus 封装了一个数字范围输入框组件把烦人的联动和校验都收敛进去。这篇就把完整设计过程、核心实现和真实踩坑记录全部摊开给同样被筛选区折磨的前端同学一个可以直接抄作业的版本。1. 为什么后台管理系统总在写这段重复代码需求场景与设计取舍1.1 直观方案两个输入框凑合然后被细节反复折磨大部分遇到范围输入需求的人第一反应都是这样写el-input-number v-modelform.minPrice placeholder最低价 / span - /span el-input-number v-modelform.maxPrice placeholder最高价 /页面一跑看起来确实没问题。两个输入框都能输入数字中间一个减号视觉上也像范围筛选。问题出在需求细化之后。第一个需求通常会来最低价不能大于最高价。于是你写一个 watch或者在使用方把两个值做比较弹一个错误提示。第二个需求跟着来价格区间改为“清空后不传参”方便后端查询全部数据。你需要在两个输入框都清空时置空对象字段。第三个需求保留两位小数精度如果超过 2 要自动四舍五入。第四个需求某个状态下两个框都要禁用……等我在五个页面里用了四份长得不一样的范围输入后再翻代码几乎每一份都有一点点不同的边界处理——有的页面忘了判断相等值有的页面忘了处理 0有的页面在最小值改动时没有同步校验最大值。这种“看起来简单、改起来没底”的状态比直接写一个复杂组件更危险。1.2 数据形态先定用数组还是对象装范围值动手之前第一个要决定的是数据形态。范围值到底用什么传我用的是数组[start, end]而不是对象{ start, end }。数组的好处很直接序列化和接口映射最简单。后端筛选接口通常就接收两个参数比如minPrice和maxPrice拿到数组后解构一次就能塞进请求体不用每处都写data.startPrice这种长链取值。另外 Element Plus 自己的日期范围选择器el-date-picker返回的也是[start, end]数组结构上保持一致代码看起来也顺眼。对象的好处则是语义清晰字段名可以自定义而且未来想扩展比如给每个端点加一个单位字段时相对容易。如果你做的是比较重的领域模型或者范围值本身要承载更多信息选对象也合理。我的建议很简单团队已有后端契约按对象传参就用对象否则一律数组。一个组件只解决一种数据形态别在内部同时兼容数组和对象不然props的类型判断会写得越来越胖。1.3 为什么不自研复合输入框也不引入第三方库既然 el-input-number 只是单值输入那“复合”这个事是不是应该自己做比如用原生 input 重写一个支持光标、选择、格式化、步进、精度控制的输入框体验上会更“定制”但代价非常高。原生输入框处理数字格式化时要考虑光标位置、非法字符拦截、中文输入法组合态、失焦回填这些坑我后面还会细讲自己全写一遍工作量至少一个星期起步而且很难测全。反过来第三方现成的范围组件能用吗市面上确有一些 form 组件库带 Range 输入但引入一个第三方库仅为解决一个双输入框在包体积、样式统一和后续维护上都不划算。尤其 Element Plus 已经统一了后台管理系统的基础交互语言突然混入一套新风格的输入框视觉上会很跳。所以最后的选择是基于 Element Plus 的el-input-number做组合封装把“两个单值输入框 联动 校验 表单集成”收敛成一个内部组件。组件本质上是组合和约束不是重复造轮子。基础输入能力交给成熟的el-input-number业务联动和边界逻辑交给我们的封装层两边各干各擅长的事。2. 组件骨架定案props、emits、v-model 的取舍细节2.1 props 定义把业务约束暴露成配置项组件能不能被多个业务场景复用关键看 props 设计。我现在这版组件的 props 定义如下prop类型默认值说明modelValueArraynumber | null[null, null]范围值配合 v-model 使用minnumber-Infinity允许输入的最小值maxnumberInfinity允许输入的最大值precisionnumber0小数位数金额场景通常设为 2stepnumber1步长配合键盘上下键和加减按钮disabledbooleanfalse是否禁用整个范围输入sizelarge | default | smalldefault输入框尺寸controlsbooleantrue是否显示加减按钮separatorstring~中间分隔符startPlaceholderstring最小值起始输入框占位符endPlaceholderstring最大值结束输入框占位符enforceRangebooleantrue是否在值交叉时强制纠偏followDirectionstart | endend交叉时让哪一端跟随另一端这里最值得聊的是enforceRange和followDirection。后台筛选场景里用户经常是先输入最大值再回头想最小值。如果最小值一输入就被“不能大于最大值”的限制死死拦住交互会很生硬。更合理的是允许用户输入在失焦或 change 时统一纠偏。但某些场景比如排期范围、库存拆单要求任何时候都不能出现交叉值那就需要严格控制。这两个 prop 就是为这两种策略设计的。followDirection决定交叉时到底谁跟随谁。默认值是end即当出现startVal endVal时让结束值跟随起始值。原因是多数情况下用户最后操作的那个输入框才是当前真正想填的值而另一个输入框大概率是不小心被改动的。当然不同业务习惯不同做成可配置项最稳妥。2.2 v-model 双向绑定的实现要点Vue3 里的自定义组件实现 v-model就是接收modelValue、触发update:modelValue。不过范围输入框有一点特殊它内部有两个输入框不能直接把两个内部 ref 一股脑往外 emit。我的做法是组件内部维护startVal和endVal两个本地 ref每次其中一个变化后把两个值组装成数组统一 emit 出去。const emit defineEmits{ (e: update:modelValue, value: Arraynumber | null): void (e: change, value: Arraynumber | null): void (e: invalid, type: empty | cross): void }() function sync() { const value [startVal.value ?? null, endVal.value ?? null] emit(update:modelValue, value) emit(change, value) }父组件只是一个v-modelform.priceRange内部怎么联动完全不用管。这里还有个小细节el-input-number在清空后 change 事件拿到的值是undefined或null组装时统一转成null保证外部拿到的数组结构始终是稳定可判定的。2.3 内部状态与外部 props 的同步规则封装受控组件最容易翻车的地方是外部 props 变化后内部状态不跟着变。所以必须用 watch 监听props.modelValuewatch( () props.modelValue, (newValue) { startVal.value normalize(newValue?.[0]) endVal.value normalize(newValue?.[1]) }, { deep: true } )normalize函数负责把传入的任意值解析成合法数字或 null这层兜底很有必要。父组件如果传入字符串形式的“100”或者从接口拿到undefined都能被安全归一化。另一个原则watch 里只改写内部状态不要再反向 emit。因为外部 v-model 的更新链路本身已经完成了一次“内部状态 - emit - 父组件 - props - 内部状态”的闭环如果在 watch 里又 emit 一次就会出现重复事件还可能造成死循环。3. 数字联动与边界约束这组件一半的价值在交互策略3.1 两种联动策略实时纠偏还是事后提醒前面提到enforceRange它控制的核心逻辑是当起始值和结束值出现交叉时组件怎么处理。我实现的是两种策略策略 A强制纠偏。开启enforceRange后任何一端值变化如果发现startVal endVal就按followDirection把另一端的值同步过来。比如当前是[100, 50]followDirection为end最终会变成[100, 100]。这个策略适合不允许任何交叉状态存在的场景。策略 B事后提醒。关闭enforceRange用户填写时不做物理上的拦截交叉状态下组件 emit 一个invalid事件由父组件决定是弹提示还是变色。适合筛选类场景因为用户可能正在连续输入强行改写会打断输入节奏。实际使用中还有个折中点即使enforceRange关闭我也会建议在提交查询时做一次最终校验。组件只管输入体验业务合法性判断应该同时存在于提交阶段两头各守一道。3.2 统一数值解析与格式化把 0、NaN、小数位处理干净写数字组件最容易被两个东西坑一个是 0一个是 NaN。很多人在判断“是否有值”时会直接写if (!value)结果把 0 也当成了空值。范围输入框里 0 完全可能是合法值比如“0 ~ 100”表示免费到一百元。所以组件里所有判空逻辑必须显式写value null || value undefined绝不依赖真值判断。数值归一化函数长这样function normalize(value: unknown): number | null { if (value null || value undefined || value ) return null const num Number(value) return Number.isNaN(num) ? null : num }precision这块el-input-number自带精度截断但要注意它默认是直接舍去多余小数不是四舍五入。如果业务要求四舍五入需要在 change 事件里手动处理一次。我习惯把归一化逻辑放在 sync 前统一解析外层传入值和用户输入值内部所有比较都在 number 类型上进行避免字符串数字比较出乱子。3.3 空值语义与清空行为范围组件的空值有两种一种是两个输入框都为空表示“不限制”后端查询时不应该带任何条件另一种是一端为空比如用户只填了“2000以上”这种情况一般表示只查询最小值以上的数据。我的组件统一用[null, null]来表示完全空一端有值、另一端为 null 则如实传递。父组件拿到的永远是长度固定的数组判断起来很轻松if (value[0] null value[1] null) { // 不传价格筛选条件 }清空行为上我在分隔符旁边加了一个可点击的清除标记当两个值都不为空时显示点击后把两个输入框重置为 null并 emit 一次空数组。这是为了避免用户手动删两次的麻烦尤其在做筛选面板时一键清空基本是刚需。相等值也是合法的范围值[5, 5]表示精确等于 5不应被判定为交叉。这里只做“大于”的交叉判断不做“大于等于”。4. 接入表单校验不是套一层 el-form-item 就完事4.1 让 FormItem 的 validator 跑起来自定义组件接入el-form校验最容易出现的问题是form-item 里的 prop 指向了form.priceRange但组件内部怎么触发校验默认情况下el-form-item只监听它内部原生输入产生的blur、change事件自定义组件不会自动触发。最省事的外部校验方式是单独写一个 validator并让组件的change事件作为触发时机const rules { priceRange: { validator: (_rule: any, value: Arraynumber | null, callback: (err?: Error) void) { if (value[0] null value[1] null) { callback(new Error(请输入价格区间)) } else if (value[0] null) { callback(new Error(请输入最低价)) } else if (value[1] null) { callback(new Error(请输入最高价)) } else if (value[0] value[1]) { callback(new Error(最低价不能大于最高价)) } else { callback() } }, trigger: change } }绑定的 form-item 写法很常规el-form-item label价格区间 proppriceRange range-input v-modelform.priceRange/range-input /el-form-item但这里有个细节trigger 写change后组件不能在内部某个值变化时直接调用 form 校验否则会报“找不到校验字段”之类的错。必须保证 emit 的change事件是组装完成后的最终值而不是中间某个临时值。所以我在组件内部统一用sync()方法 emit外部再绑定change触发表单校验。4.2 组件内部自动触发表单校验如果你不想在每个使用页面写 validator也可以通过注入formItemContextKey让组件内部直接触发表单校验import { formItemContextKey } from element-plus const formItemContext inject(formItemContextKey, undefined) function sync() { const value [startVal.value ?? null, endVal.value ?? null] emit(update:modelValue, value) emit(change, value) formItemContext?.validate?.(change).catch(() undefined) }这里需要catch兜底因为validate返回 Promise校验失败时如果不捕获控制台会打一个 unhandled rejection。在 Element Plus 的实现里子组件只要注入这个 context并调用validateform-item 就会把当前上下文里绑定的字段跑一遍校验规则。这样父组件只需要在 form-item 上配置 rules不需要额外监听事件。4.3 基础校验内置、业务校验外置的边界组件做多了之后我踩到的另一个坑是“过度内置校验”。一开始恨不得把全部校验写进组件后来发现业务校验千奇百怪比如“价格不能超过 9999”“年龄必须是 18 到 60 之间”“金额必须是 100 的倍数”这些如果都塞进组件 props组件的配置项会膨胀到没人愿意看。我的分工原则是跨值校验start end、必填结构校验一端为空、空值兜底这类和“范围”本身强相关的逻辑由组件内置处理业务上下限、枚举倍数、远程查重这类和具体页面强相关的逻辑留在外部 el-form 的 rules 里。组件保证输入结果结构合法页面保证业务语义合法两层各管各的维护起来才不打架。5. 踩坑实录Element Plus 输入框组件那几个隐藏脾气5.1 默认加减按钮引发的宽度坍塌把两个el-input-number放进 flex 容器后第一个问题出现了输入框宽度被按钮挤压数字稍微多一点就被截断甚至出现输入区域只有十几个像素宽的惨状。原因是el-input-number在显示加减按钮时会给按钮预留固定宽度这个值由内部 CSS 变量--el-input-number-controls-width控制。两个输入框各占 50% 后再减去按钮宽度内容区自然就小了。解决方式我推荐两个一是设置:controlsfalse干脆不要按钮。虽然少了个可视化步进入口但键盘上下键仍然可以步进对筛选场景影响不大。二是保留按钮但调小按钮占位.range-number-input :deep(.el-input-number) { --el-input-number-controls-width: 16px; }具体用哪个要看交互需求。如果用户年龄筛选这种我一般关闭 controls如果是步长精确的库存调整场景保留按钮更顺手。5.2 中文输入法下 input 事件被打断这个坑很隐蔽。用户在输入数字时如果开着中文输入法直接按键盘上的数字键输入法可能先把数字当作拼音组合的一部分。比如输入“200”时中文输入法的联想条还没消失input事件已经触发了此刻输入框里可能是“”或部分乱字符。el-input-number内部对这种情况的处理并不彻底偶尔会出现失焦后数字被截断成异常值。我的方案是在组件外层监听compositionstart和compositionend组合输入期间不触发联动逻辑等用户确认输入后再统一解析。同时用beforeinput过滤明显非法的字符function handleBeforeInput(e: InputEvent) { if (e.data !/^[\d.eE-]$/.test(e.data)) { e.preventDefault() } }intrinsic 的数字输入框允许e、E、、-是因为科学计数法和负数都需要用到。但如果业务明确是“非负数且不允许科学计数法”可以把e和E也过滤掉。这块属于体验细节不同团队可以按自己习惯调整。5.3 键盘上下方向键的误触与步进el-input-number哪怕关闭了加减按钮输入框聚焦后按键盘上下方向键依然会步进。这有时候是好事有时候却是灾难。筛选面板里用户本来是想滚动页面结果在输入框里不小心把价格从 100 步进到了 101。如果你希望彻底禁用这个行为可以在输入框聚焦态下拦截对应的 keydownfunction handleKeydown(e: KeyboardEvent) { if (e.key ArrowUp || e.key ArrowDown) { e.preventDefault() } }但注意这样会让键盘步进能力完全失效。如果是“价格范围”这种对精度不敏感的场景禁用无妨如果是“数量加减”场景保留反而更高效。所以我没有把这个行为写死而是准备留个可选开关默认开启键盘步进需要的时候再关。5.4 e/E 与科学计数法的意外入侵el-input-number默认支持e、E等字符这是为了数学输入而保留的。但业务数字范围里用户根本不会输入1e3这种科学计数法反而可能在搜索框里打出100e然后被解析成100造成困惑。如果你想让组件彻底拒绝这类输入可以在 keydown 阶段直接拦截function handleKeydown(e: KeyboardEvent) { if ([e, E, , -].includes(e.key)) { e.preventDefault() } }这个处理要配合业务是否允许负数一起考虑。绝大多数筛选场景只涉及正数和 0我会直接禁掉 e/E有负数需求时再放开-号。6. 完整源码与开箱用法直接复制到项目里的版本6.1 组合好的 NumberRangeInput.vue 完整代码我把前面讲的设计全部整合成一个可直接使用的组件版本基于 Vue3script setup langts写法依赖 Element Plus。template div classrange-number-input el-input-number v-modelstartVal :minmin :maxmax :stepstep :precisionprecision :disableddisabled :sizesize :controlscontrols :placeholderstartPlaceholder changehandleStartChange keydownhandleKeydown beforeinputhandleBeforeInput / span classrange-number-input__separator{{ separator }}/span el-input-number v-modelendVal :minmin :maxmax :stepstep :precisionprecision :disableddisabled :sizesize :controlscontrols :placeholderendPlaceholder changehandleEndChange keydownhandleKeydown beforeinputhandleBeforeInput / span v-ifhasValue classrange-number-input__clear clickhandleClear×/span /div /template script setup langts import { computed, inject, ref, watch } from vue import { formItemContextKey } from element-plus type Size large | default | small const props withDefaults( defineProps{ modelValue?: Arraynumber | null | null min?: number max?: number step?: number precision?: number disabled?: boolean size?: Size controls?: boolean separator?: string startPlaceholder?: string endPlaceholder?: string enforceRange?: boolean followDirection?: start | end clearable?: boolean }(), { modelValue: () [null, null], min: -Infinity, max: Infinity, step: 1, precision: 0, disabled: false, size: default, controls: true, separator: ~, startPlaceholder: 最小值, endPlaceholder: 最大值, enforceRange: true, followDirection: end, clearable: true, } ) const emit defineEmits{ (e: update:modelValue, value: Arraynumber | null): void (e: change, value: Arraynumber | null): void (e: invalid, type: empty | cross): void }() const formItemContext inject(formItemContextKey, undefined) function normalize(value: unknown): number | null { if (value null || value undefined || value ) return null const num Number(value) return Number.isNaN(num) ? null : num } const startVal refnumber | null(normalize(props.modelValue?.[0])) const endVal refnumber | null(normalize(props.modelValue?.[1])) const hasValue computed(() startVal.value ! null || endVal.value ! null) watch( () props.modelValue, (newValue) { startVal.value normalize(newValue?.[0]) endVal.value normalize(newValue?.[1]) } ) function sync(rangeInvalid?: boolean) { const value [startVal.value ?? null, endVal.value ?? null] emit(update:modelValue, value) emit(change, value) if (rangeInvalid) { emit(invalid, cross) } try { const result formItemContext?.validate?.(change) if (result typeof result.catch function) { result.catch(() undefined) } } catch { // 不在表单内时忽略 } } function handleStartChange() { if (props.enforceRange startVal.value ! null endVal.value ! null startVal.value endVal.value) { if (props.followDirection end) { endVal.value startVal.value } else { startVal.value endVal.value } sync(true) return } sync() } function handleEndChange() { if (props.enforceRange startVal.value ! null endVal.value ! null startVal.value endVal.value) { if (props.followDirection end) { endVal.value startVal.value } else { startVal.value endVal.value } sync(true) return } sync() } function handleClear() { startVal.value null endVal.value null sync() } function handleBeforeInput(e: InputEvent) { if (e.data !/^[\d.eE-]$/.test(e.data)) { e.preventDefault() } } function handleKeydown(e: KeyboardEvent) { if ([e, E].includes(e.key)) { e.preventDefault() } } /script style scoped .range-number-input { display: inline-flex; align-items: center; width: 100%; } .range-number-input .el-input-number { flex: 1; min-width: 0; } .range-number-input__separator { margin: 0 8px; color: var(--el-text-color-placeholder); white-space: nowrap; } .range-number-input__clear { margin-left: 6px; padding: 0 4px; cursor: pointer; color: var(--el-text-color-placeholder); font-size: 16px; line-height: 1; user-select: none; } .range-number-input__clear:hover { color: var(--el-text-color-primary); } /style几个值得注意的细节在代码里已经体现normalize统一处理空值和非法值enforceRange开启时发生交叉跟随策略会主动修正另一端sync在每次值变化后统一对外 emit并尝试触发表单校验beforeinput和keydown过滤掉明显不需要的字符。6.2 页面接入示例以一个典型的商品价格筛选表单为例template el-form :modelform :rulesrules label-width80px el-form-item label价格区间 proppriceRange range-input v-modelform.priceRange :precision2 :max5000 separator至 start-placeholder最低价 end-placeholder最高价 / /el-form-item /el-form /template script setup langts import { reactive } from vue import RangeInput from /components/NumberRangeInput.vue const form reactive({ priceRange: [null, null] as Arraynumber | null, }) const rules { priceRange: { validator: (_rule: any, value: Arraynumber | null, callback: (err?: Error) void) { if (value[0] null value[1] null) { callback(new Error(请输入价格区间)) } else if (value[0] ! null value[1] ! null value[0] value[1]) { callback(new Error(最低价不能大于最高价)) } else { callback() } }, trigger: change, }, } /script组件内部已经把“交叉纠偏”做了外部 validator 再写一层min max判断其实是双保险。因为enforceRange默认开启时交叉值会被内部直接修正关掉时外部校验才会兜底。6.3 还能往下怎么扩展范围输入这个组件本身可以往很多方向扩展。第一个方向是视觉升级把两个输入框换成双滑块。这类需求在价格筛选上非常高频尤其是移动端或可视化大屏。双滑块组件可以用两个原生 input range 配合手势计算实现或者基于无头逻辑库自己封装交互更好但成本也更高。第二个方向是单位感知比如金额输入要自动加¥前缀数量输入要加件后缀。这个可以在组件里通过unitprop 暴露或者在 separator 两旁的插槽处定制。第三个方向是格式化回显筛选面板重进时需要把[100, 500]还原成两个输入框的显示值同时处理精度和千分位。当前组件用normalize已经解决了数值还原但千分位展示需要额外处理可以作为一个待办存着。第四个方向是和 URL 状态同步筛选条件通常要反映到 query params 上组件可以扩展一个sync-route能力值变化时自动更新路由查询参数。这个跨项目通用性没那么强我通常放业务层做不塞进基础组件里。如果你也准备在项目里做类似的基础组件我个人的建议是先把自己页面里复制粘贴过的代码全部找出来统计一下哪些逻辑重复率最高再封装。千万不要为了“优雅”而过度设计比如一上来就想支持数组对象双形态、双滑块、单位换算结果组件写了两个月没上线。我这一版从最朴素的需求出发一步步加上联动、表单校验、踩坑修复最后也才两百行左右。范围输入框这个事的本质是把两个输入框之间的约束关系收拢到一个地方让使用方不再为“最低价大于最高价”这种问题分心。想清楚这一点组件的规模自然就不会失控。