Vue+ElementUI金额输入框:正则控制、光标处理与三层拦截

Vue+ElementUI金额输入框:正则控制、光标处理与三层拦截 1. 需求拆完才发现这不是贴一条正则就能收工的事elementUI 的输入框“只允许输入数字、最多两位小数”是我在后台管理项目里接到过最高频的需求。多数人第一反应是“贴一条正则就行”但真正用正则去控制输入过程的时候问题一个接一个冒出来。这篇文章把这件事从需求拆解、事件链路、三层拦截到项目封装完整讲一遍适合正在写表单的同学也适合想把这个功能做成公共组件的人参考。先说我为什么专门想写它。这个功能表面上看太简单了简单到很多人不愿意多花五分钟去设计最后交付的输入框都是“勉强能用”的状态用户多按一个键就跳一下、粘贴一串金额进来直接乱掉、中文输入法下打出几个汉字混在数字里。这些小问题单个看都不致命但在一个要上线的后台系统里数量和金额字段天天被财务同事用体验差一点吐槽就能攒一屏。1.1 所谓“两位小数数字”实际藏着五个约束把一个需求写清楚比写代码更重要。这个输入框看似只有一句话拆开其实是五条约束少处理一条都会出 bug。只能出现数字和小数点其他字符一律不能进小数点最多出现一次小数点后最多两位数字小数可以不输入但失焦以后要么保持整数要么统一补成两位输入过程中的中间态必须允许比如用户刚输入完1.还没输小数位时这一瞬间它不是合法数字但你不能拦他。前三条是规则约束后两条是过程约束。规则约束用正则匹配完全可以覆盖过程约束就得靠事件链路一层层处理。很多人写出来难用就是把这两类约束混在一起想在一条正则里把五个条件全部解决结果处处碰壁。1.2 校验和控制是两码事别混在一起写再往下挖一层会发现“用正则校验”和“用正则在输入过程中控制”是两个维度的东西。校验是事后判断值已经产生了用一条正则判断它合不合法不合法给提示。控制是事前拦截在用户按下按键、粘贴内容的那一刻就去管让不合法的东西根本进不了输入框。标题里说的“用正则控制输入”重点在后者。但这里有个关键认知正则本身不承担“控制”功能。正则只能描述一个字符串长什么样它没法决定用户按下某个键之后 Vue 的 data 里应该是什么。真正控制输入的是输入框那一串事件keydown、input、blur、composition 等等正则只是在每个事件回调里充当过滤器。想通这一点后面写代码就不会被“一条正则解决所有问题”的思路困住。2. 事件链路梳理先搞清楚在哪一层动手我做这个功能的早期版本只写了 input 一个处理函数想着“反正每次输入都会触发我在里面清洗值不就完了”。结果被光标乱跳、值回退、输入法串键折磨了很久。后来老老实实把事件链路捋了一遍才发现每个拦截动作都有它最合适的位置。2.1 从按键到数据落地的完整顺序一个用户输入从按下键盘到数据真正进入业务模型要经过这四个阶段。keydown按键刚按下此时输入框的 value 还没变这是拦截“非法按键”的最佳时机inputvalue 已经变了input 事件携带最新的值这是清洗脏数据的最后一道闸门change输入框的 change 在失焦时触发和 blur 挨得很近blur失焦事件适合做最终格式化比如补零、去前导零、截断超长整数位。顺序大致是按下键触发 keydown这里可以决定“让不让这个键生效”如果没拦住value 变化触发 input再清洗一轮最后失焦时 blur 兜底格式化。elementUI 的 el-input 把原生的 keydown、input、blur、paste 事件都透传出来了直接绑定处理函数就行不需要额外 hack DOM。2.2 v-model 的更新时机会让“清洗”变得很微妙el-input 通过 v-model 同步值而 Vue 的 v-model 内部监听的就是原生 input 事件。所以当你写el-input v-modelform.price inputhandleInput /handleInput执行的时候form.price已经是用户输入后的最新值了不管这个值有多脏。这里就是翻车高发区。很多人会在 handleInput 里把form.price清洗后直接赋值回去依赖 Vue 响应式更新去刷新界面。比如handleInput(val) { this.form.price val.replace(/[^\d]/g, ) }这条正则看着没毛病实际跑一下就会发现用户输12.34时输到12.小数点就会被 replace 干掉再输3变成了123。原因很简单[^\d]把小数点也当成非法字符删了用户永远无法输入小数部分。改成val.replace(/[^\d.]/g, )能保留小数点但用户输入1.2.3时小数点出现两次照样不是合法数字。还有人每次 input 都用完整正则/^\d(\.\d{0,2})?$/去 test不匹配就清空结果1.这种中间态直接触发拦截用户输小数时总觉得输入框在“抢键盘”。到了这一步结论已经很明确单靠一个事件、一条正则做不好这个功能。必须分层。3. 三层控制方案按键拦截、输入清洗、失焦格式化我最终稳定使用的方案是三段式keydown 拦按键、input 洗脏值、blur 做格式化。三层各管一件事互不替代但层层兜底。下面按层拆开讲。3.1 第一层在 keydown 直接拦掉非法按键这一层的目标很纯粹用户按下非法键的时候直接 preventDefault让这个字符根本没有机会进入 value。这样做的体验最好不闪烁、不跳光标。模板部分el-input v-modelform.price placeholder请输入金额最多两位小数 keydownhandleKeydown inputhandleInput blurhandleBlur /处理函数handleKeydown(e) { // 放行编辑键和功能键 const allowedKeys [ Backspace, Delete, Tab, Enter, Escape, ArrowLeft, ArrowRight, ArrowUp, ArrowDown, Home, End ] if (allowedKeys.includes(e.key)) return // 放行 Ctrl/Meta 组合键否则复制粘贴、全选全部失效 if (e.ctrlKey || e.metaKey) return // 数字键直接放行 if (/^\d$/.test(e.key)) return // 小数点允许第一次输入已经有小数点就拦掉 if (e.key .) { if (e.target.value.includes(.)) { e.preventDefault() } return } // 剩下的字符全部拦截 e.preventDefault() }几个细节说明一下。判断按键用e.key不要用e.keyCode。e.keyCode在数字小键盘、全角半角、不同键盘布局下表现不一致而e.key是逻辑键名1、.、Backspace都很直观。必须放行 Backspace、Delete、方向键、Home、End否则用户改错字都改不了会被骂的。Tab 和 Enter 也得放行不然表单无法通过键盘切换焦点、无法回车提交。Ctrl/Meta 组合键必须放行否则 CtrlC、CtrlV、CtrlA 全被拦掉这是很多“限制键盘”方案最容易误伤的地方。最后注意keydown 拦不住粘贴、拖拽、浏览器自动填充这些不会触发 keydown所以必须要有第二层。3.2 第二层输入阶段清洗粘贴和输入法漏进来的脏值第二层要做的事情只有一件拿到当前值用正则和字符串操作把它清洗成“最多两位小数的数字字符串”。sanitizeDecimal(value) { let str String(value ?? ) // 第一步只保留数字和小数点 str str.replace(/[^\d.]/g, ) // 第二步只保留第一个小数点 const dotIndex str.indexOf(.) if (dotIndex ! -1) { const intPart str.slice(0, dotIndex) const rest str.slice(dotIndex 1).replace(/\./g, ) str intPart . rest } // 第三步小数点后最多两位 if (dotIndex ! -1) { const intPart str.split(.)[0] const decPart str.split(.)[1].slice(0, 2) str intPart . decPart } return str } handleInput(val) { const cleaned this.sanitizeDecimal(val) if (cleaned ! val) { this.form.price cleaned } }为什么不用一条复杂正则一步到位因为“匹配”和“清洗”是两种逻辑。正则做匹配是判断题这串字符合不合法做清洗是改错题把不合法的部分去掉再拼回去。replace(/[^\d.]/g, )负责去脏字符indexOf加slice负责约束格式分开写每一行都看得懂比维护一条“神奇大正则”省心得多。比如粘贴进来的1,234.56第一步会把和,干掉得到1234.56。粘贴进来的1.2.3.4第二步会把后面的小数点全部移除得到1.234第三步再截掉4最终是1.23。3.3 第三层失焦后统一补零、去前导零输入过程保留1.、.5、001这些中间态体验是好的但失焦以后不能还是这副样子。金额框的普遍预期是你输5失焦变5.00输.5失焦变0.50输001.2失焦变1.20。handleBlur() { if (this.form.price || this.form.price null) return let str String(this.form.price) // 去掉末尾的小数点 if (str.endsWith(.)) str str.slice(0, -1) // 开头是小数点就补零 if (str.startsWith(.)) str 0 str const parts str.split(.) // 去掉整数部分多余的前导零001.23 - 1.23 parts[0] parts[0].replace(/^0(?\d)/, ) || 0 // 整数部分过长时截断这里按 9 位控制 if (parts[0].length 9) { parts[0] parts[0].slice(0, 9) } // 小数位没有就补两位不足两位也补到两位 if (parts.length 1) { parts.push(00) } else { parts[1] parts[1].padEnd(2, 0).slice(0, 2) } this.form.price parts.join(.) }有人会问为什么不直接在 blur 里用Number(str).toFixed(2)一把梭。可以但要注意toFixed的结果是字符串而且它做的是四舍五入。如果业务上允许用户输入超过两位小数然后自动四舍五入那用toFixed(2)更省事如果业务要求“最多只能输入两位”第三层这里用 padEnd 补零就够了不会改变用户本来输入的有效数字。这层还有一个容易忽略的价值接口提交的数据永远是规整的。后端拿到的不是5、.5、001.2这种五花八门的值而是统一的5.00、0.50、1.20少了很多数据清洗的扯皮。4. 实测踩坑记录光标乱跳、粘贴脏串、中文输入法三层方案不是一开始就长这样的。早期版本没有第一层 keydown只有 input 清洗和 blur 格式化结果被三个问题反复折磨后来一个个补上的。这一段把这些坑和对应解法完整记录下来你遇到相同问题时可以直接对照排查。4.1 光标跳跃问题input 里直接改 v-model 的代价只要在 input 事件里把清洗后的值赋值回 v-modelVue 就会重新渲染输入框浏览器光标会跳到字符串末尾。用户输入过程中一旦有字符被清洗掉光标瞬间跑掉比如他在数字中间想插入一个小数点结果插入后发现光标已经跑到最后。这个问题的根治思路是尽量让清洗动作不发生或者只发生在用户感知不到的地方。第一层 keydown 拦截已经挡掉大部分非法按键第二层的清洗只处理粘贴、输入法、自动填充这些特殊渠道光标跳跃的频率自然大幅降低。但特殊渠道下还是可能改值。这时候要手动保存并恢复光标位置handleInput(val) { const el this.$refs.priceInput.$el.querySelector(input) const pos el.selectionStart const cleaned this.sanitizeDecimal(val) if (cleaned ! val) { this.form.price cleaned this.$nextTick(() { el.setSelectionRange(pos, pos) }) } }注意$refs.priceInput.$el.querySelector(input)能拿到 el-input 内部的原生输入框因为在 elementUI 里 el-input 外层是 div原生 input 在内部。这个改法大多数场景够用但如果清洗后字符串长度变短恢复的光标位置可能和预期有偏差所以它是兜底策略不是首选。4.2 粘贴场景接管 paste 事件避免界面闪一下脏值后台系统里金额经常是从 Excel、邮件、聊天记录里复制过来的一粘就是一串带千分位、货币符号甚至汉字的脏字符串。比如用户从 Excel 复制1,234.56或者从某张表里复制1.234,56。不接管 paste 事件靠第二层的 input 清洗兜底也能把1,234.56洗成1234.56因为和,会被[^\d.]干掉。但有个体验问题粘贴动作发生后界面会先闪一下脏值再被清洗成干净值这个闪动在视觉上很明显。要顺滑一些就在 paste 阶段直接接管并清洗handlePaste(e) { e.preventDefault() const pasted (e.clipboardData || window.clipboardData).getData(text) const el e.target const start el.selectionStart const end el.selectionEnd const raw el.value.slice(0, start) pasted el.value.slice(end) this.form.price this.sanitizeDecimal(raw) this.$nextTick(() { el.setSelectionRange(start pasted.length, start pasted.length) }) }这里防止了默认粘贴行为把剪贴板内容取出来拼接后统一清洗再赋值到 model 上。光标位置的恢复也只是近似因为pasted清洗后长度可能不一样但比默认行为顺滑很多。如果项目对光标位置没有强迫症只做e.preventDefault()加上清洗赋值就已经够好了。另外提醒一句粘贴进来的字符串如果带-号上面的[^\d.]会直接把负号删掉。如果你要支持负数需要把-加进保留字符并且只允许出现在开头这个判断要在第二步专门处理不能一把梭。4.3 中文输入法用 isComposing 拦掉组词阶段中文输入法搜狗、微软拼音、手机九键输入过程中input 事件会触发多次而且每次携带的值可能是半截拼音或候选词。这时候用replace(/[^\d.]/g, )去清洗很容易把拼音字母过滤掉然后留下一个半中半英的脏值比如12三或者12san。解决办法是判断输入法是否处于组词阶段。浏览器在输入法组词时会依次触发 compositionstart、compositionupdate、compositionend在 compositionend 之前input 事件虽然会触发但业务逻辑不应该动它。handleInput(val, e) { if (e e.isComposing) return const cleaned this.sanitizeDecimal(val) if (cleaned ! val) { this.form.price cleaned } }e.isComposing是标准属性兼容性已经足够好了。Vue 的 v-model 本身也做了 composition 判断但 el-input 的 input 监听不会自动帮你过滤所以这一行必须自己写。顺带说一个相关的场景扫码枪。扫码枪本质上是一个超高速键盘每次扫描会瞬间触发一连串 keydown字符基本都是数字和点第一层正则能全部放行。但它的输入速度极快如果你在清洗逻辑里写了 debounce 或者异步处理就会把扫码内容切碎或者只保存后半段。所以扫码枪场景下清洗必须是同步的不要做任何节流。5. 封装成 v-decimal 指令一个项目一处维护上面的逻辑如果每个页面复制一遍后期维护就是灾难。我在项目里习惯把它封装成一个全局指令业务代码里一行就够。指令同时覆盖三层逻辑并且自动兼容 el-input 的内部结构。5.1 指令完整代码与注册方式// src/directives/decimal.js // 兼容 el-input外层是 div需要取内部 input function getInput(el) { return el el.tagName INPUT ? el : el.querySelector(input) } function sanitizeDecimal(value) { let str String(value ?? ) str str.replace(/[^\d.]/g, ) const dotIndex str.indexOf(.) if (dotIndex ! -1) { const intPart str.slice(0, dotIndex) const rest str.slice(dotIndex 1).replace(/\./g, ) str intPart . rest } if (dotIndex ! -1) { const intPart str.split(.)[0] const decPart str.split(.)[1].slice(0, 2) str intPart . decPart } return str } function formatBlur(value, maxIntLength 9) { if (value || value null) return value let str String(value) if (str.endsWith(.)) str str.slice(0, -1) if (str.startsWith(.)) str 0 str const parts str.split(.) parts[0] parts[0].replace(/^0(?\d)/, ) || 0 if (parts[0].length maxIntLength) { parts[0] parts[0].slice(0, maxIntLength) } if (parts.length 1) { parts.push(00) } else { parts[1] parts[1].padEnd(2, 0).slice(0, 2) } return parts.join(.) } const decimalDirective { inserted(el, binding, vnode) { const input getInput(el) if (!input) return const value binding.value || {} const maxIntLength value.maxIntLength || 9 input.addEventListener(keydown, (e) { const allowedKeys [ Backspace, Delete, Tab, Enter, Escape, ArrowLeft, ArrowRight, ArrowUp, ArrowDown, Home, End ] if (allowedKeys.includes(e.key)) return if (e.ctrlKey || e.metaKey) return if (/^\d$/.test(e.key)) return if (e.key .) { if (e.target.value.includes(.)) e.preventDefault() return } e.preventDefault() }) input.addEventListener(input, (e) { if (e.isComposing) return const cleaned sanitizeDecimal(e.target.value) if (cleaned ! e.target.value) { const pos e.target.selectionStart e.target.value cleaned vnode.componentInstance.$emit(input, cleaned) requestAnimationFrame(() { e.target.setSelectionRange(pos, pos) }) } }) input.addEventListener(blur, (e) { const formatted formatBlur(e.target.value, maxIntLength) if (formatted ! e.target.value) { e.target.value formatted vnode.componentInstance.$emit(input, formatted) } }) } } export default decimalDirective注册到全局// main.js import Vue from vue import decimalDirective from /directives/decimal Vue.directive(decimal, decimalDirective)页面里的用法el-input v-modelform.price v-decimal placeholder金额 /需要限制整数位长度时传参el-input v-modelform.price v-decimal{ maxIntLength: 6 } placeholder金额 /这里有一个 Vue 版本相关的注意点上面代码用的是 Vue 2 写法vnode.componentInstance.$emit是 el-input 组件实例的 emit。如果你用的是 Vue 3 Element Plus把vnode.componentInstance换成vnode.component即可其他逻辑基本不变。5.2 组件写法的取舍如果团队里不习惯用指令也可以抽成一个DecimalInput.vue组件把三层逻辑全部包进去业务方直接decimal-input v-modelform.price /。组件的写法对新手更友好也方便在展示层做定制比如加人民币前缀、金额大写展示、千分位展示等等。指令和组件二选一即可功能上限差不多。但从我实际维护的经验看指令在“对现有代码无侵入”这点上更胜一筹因为不同页面的存量 el-input 不需要改成自定义组件只要在标签上加一个v-decimal就完事改动面小很多评审的时候也好解释。还有一点要提醒指令绑定的是 el-input 内部的原生 input 节点它依赖“el-input 渲染出来的 input 节点是稳定的”这个前提。大部分情况下没问题但如果你的项目里 el-input 被 v-if 频繁切换或者套了多层封装组件建议用组件方案更稳。6. 别急着抄先和 el-input-number、formatter 做一轮对比我知道有人看到这里会想elementUI 不是自带 el-input-number 吗设置一下 precision 不就行了确实这是很多人第一个想到的方案但实际对比下来它的体验离“金额输入框”的需求还有不小距离。6.1 方案横向对比方案限制两位小数拦截非法字符输入过程体验适合场景el-input-number precision2只约束数值精度拦不住 e、、- 和输入法输错会闪、光标会跳、还会自动把1.变成1有步进调节需求的数字框el-input 加 formatter/parser只影响展示格式不拦截输入parser 结果回填 v-model容易产生奇奇怪怪的值千分位、货币符号展示纯 blur 正则校验失焦后才知道不合法不拦截用户填完才知道错只能作为表单校验兜底本文三层方案输入和失焦都管住能拦大部分非法来源顺滑无光标乱跳金额、价格、数量的严格输入场景6.2 el-input-number 为什么不适合金额框el-input-number 看起来最省事设:precision2、:controlsfalse之后似乎就是“金额输入框”了。但实际用起来有几个硬伤。第一原生 number 输入框在部分浏览器下允许输入e、、-这些符号是科学计数法的合法部分el-input-number 拦不住用户输完才发现值变成 NaN 或直接被清掉。第二输入过程里小数点后超过两位它确实会截断但截断的时机和行为在键盘输入、鼠标滚轮、方向键调节时表现都不一致体验很生硬。第三它失焦时会把1.自动变成1这个行为看起来没问题但业务上往往希望它变成1.00格式统一度反而不如自己写的 blur 格式化。当然如果你的场景确实需要步进按钮比如数量、倍数这种字段el-input-number 还是合适的。金额字段真的别用。6.3 提交前的 rules 校验正则不能省最后强调一件事即使输入框已经被三层控制得很严表单提交时的 rules 校验正则仍然要写。输入控制管的是“用户手动输入”但你拦不住接口回填、程序赋值、浏览器插件改 DOM 这些情况提交前用一条完整正则把关是最后一道防线。rules: { price: [ { required: true, message: 请输入价格, trigger: blur }, { validator: (rule, value, callback) { if (/^\d(\.\d{1,2})?$/.test(String(value ?? ))) { callback() } else { callback(new Error(价格最多保留两位小数)) } }, trigger: blur } ] }这条正则和前面控制阶段用的 replace、slice 不是一回事它是纯校验两者各司其职配合起来才算完整。最后分享一个我一直在用的经验每次做评审前先去跟产品确认一个问题——“小数部分要不要强制显示两位”。这个决定会直接影响 blur 格式化逻辑也会影响后端联调时的字段格式预期。别自己拍脑袋也别等后端上线了再返工。