做后台管理系统的时候,肯定绕不过一类组件:金额输入框。el-input本身是纯文本输入,如果不做任何限制,用户能往里面塞各种奇怪内容——字母、符号、空格、甚至带上emoji,提交到后端以后要么报错,要么让校验弹窗变得异常狰狞。标题里的“限制只能输入价格格式”看起来是个小需求,真做起来又有不少细节:正则怎么写才不误杀中间状态,中文输入法会不会把拼音首字母塞进输入框,粘贴一段“12.3.4元”怎么处理,失焦之后需不需要自动补两位小数。这篇文章我把整个思路和踩过的坑从头到尾捋一遍,直接给出一套能落地的方案。
我习惯把这类需求拆成三层防线来处理:输入过程实时过滤、失焦时格式化兜底、表单提交时再校验一次。三件事各司其职,单靠某一个环节都会出问题。比如只做表单校验,用户输入阶段依然能看见一堆乱码,体验很差;只做输入过滤不校验,某些边界情况(比如直接粘贴一个异常值)可能漏过去。所以全部都要,但各自管好自己的事。
1. 需求拆解与方案选型
1.1 为什么不能只靠表单校验
很多刚接触这个需求的人第一反应是写一个校验规则,提交时告诉用户“请输入正确金额”。这个思路本身没错,但它解决的是“提交后发现问题”,而不是“输入时不出问题”。
设想一个场景:用户在金额框里敲了个“1.2.3”,他其实自己也没意识到多输了一个小数点,直到点击提交被告知格式错误。接着他得回头逐字检查到底哪里错了,这个来回折腾的过程非常影响体验。所以在输入的一瞬间就把非法字符过滤掉,让用户根本没有机会输入错误格式,这才是这个需求真正的核心价值。
另外一点是,价格输入框通常意味着后端要接收到一个干净的数据。如果提交之前再让后端处理脏数据,很容易出现精度问题或类型问题。与其在接口层做各种防御,不如在最源头就把格式限定死。
1.2 type="number" 为什么是条歪路
有人会说:El-input的type设成number不就行了?这个方案看起来省事,实际用起来坑很深。
先说e的问题。在大部分浏览器里,type="number"的输入框是允许输入字母e的,因为那是科学计数法的一部分。用户压根不懂什么是科学计数法,只是觉得“怎么还能输字母”。加减号也是同理,number类型原生允许正负号,但价格通常不该出现负号。滚动滚轮还会自动改值,这个在桌面端很容易误触,表格页面手一滑,金额就变了,而且很难排查。
更麻烦的是不同浏览器对这个输入框的表现不一致。某些移动端浏览器的number键盘并不是纯数字键盘,有的会显示带小数点的符号键盘,有的展示全数字键盘,设计上很不统一。而且它无法直接限制小数位数,你输入“1.234”,el-input的v-model能拿到这个值,除非额外处理,否则它就会原样传给后端。type="number"的定位是“提交前允许用户输入数字类型”,不是“格式化为价格”,两者的目标完全不一样。
所以我的选择是:type继续用text,靠手动控制过滤逻辑,这样行为完全可控,不再依赖浏览器差异。
1.3 三层防线的整体设计
第一层,在input事件里实时过滤。每次用户敲键盘、粘贴、拖拽内容时,把不符合价格格式的字符全部去掉。这一层负责保证输入框里永远不会出现脏数据。
第二层,在blur失焦时做格式化。价格一般来说应该保留两位小数,但用户输入过程中不应该强迫他必须按两位去敲(那样很反人类)。失焦以后把0.5格式化成0.50,把1.230格式化成1.23,把空值处理成空字符串或者回退成0,让数据以一个规范的状态进入下一环节。
第三层,表单校验。这一层不是为了抓脏字符,而是为了保证语义正确:比如输入不能为空、不能超过业务允许的最大金额、小数位最多两位等。前面两层已经保证了格式,第三层保证的是业务规则。
2. 核心实现:正则过滤与控制逻辑
2.1 正则搭配要选对,逐字判断和整体判断不一样
先看一个很多人直接拿来用的正则:
/^\d+(\.\d{0,2})?$/这个正则看起来很完美:整数部分至少一位,小数部分可以有但最多两位。然而直接拿它在input事件里做校验,一输入就出问题。你初始化的时候输入框是空的,用户刚打开页面,还没敲数字,此时空字符串在被校验时会失败。用户输入“1.”的时候,其实是合法的中间状态,但上面这个正则也会失败,因为\d+要求整数部分至少一位,“1.”最后那个点是合法的,但正则不允许字符串末尾悬着一个点。如果直接用这个正则做“非法即拦截”,用户连“1.5”都输不进去。
正确的思路是:过滤非法字符时,应该用“允许出现的字符”集合来过滤,而不是用完整格式规则来否决一切。
我的做法是用这个正则去替换掉非法字符:
let filteredValue = value.replace(/[^\d.]/g, '');这行代码会把所有不是数字和小数点的字符全部删掉。但这样还不够干净,比如“1.2.3”这种,数字和小数点都是合法字符,不会被上面的正则清掉,所以还需要做下一步约束。
2.2 在 input 事件里做逐字过滤
完整思路可以这样写:拿到用户输入的值,先去掉非法字符,再处理小数点和长度的限制,最后回填到输入框。
function formatInput(value) { // 1. 去掉所有非数字和小数点的字符 let result = String(value).replace(/[^\d.]/g, ''); // 2. 只保留第一个小数点,后面的小数点全部删除 const firstIndex = result.indexOf('.'); if (firstIndex !== -1) { result = result.slice(0, firstIndex + 1) + result.slice(firstIndex + 1).replace(/\./g, ''); } // 3. 限制小数位最多两位(这个限制放在输入过滤里,避免用户输入 1.234) if (firstIndex !== -1) { const integerPart = result.slice(0, firstIndex); let decimalPart = result.slice(firstIndex + 1); decimalPart = decimalPart.slice(0, 2); result = integerPart + '.' + decimalPart; } // 4. 限制整数位数(比如最多7位,对应最大金额9999999.99) const dotIndex = result.indexOf('.'); const intPart = dotIndex === -1 ? result : result.slice(0, dotIndex); if (intPart.length > 7) { result = dotIndex === -1 ? intPart.slice(0, 7) : intPart.slice(0, 7) + result.slice(dotIndex); } return result; }这里有一个很多人会忽略的细节:强制把过滤后的值回填给输入框时,光标位置会跳。比如用户输入了一大串数字,中间发现输错了想删掉一位,输入事件触发后输入框内容被重新赋值,光标直接跳到末尾,这种体验很糟糕。
解决办法是记录光标位置并恢复:
const pos = input.selectionStart; const newValue = formatInput(input.value); if (newValue !== input.value) { input.value = newValue; // 光标位置做一定的偏移修正 const newPos = Math.min(pos, newValue.length); input.setSelectionRange(newPos, newPos); }具体偏移需要看过滤掉了几个字符。如果用户在光标前输入了一个非法字符,过滤会删掉它,此时光标应该保持在原位置或回退一位;如果用户输入的是合法内容,光标正常前进。这里不追求绝对完美,但至少要保证光标不会乱跳到末尾。
2.3 边界情况:小数点开头、多个小数点、负号
小数点开头的情况,比如用户输入“.5”。中间状态是允许的,因为用户可能接下来会补一个0变成“0.5”。所以过滤逻辑里不能一看到开头是小数点就删除。正确的做法是失焦格式化时把它补成“0.50”。
多个小数点,直接由过滤逻辑里的第二步处理。第一个点保留,后面的点全部删掉。这个策略的好处是用户不会因为输入被强行中断而困惑,而是输入“1.2.3”后自己看到变成了“1.23”,马上意识到规则。
负号按业务来。价格通常是正数,所以负号一律过滤掉。如果某些场景下需要允许负数,比如记账时有支出,那过滤逻辑得额外保留一个位于开头的负号,并且不允许“1-2”这种中间出现的负号。我只讲正数价格的场景,负数场景实在要扩展也是同理,只是多一个判断。
3. 中文输入法、粘贴与移动端适配
3.1 输入法组合态问题不解决,中文用户直接骂人
这个坑几乎每个做过输入过滤的人都会踩一次。用户用中文输入法打字时,输入“abc”的拼音字母会触发input事件,此时如果你的过滤逻辑直接删字母,输入法会立刻“抽风”——拼音刚打了两个字母就被删掉,导致候选词完全出不来,用户根本没法正常打字。
这里需要用composition相关的事件来做保护。compositionend事件表示输入法组合结束,compositionstart事件表示组合开始。在组合期间,input事件虽然会触发,但输入的内容是拼音的中间态,不应做任何过滤。
let isComposing = false; el.addEventListener('compositionstart', () => { isComposing = true; }); el.addEventListener('compositionend', (e) => { isComposing = false; // 组合结束后的值也要过滤一遍,防止有残留非法字符 handleInput(e); }); el.addEventListener('input', (e) => { if (isComposing) { return; } handleInput(e); });这样用户在输入框里通过拼音打了一个“元”字,组合期间拼音字母不会被删,combositionend后整个“元”字落进输入框,再被过滤逻辑删掉。虽然结果是用户打了一个汉字然后消失了,但至少输入法本身能正常使用,不会出现那种“打字就断”的崩溃体验。
3.2 粘贴的内容必须先过一遍过滤器
用户很少会手动乖乖敲数字,更多时候是从Excel、Word、网页上复制金额数据粘贴过来。复制内容可能是“¥1,234.56”或“12.34元”,甚至是从表格里复制出来的“1,234.56”带千分位逗号。
我们的过滤逻辑本身已经把非数字和非小数点的字符去掉了,所以粘贴“¥1,234.56”进来,结果是“1234.56”,正好是想要的值。逗号被删掉,人民币符号被删掉,数据处理得很干净。
但粘贴有一个特殊情况:某些时候过滤后值一模一样,比如粘贴“123.45”进去,过滤前后没有变化,此时不需要重新赋值,光标保持原样就行。这一点在上面的代码里已经体现了。
还建议大家处理一下paste事件,直接在粘贴发生之前拦截并处理掉,可以避免一些浏览器对中文输入法组合状态的干扰。不过我的经验是,input事件里统一处理已经足够,paste事件拦截属于可选的加强措施。
3.3 移动端键盘和桌面端的差异处理
type="text"的输入框在手机端默认弹起的是全键盘,打字比较折腾,所以移动端需要设置inputmode属性,告诉浏览器我们只需要数字和小数点键盘。
<el-input :model-value="price" inputmode="decimal" />inputmode="decimal"会在支持的浏览器上弹起带小数点的数字键盘,iOS和Android均支持。注意这里不能依赖type="number",原因前面已经说过了,e和正负号的问题在移动端同样存在。
另一个移动端的问题是,很多Android键盘在输入完数字后,会出现“换行”键而不是“完成”键。对输入框来说影响不大,但如果你用的是表单,用户点换行可能触发不了提交,需要考虑用enterkeyhint="done"来提示键盘展示“完成”按钮。这个属于锦上添花,但做了以后移动端体验会明显提升。
4. 价格精度与格式化输出
4.1 浮点数计算的坑
价格是典型的不能直接用浮点数做运算的数据。0.1 + 0.2不等于0.3这个经典问题在这里也一样:用户输入0.06元,乘以100份额,后端返回6.000000000000001之类的东西,页面显示起来就是一连串不敢看的数字。
价格相关的计算,最稳的办法是全部转成整数分来做。也就是说,任何涉及到两位小数的运算,先把数字乘以100取整,运算完再除以100。比如要计算“12.34 + 5.66”,不要直接加,而是算(1234 + 566) / 100。
如果只是想格式化显示,用toFixed(2)是够的,但做计算想避免浮点误差,就要尽量绕开二进制浮点运算。
// 不要这样 const total = 0.1 + 0.2; // 0.30000000000000004 // 要这样 const total = Math.round((0.1 * 100)) + Math.round((0.2 * 100)); // 30(单位分)4.2 失焦时怎样格式化才不招人烦
失焦格式化的目标是让数据规范,但也不能矫枉过正搞得太死板。我的策略是:
输入框失焦时,把值做一次完整格式化和补全。空值保持为空,不强行改成0,除非业务要求空值默认为0。是“2.”的补成“2.00”,是“.5”的补成“0.50”,是“1.2”的补成“1.20”,是“1.234”的已经被过滤成“1.23”了,所以这里主要处理补零的事。是“123”的保持“123”还是补成“123.00”,看产品需求。我个人更喜欢补成“123.00”,因为这样表达更明确。
具体代码很简单:
function formatBlur(value) { if (value === '' || value === null || value === undefined) { return ''; } const num = Number(value); if (isNaN(num)) { return ''; } return num.toFixed(2); }有一个注意点:toFixed()返回的是字符串,如果你直接赋值给v-model,原来的number值会变成string,这个在提交时需要统一处理。我的做法是v-model的字段保持string类型,提交时再转成数字或分。
4.3 上限和整数位数的设计
价格输入必须考虑上限,否则用户输入99999999999.99,后端大概率会溢出或抛异常。我建议根据业务实际设置一个封顶值。
比如业务限定最大金额不能超过一千万元,那么整数位最多7位,小数位固定2位,最大合法值是9999999.99。过滤器里对整数部分做一个slice(0, 7)就能硬性截断。
这个硬截断可能让用户觉得“我还在输入怎么就被截了”。比较好的体验是:输入阶段只过滤非法字符和过多的位数,等失焦时如果超了,给出一次性提示。但多数团队不愿意为这种极端场景增加复杂度,所以直接截断也能接受。具体看你们的产品定位,我一般选择硬截断,因为用户输入超长数字时,其实他已经犯了错,提前截断等于提前纠错。
5. 表单校验与整体集成
5.1 自定义 validator 的写法
Element的表单校验底层是async-validator,自定义规则时直接写validator函数即可。
const validatePrice = (rule, value, callback) => { const strValue = value == null ? '' : String(value); if (strValue === '') { // 是否必填,看你们的业务。这里假设必填 callback(new Error('请输入金额')); return; } const regex = /^\d+(\.\d{1,2})?$/; if (!regex.test(strValue)) { callback(new Error('金额格式不正确,请输入例如 12.34 的格式')); return; } const num = Number(strValue); if (num > MAX_PRICE) { callback(new Error(`金额不能超过 ${MAX_PRICE}`)); return; } callback(); };注意正则里的\d+,它要求整数部分至少一位。所以“0.5”是可以通过的,“.5”不能通过,这个语义和失焦格式化之间的衔接要处理好。我的做法是失焦时已经把“.5”格式化成“0.50”了,所以提交时拿到的是规范字符串,validator不会误判。
另一个容易忽略的点是:如果v-model绑定的是number类型,有时候用户输入“1.”会被框架转成“1”,导致显示和校验结果不一致。所以这类输入框我建议v-model直接用String类型绑定,保持输入原始文本。
5.2 与 el-form 联动的细节
Element的表单校验默认在值变化时触发。如果你在input事件里做了过滤,过滤后的值赋值给v-model,它的watch会自动触发校验,这时候用户输入非法字符的瞬间,表单错误提示就会弹出来。这个体验我觉得太吵了。
我建议这样配置:
<el-form-item prop="price" :rules="priceRules"> <el-input :model-value="price" @update:model-value="handleInput" @blur="handleBlur" /> </el-form-item>handleInput里做过滤和赋值,handleBlur里做格式化和显式校验。通过控制validateTrigger,可以让它只在失焦时校验:
<el-form-item prop="price" :rules="priceRules" validate-trigger="blur">这样用户在输入过程中不会被打断,失焦时才会看到错误提示,显得更友好。
5.3 封装成通用组件
做过两三个项目之后,我强烈建议把这个逻辑抽成一个独立组件,而不是每次copy代码。组件的props可以简单收敛为下面几个:
- modelValue:字符串或数字,v-model绑定
- max:最大金额
- precision:小数位数,默认2
- disabled、placeholder这些跟el-input保持一致
过滤、格式化、失焦、校验全部封装在组件内部,外部只关心拿到一个规范的string。后续团队成员谁要用都不会再踩重复的坑。
6. 常见问题与排查技巧实录
6.1 问题速查表
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 输入e、+、-照样能进 | 过滤器没有对字母和符号做处理 | 用[^\d.]替换非数字和小数点字符 |
| 输入1.2.3能输进去 | 没有处理多个小数点 | 只保留第一个小数点,后面的删除 |
| 中文输入法下按数字键没反应 | input事件在composition期间误删拼音 | 监听compositionstart/end,组合期间跳过过滤 |
| 拼音输入的字母被当成内容 | 组合期间过滤被跳过 | 在compositionend后做一次统一过滤 |
| 输入0.06+0.01计算结果异常 | 浮点运算误差 | 转成整数分后运算 |
| 粘贴“¥1,234.56”进来多了很多字符 | 过滤写成只挡键盘输入 | 确保过滤逻辑同样作用于粘贴内容 |
| number类型的el-input能滚轮改值 | 滚轮默认行为 | 使用type=text避免此问题 |
| 失焦后0.5变成了undefined | 没有做类型转换就toFixed | 先Number(value)再toFixed |
6.2 三个实战小技巧
第一个,自动填充的坑。浏览器有时会把之前填过的手机号、姓名自动填充到输入框里,这些内容往往包含中文字符,会直接绕过你的过滤逻辑出现在页面上。解决方式是在el-input上加上autocomplete="off",有的浏览器可能还不够,需要加一个name="off"之类的属性,具体看浏览器策略。
第二个,测试时记得模拟粘贴操作。手动敲键盘只能验证一部分逻辑,粘贴操作必须单独测。我在实际开发中会用浏览器的控制台手动给输入框塞值,比如el.value='¥1,234.56',然后dispatchEvent(new Event('input')),确保过滤逻辑对程序化赋值同样生效。
第三个,中间状态“1.”的取舍。很多产品经理会坚持要求“用户输入1.的时候,焦点离开后自动变成1.00”,但他可能没想过如果用户输入“1.”后马上又输入“2”,最终正确值应该是1.2,失焦补零的逻辑必须考虑到这一点,不能在“1.”的中间态就强制补成“1.”。稳妥的做法是不管中间态,只在失焦时格式化。
6.3 踩过几次坑之后我的一些经验
这个需求我第一次做的时候,只写了一个input事件replace,结果被中文输入法坑到怀疑人生。后来加上composition保护,过了几天又被浮点数精度问题恶心到。现在再遇到类似需求,我第一个动作就是确认“输入阶段、格式化阶段、校验阶段”是否都划清楚了。
另外发现一个很实用的思路:不要过度设计。有些开发者会想写出一个完美无缺的正则,把所有可能性都堵死,结果用户输入“.”都费劲。价格格式的核心约束其实就是“数字+一个小数点+最多两位小数”,围绕这个约束去做,逻辑不会太复杂,一定不要为了防万一而把用户的合理操作也拦了。
最后再分享一个小技巧:可以给输入框的placeholder里明确写出“请输入≥0的金额,最多两位小数”之类的提示,别小看这句话,它能让用户从一开始就带着正确的预期去操作,输入错误率会低很多。格式上的问题解决以后,还要记得跟后端确认最终的金额传输格式——是字符串还是数字、以元为单位还是以分为单位,这些约定最好在接口文档里写清楚,避免前后端各自解释各自的路。