1. 先拆需求:价格输入框到底在拦什么
做过后台管理系统的朋友肯定遇到过这个需求:价格输入框,不让用户乱输。乍一看这是个特别小的功能,但真动手做的时候,你会发现一堆隐藏的坑——中文输入法能绕过keydown拦截、粘贴带格式的文本过滤不干净、用户还能敲出01.234这种不符合直觉的数。今天把这篇文章整理出来,把我踩过的坑和最终落地的方案一次性讲清楚。
先明确这个需求到底在限制什么。价格格式在不同业务里定义略有差别,但最核心的要求是一致的:
- 只能输入数字和小数点
- 不能有负号、正号、字母、空格、中文
- 整数部分不能在开头补零(比如不能是
01、007) - 小数点最多两位,小数位不能超过2位
- 不能出现多个小数点(比如
1.2.3) - 不能以小数点开头(比如
.5这种除非你想自动转成0.5) - 不能以小数点结尾时失去数字意义(比如
1.)
这写出来的七条规则,基本覆盖了"合法价格格式"的全部边界。前几条是大多数人能想到的,后面三条才是真正区分方案好坏的地方。
关于选型,很多人第一反应是直接用type="number"。实测下来这个方案问题太多:浏览器自带的步进按钮不好看、e能输入进去、不同浏览器对非法字符的处理行为不一致、移动端键盘类型不可控。所以正确的做法是继续用el-input的文本类型,配合事件拦截和正则校验来实现,这样视觉统一、行为可控,还方便后续扩展。
1.1 一个看似简单的限制背后
价格输入框之所以值得单独写一篇,是因为它代表了所有"格式化输入"场景的通用难题。所谓交互细节,往往藏在这些边角料里。初版你可能只用一个正则判断/^\d+(\.\d{0,2})?$/,输入合法就放行,不合法就拦截,看起来天衣无缝。但实际跑起来你会发现,用户输入是一个过程,不是一次性结果。
比如用户想输入12.50,他先按1,再按2,再按.,再按5,再按0。在按.的那一瞬间,字符串是12.,此时如果按照完整正则在input事件里校验,这个状态会被判定为非法,结果就是你没法输入小数点。这就是最常见的一个坑。
所以真正的限制逻辑,不是"校验整个输入是否合法",而是"在输入的过程中允许临时非法状态,但拦截那些永远不可能合法输入的字符"。这两者的差别很微妙,却也决定了整个实现的复杂度。
1.2 三种实现思路的取舍
围绕"限制输入"这件事,业界沉淀出了三类主流方案。第一类是input事件里做正则匹配+字符串处理,第二类是keydown事件里拦截非法按键,第三类是把前两种能力封装成Vue自定义指令。三类方案都能实现需求,但各自的边界能力完全不同。
我在实操中的体会是:keydown能拦截物理按键,但拦不住中文输入法导致的间接输入,也拦不住拖拽文本进输入框;input事件能处理最终结果,但如果写不好,会出现"输入过程中被卡死"的体验问题。把思路从"拦"改成"允许输入+事后清洗",体验会好很多。
所以最终我采用的是以input事件为主、辅以composition事件处理、再配合blur兜底校验的混合方案。这套打法可以灵活应对几乎所有浏览器环境和用户操作路径。
1.3 边界情况:验证方案是否成熟的试金石
判断一个限制方案成熟不成熟,我总结了一套边界测试用例,直接拿这套用例去验证新写的功能:
- 快速连续输入多个数字,看是否丢字符
- 输入
0开头再输入数字,看是否为01 - 先输入
.,看是否直接变成.5这样的情况 - 输入两个小数点
- 输入
1.234,看小数第三位是否被截断 - 粘贴一段"价格:¥1,234.56"的文案
- 在中文输入法下输入数字和小数点
- 全选内容后直接替换
这一套跑下来,绝大多数方案都会露出问题。写完本文的方案之后,我把这些场景全部过了一遍,才算确认这个输入框真正可用。
2. 方案对比:正则、事件拦截还是自定义指令
2.1 方案A:input事件 + 正则过滤
这是最直白也是流传最广的写法:监听input事件,取到用户输入的值,用一个正则判断是否合法,不合法就直接从字符串里把非法字符抠掉,再回填到输入框里。
methods: { handleInput(value) { // 先把非法字符全部剔除,只保留数字和小数点 let result = value.replace(/[^\d.]/g, '') // 保证最多一个小数点 const firstIndex = result.indexOf('.') if (firstIndex !== -1) { result = result.slice(0, firstIndex + 1) + result.slice(firstIndex + 1).replace(/\./g, '') } // 限制小数位数 if (firstIndex !== -1) { const intPart = result.slice(0, firstIndex) const decimalPart = result.slice(firstIndex + 1, firstIndex + 3) result = intPart + '.' + decimalPart } // 去除开头的多余0 result = result.replace(/^0+(\d)/, '$1') return result } }这个方案的优点在于逻辑可控,过滤规则你可以精确到每一个字符;缺点是需要自己处理很多细节,写起来不短,而且不同业务场景的规则稍微一变,函数就要跟着重写。
2.2 方案B:keydown事件拦截
还有一种思路是在字符进入输入框之前就拦下来。监听keydown事件,检测按键的key值,如果不是数字、小数点、退格、删除、方向键、Tab等控制键,就直接preventDefault掉。这个方案在早期的需求里很受欢迎,因为它既拦住了非法输入,也拦住了用户的操作。
但实际用下来,这个方案有个致命伤:它拦不住"间接输入"。中文输入法永远是最头疼的那一类,因为用户在输入法状态栏里打的数字和字母,不会产生一个个独立的keydown事件,而是等到确认候选词之后,一次性以compositionend的形式提交进输入框。这种情况下,keydown里根本检测不到任何非法按键,等文本进入到输入框里的时候,代码已经被绕过了。
所以keydown只能作为辅助手段,配合其他方案一起用才有意义。
2.3 方案C:自定义指令统一封装
如果你在项目里多个地方都需要这种输入限制,每次都在组件里复制粘贴一大段methods,是非常痛苦的事情。这时候把逻辑抽成一个自定义指令,挂在v-model没有侵入性,再配合el-input的v-model直接复用,是更工程化的选择。指令的inserted钩子里把input事件挂上去,unbind钩子里再把监听解绑,防止内存泄漏。
Vue.directive('price-input', { inserted(el, binding, vnode) { el.addEventListener('input', (e) => { const result = formatPrice(e.target.value) if (result !== e.target.value) { e.target.value = result vnode.componentInstance.$emit('input', result) } }) }, unbind(el) { el.removeEventListener('input', el._priceHandler) } })这个写法的好处在于:页面上哪个输入框要限制价格格式,直接加一个v-price-input指令就好,不再需要复制一堆逻辑。坏处是,如果你们的项目没有指令注册的好习惯,第一次见到这个写法的同事会有点懵。
2.4 三种方案的对比总结
| 对比维度 | 方案A:input正则过滤 | 方案B:keydown拦截 | 方案C:自定义指令 |
|---|---|---|---|
| 实现复杂度 | 中等 | 低 | 高(需要指令封装) |
| 拦截中文输入法 | 能 | 不能 | 能 |
| 拦截粘贴文本 | 能 | 不能 | 能 |
| 复用性 | 低(每处都要写) | 低 | 高 |
| 可维护性 | 一般 | 一般 | 好 |
| 适用的项目规模 | 小型项目、单个页面 | 临时方案 | 中大型项目、多场景复用 |
我的结论是:小项目可以直接用方案A把逻辑写进method里,虽然代码长了点,但看得见摸得着。项目里有多个金额输入需求的,直接上方案C,抽一个指令或工具函数统一管理。
3. 实操落地:完整实现与正则细节
3.1 第一步:先解决输入过程中的过滤
我最终落地的核心逻辑是一个formatPrice函数,它接收用户输入的原字符串,返回清洗后的合法价格字符串。这个函数同时承担了几件事:
- 剔除所有非数字、非小数点的字符
- 只保留第一个小数点
- 限制小数点后最多两位
- 去掉整数部分开头的多余零
- 如果什么数字都没输入,把空字符串返回
这里最关键的一点是:这个函数不会在用户只输入到一半时"拒绝"输入,而是把可能非法的字符直接清掉。比如你输入了1.2.3.4,函数会把多余的2.3.4这截的后两个小数点丢掉,只保留1.23,然后把结果回填到输入框。这比"提示说不能那么输然后就清空整个输入框"的体验好太多。
3.2 完整代码:一个可落地的价格输入框
<template> <el-input v-model="price" placeholder="请输入价格" @input="handlePriceInput" @blur="handlePriceBlur" /> </template> <script> export default { data() { return { price: '' } }, methods: { // 输入过程中实时清洗 handlePriceInput(value) { this.price = this.formatPrice(value) }, // 失焦时做一次兜底校验 handlePriceBlur() { if (this.price === '' || this.price === '.') { this.price = '' return } // 如果以小数点结尾,自动补零 if (this.price.endsWith('.')) { this.price = this.price + '0' } // 如果只输入了小数点,转成空 if (this.price === '.') { this.price = '' } // 解析成数字再回填,可以顺便消除浮点误差产生的长小数 const parsed = parseFloat(this.price) if (!isNaN(parsed)) { this.price = String(Number(parsed.toFixed(2))) } }, formatPrice(value) { if (value === undefined || value === null) return '' // 1. 清理字符:只保留数字和小数点 let result = String(value).replace(/[^\d.]/g, '') // 2. 如果输入的是小数点的另一种写法,补一个前置0 if (result.startsWith('.')) { result = '0' + result } // 3. 只保留第一个小数点 const firstIndex = result.indexOf('.') if (firstIndex !== -1) { result = result.slice(0, firstIndex + 1) + result.slice(firstIndex + 1).replace(/\./g, '') } // 4. 限制小数位数为2位 if (firstIndex !== -1) { const intPart = result.slice(0, firstIndex) const decimalPart = result.slice(firstIndex + 1, firstIndex + 3) result = intPart + '.' + decimalPart } // 5. 去掉整数部分开头的多余0(但保留单个0) result = result.replace(/^0+(?=\d)/, '') return result } } } </script>这段代码在逻辑上有几个细节值得注意。第一,startsWith('.')时自动补0,这意味着用户直接输入.会变成0.,体验上是合理的。第二,小数位数限制用的是slice(firstIndex + 1, firstIndex + 3),也就是最多取两位,多出来的直接截掉。第三,去零的逻辑用的是/^0+(?=\d)/,它会匹配开头一连串的0,但后面必须还有一个数字,所以0.88不会被误伤,01.2会被改成1.2。
3.3 正则逐段拆解:看懂才能改对
很多同学在别人的代码里看到正则就直接复制,但一旦规则变了一点就完全不会改了,所以我在这里把常用的几个正则拆开讲一讲。
/^\d+(\.\d{0,2})?$/是完整校验价格的正则:
^匹配字符串开头\d+表示一位或多位数字(\.\d{0,2})?表示一个可选的以小数点为开头的分组,后面的\d{0,2}表示0到2位数字$匹配字符串结尾
这个正则的问题是,它要求整个字符串必须从头到尾满足格式,所以在input事件里用test去判断的话,用户输入12.会被判为非法。这就是我前面说的"过程非法"的典型案例。
清洗用的replace正则是/[^\d.]/g,意思是匹配所有不是数字也不是小数点的字符并全局替换为空。其中[^\d.]是一个字符类的否定形式,^在字符类里面表示取反。
开头去零的正则是/^0+(?=\d)/,这里用了(?=\d)这个正向先行断言,意思是"匹配一个位置,该位置前面是0,后面是数字"。改用这个写法可以保证0.5不会被改成.5,因为0后面的字符是.而不是数字。
3.4 特殊场景:预填值、清空、小数位
实际开发中还有几个和"限制输入"本身无关、但必须一起处理的场景。
第一个是初始赋值。如果你的输入框是从接口回填数据的,那么你不能保证后端返回的一定是合法的价格格式。比如后端可能返回12,也可能返回0.00,甚至可能返回一个空串。我的建议是在赋值的时候也调用一遍formatPrice,确保进入输入框的值就已经是清洗过的。
第二个是清空。用户把输入框全部删掉之后,value会变成空字符串,这个状态本身是合法的,所以formatPrice里要处理这种情况,直接返回空字符串,而不是把0塞回去。
第三个是小数位数动态变化。有些业务场景下,商品价格要求保留2位小数,但优惠券面额可能只保留1位。这种需求最好在formatPrice函数外面套一个配置参数,比如formatPrice(value, decimalPlaces = 2),这样按照不同的业务设置不同的小数位上限,函数内部把固定值2替换成参数就好,没必要为了每个精度写一遍逻辑。
4. 常见问题:我踩过的坑和排查记录
4.1 中文输入法绕过了keydown拦截
这是我在实际项目中踩过最多遍的坑。明明keydown已经拦住了非法按键,但用户用中文输入法在输入框里打完数字和字母再确认,那一串字符全都涌进了输入框。代码逻辑上确实拦截了键盘事件,但组合输入产生的确认事件触发的input事件里,直接夹带了原始字符串,而keydown阶段根本看不到这些字符。
解决方案就是围绕input事件配合composition事件。如果检测到输入处于组词状态,就先不处理,等compositionend触发后再统一清洗。实际上,如果你直接调用我在3.2中写的formatPrice,它会从最终结果里把非法字符全抠掉,因此不区分是否走输入法,结果都是正确的。这个回调在输入过程中多出来的工作极小,因为它只是做了一个正则替换,性能上没有影响。
4.2 粘贴和拖拽:校验的漏网之鱼
这是另一个高发问题。很多价格输入的方案只处理了键入,没有处理粘贴。用户从Word、Excel或者网页里复制一段价格:¥12.50(含税),直接Ctrl+V粘贴进输入框,如果你没有做粘贴内容清洗,那么整段乱码就全进去了。
处理粘贴的方法有两个层面:第一是在handleInput里接收到的value已经是包含了粘贴内容的新字符串,formatPrice会自动把所有非数字和小数点的字符清掉,所以"价格:¥12.50(含税)"会被清洗成12.50。第二是如果你还想更进一步,在粘贴发生前就直接阻止某些内容进入,可以监听paste事件并检查clipboardData.getData('text')的内容,但经过我的实验,只做第一层处理已经能覆盖绝大多数场景,第二层一般不用写。
拖拽文本进输入框的场景更冷门,但Windows下偶尔能遇到。这个不通过immediate事件处理,但只要输入框发生input事件,formatPrice依然能兜住。所以结论不变:input事件里的清洗逻辑,是你必须守住的最后一道防线。
4.3 精度问题:parseFloat带来的科学计数法
输入限制做完了,很多开发以为万事大吉。结果在提交接口时,发现传给后端的值是科学计数法格式。比如你输入了很长的整数部分,比如12345678901234567.89,在JavaScript里用parseFloat解析,然后转成字符串时会变成1.2345678901234568e+16。
这个问题的根源是JavaScript的浮点数精度上限。超过Number.MAX_SAFE_INTEGER时,数字本身就是不精确的。我的处理方式是:在传输层使用字符串格式,不做Number转换。也就是说,整个输入框内部所有处理流程都保持字符串类型,只在需要参与计算的时候用转化成数字,一旦转化出现精度风险就改用其他计算方式,比如第三方库或字符串拼接运算。
到了blur那一步,parseFloat处理时如果发现解析结果带有e,就说明已经发生了精度丢失,这时候最好回到字符串原始值,不要强行转。
4.4 性能与体验:高频输入时的处理细节
限流、防抖,在输入框的高频调用里到底需不需要?我的结论是:价格输入框的清洗逻辑本身非常轻量,一个正则替换加上几个字符串方法,纯JS操作耗时在微秒级别,完全不需要防抖。
但有一个隐藏的性能问题在于,如果你在handleInput里直接对this.price做赋值,每次输入都会触发Vue的响应式更新。在大型表单页面里,渲染开销虽然不至于崩,但当输入框很多时,会对整体交互流畅度有一定影响。实际体验上,如果表单里有十几二十个价格输入框,我会建议把清洗逻辑做成纯函数,放在一个全局工具文件里,只做字符串运算,不涉及Vue实例,这样最小化响应式依赖的影响。
另一个体验细节是光标位置。当用户在字符串中间插入内容时,清洗函数可能改动了字符串长度,会导致光标跳到末尾。这个问题我在写格式化组件时也遇到过,但在价格输入的场景里,用户通常是从左往右输入完整数字的,所以光标问题很少出现。如果你真的遇到了,可以在赋值前记录selectionStart,赋值后重新设置光标位置。
| 常见问题 | 触发场景 | 推荐处理方式 |
|---|---|---|
| 中文输入法绕过keydown | 拼音/五笔输入数字 | 用input事件做最终清洗 |
| 粘贴脏文本 | 复制Excel、网页中的价格 | 清洗函数过滤非数字字符 |
| 多个小数点 | 手滑输入1.2.3 | 只保留第一个小数点 |
| 科学计数法 | 长整数+toFixed转字符串 | 传输保持字符串格式 |
| 开头补零 | 输入007 | 正则去掉多余0,保留单个0 |
| 浮点精度 | 0.1+0.2 | 计算进后台或字符串截断 |
5. 工程化扩展:让价格限制变成可复用能力
5.1 抽离成通用工具函数
如果你在项目里遇到的不只是一个价格输入框,而是订单金额、运费金额、折扣金额等多种价格字段,那就值得把清洗逻辑抽出来放在公共的utils里。我在项目里最终维护了一个formatPriceInput函数,传入值和保留小数位,返回清洗结果。这个函数不依赖任何框架,任何组件里都能用。
// utils/price.js export function formatPriceInput(value, decimalPlaces = 2) { if (value === undefined || value === null) return '' let result = String(value).replace(/[^\d.]/g, '') if (result.startsWith('.')) result = '0' + result const firstIndex = result.indexOf('.') if (firstIndex !== -1) { result = result.slice(0, firstIndex + 1) + result.slice(firstIndex + 1).replace(/\./g, '') result = result.slice(0, firstIndex + 1 + decimalPlaces) } result = result.replace(/^0+(?=\d)/, '') return result }这个函数接上了我前面说的小数位数动态变化的场景,通过第二个参数控制保留几位小数。在金额一般取2,在某些积分或折扣场景取0或1,改动量很小。
5.2 从"价格格式"扩展到更多格式场景
价格输入框的限制逻辑,本质上是对用户输入的一种格式化约束。同样的思路,举一反三还能应用到其他输入场景。比如:
- 只允许正整数:
value.replace(/[^\d]/g, '').replace(/^0+(?=\d)/, '') - 手机号输入:只允许数字,且限制11位
- 身份证号:只允许数字和X
- 银行卡号:只允许数字,且可以每4位插入空格
- 金额千分位显示:在价格格式基础上增加逗号分割
这些场景共同的底层逻辑都是"先清洗非法字符,再按格式要求做拼接或截断",区别只在正则表达式和截断位置。所以写好一个核心的formatPriceInput,你在遇到其他格式化输入需求的时候,会省掉大量从头摸索的时间。
5.3 与表单验证规则配合的完整方案
限制输入的另一个作用是配合表单校验。很多团队只写el-input的限制逻辑,忘了加上blur时的合法性别校验,结果用户输入了一个残缺的数字也能提交给后台。
正确的做法是双保险。第一道防线是input事件里的清洗函数,确保输入框里的值始终是近似合法的格式;第二道防线是表单校验规则,在提交时用完整的正则/^\d+(\.\d{1,2})?$/验证最终值,确保不是空值、不是1.这样的小数点结尾。
rules: [ { required: true, message: '请输入价格', trigger: 'blur' }, { pattern: /^\d+(\.\d{1,2})?$/, message: '价格格式不正确,最多两位小数', trigger: 'blur' } ]这里注意,如果你允许用户输入0.50,那正则里的整数部分需要是\d+而不是[1-9]\d*,后者会直接拒绝掉0.5这类正确格式。还有一种情况是有业务上不允许价格为0,那要再加一个自定义校验器,金额等于0时提示"价格必须大于0"。
我在实际项目中就是这样配置的:清洗函数负责输入过程中的体验,表单校验负责提交前的最终拦截。两套逻辑互相独立、各司其职,既保证用户输入顺畅,也保证数据提交到后端前是100%干净的。
如果你刚开始做这个功能,我的建议是先把3.2那段完整的组件代码复制到你的页面里跑一遍,再逐个调整细节,比上来就写指令封装和工具函数要稳妥得多。踩过几次坑之后,你自然会理解哪些规则是必须的,哪些规则是自己项目里特有的。