资讯动态

Vue自定义指令实战:轻松实现数字输入框限制与小数精度控制

发布时间:2026/9/18 16:17:22 来源:尧图企业网站定制
做前端的兄弟应该都有过这种经历产品经理轻飘飘丢过来一句这个输入框只能输入数字小数点后保留两位然后你打开代码欸又要去处理input事件了。以前我也图省事直接在组件里写个handlers后来项目大了复用的地方多了就觉得不对劲——十来个页面都有这种输入框总不能每个页面都复制粘贴一遍吧。所以后来我花了点时间把input只能输入数字、可开关小数点、可控制小数位数这件事封装成了一个Vue自定义指令全局注册完任何页面只要在input标签上写一行v-input-number就搞定。这篇文章就把整个设计和踩坑过程完整记录一遍包含v-model的绑定关系、composition事件的处理、光标位置恢复以及几个你大概率会遇到的坑。1. 为什么不用组件库自带的InputNumber先说个很多人会问的问题Element UI、Ant Design Vue这些组件库不都有InputNumber吗干嘛还要自己造轮子我实际用下来的感受是组件库的InputNumber在能输入和好输入之间做的是平衡而不是极致的约束。它虽然也有精度控制、min/max限制但在一些业务场景下会有问题。组件库的InputNumber在失焦时会自动修正值比如你正在输入007它会变成7但某些场景下这个帮你修的动作反而会影响v-model的实时性。组件库对诸如中文输入法、复制粘贴带格式内容的处理在不同版本里表现不一致有的会把一堆非数字字符混进去。组件库InputNumber的样式一般比较固定想自定义边框、背景、宽度往往要写一堆override改完还影响全局。移动端H5、小程序WebView、PC端后台几套环境下组件库的输入体验差异很大而原生input加自定义指令是零依赖、稳定一致的方案。再说直白一点某些UI库在未加载完全或者按需引入配置不对时InputNumber组件直接在控制台给你报错。与其依赖组件库不如自己掌控一个针对纯数字输入这个极简场景的指令。2. 核心实现思路与指令设计自定义指令最核心的难点不在于事件绑定而在于要跟v-model和平共处。很多刚入门的同学写的自定义指令会发现输入框里面的值能被用户随便改v-model拿到的却是旧值或者是能限制住用户输入了但输入框里的显示值和数据模型不同步——这就是典型的只拦了键盘没管事件流。2.1 v-model的本质到底是什么要理解这个指令先得搞明白v-model在原生input上做了什么。它不是一个双向绑定魔法它只是Vue给我们提供的一个语法糖本质等价于input :valuemyValue inputmyValue $event.target.value /也就是说你在input上监听input事件手动去修改event.target.valuev-model的数据也会跟着更新因为v-model的赋值表达式本身就是在监听input事件。但这里有第一个坑如果你通过el.addEventListener(input, handler)去监听input事件并且在该事件触发后修改了event.target.value执行顺序会影响最终结果。Vue内部添加的input事件处理和你在bind钩子里通过addEventListener添加的监听器都会在该值变化时被触发。如果你在Vue的事件处理函数执行完之后再改value你能把框里的显示值改掉但v-model已经把它自己的数据更新完了两边就会不一致。所以思路必须反过来我们必须在v-model更新数据之前先把value净化一遍。2.2 先想清楚指令参数怎么设计指令总不能写死只能整输入数字也不能写死固定保留两位小数。设计参数的时候要满足几个场景纯整数不允许任何小数点允许小数点不限制小数位数允许小数点并且限制小数点后只能有2位部分业务甚至需要允许负号我最终把参数设计成一个对象默认值是{ // 是否允许小数默认允许 decimal: true, // 小数位保留几位默认不限制为0或false表示不限制 precision: 2, // 是否允许负数默认不允许 negative: false }调用的时候是这样!-- 只允许正整数 -- input v-input-number{ decimal: false } / !-- 允许两位小数 -- input v-input-number{ decimal: true, precision: 2 } / !-- 允许负数且最多1位小数 -- input v-input-number{ negative: true, decimal: true, precision: 1 } /有人可能会问为什么不直接传字符串比如v-input-number2代表两位小数实测下来对象参数更清晰而且扩展起来不用改接口。你以后想加最大值、最小值、仅整数这些限制直接在对象里加字段就可以了。2.3 监听哪些事件只监听input是远远不够的。实测下来至少需要监听这几个input用户输入、粘贴、拖拽文本、剪切、通过输入法上屏等操作的主入口。keydown拦截一些特殊按键比如按e科学计数法、、-在not allowed时、小数点在小数点已存在或不允许小数时。blur失焦时做一个兜底如果值是12.你总不能让它留在模型里吧。如果只在input里做正则替换会有几个问题用户按e时从input事件里看event.target.value可能会因为浏览器行为有延迟不一定能在事件回调里稳定拦住。所以keydown拦截是必要的第一道防线。2.4 composition事件的特殊处理这个坑是最多人忽略的。如果你不做composition处理你会发现中文输入法下用户输入数字时拼音字母会直接被混进输入框。举个例子用户想打数字1但用了中文输入法输入yi打算上屏一结果输入框里先出来了yi然后正则一处理把字母全删了输入框就空了。更麻烦的是input事件在composition拼音组合期间也会触发你这个指令每触发一次就把字母清掉一次用户根本没法把拼音打完。处理方案是用compositionstart和compositionend两个事件记录状态el._inputNumber { isComposing: false }; el.addEventListener(compositionstart, () { el._inputNumber.isComposing true; }); el.addEventListener(compositionend, (e) { el._inputNumber.isComposing false; // composition结束之后再处理一次把拼音上屏后的最终内容正则清洗一遍 cleanValue(e); });在input事件里先判断如果正在组合输入就直接return不做任何处理。等compositionend触发后再统一清洗。这个细节做完中文输入环境下输入框的体验才会正常。3. 完整实现代码与逐段解析说了这么多思路直接上完整代码。代码下面是逐段解析我建议你别直接复制跑完就关还是花两分钟看看每段逻辑为什么要这么写因为业务一变这些细节很容易出问题。3.1 完整指令代码// directives/number-input.js const isComposing () el._numberInput el._numberInput.isComposing; function cleanNumber(value, binding) { const opts Object.assign({ decimal: true, precision: 2, negative: false }, binding.value || {}); if (typeof value ! string) { value String(value); } // 去掉首尾空格 value value.trim(); // 是否允许负数 if (!opts.negative) { // 去掉所有负号 value value.replace(/-/g, ); } else { // 只保留开头的负号 let isNegative false; if (value.startsWith(-)) { isNegative true; value value.substring(1); } // 把其余负号全删了 value value.replace(/-/g, ); if (isNegative) { value - value; } } // 是否允许小数 if (!opts.decimal) { value value.replace(/\./g, ); // 去掉所有非数字字符 value value.replace(/[^\d-]/g, ); } else { // 去掉非数字、负号、小数点以外的字符 value value.replace(/[^\d.-]/g, ); // 处理多个小数点只保留第一个 const firstDotIndex value.indexOf(.); if (firstDotIndex ! -1) { const prefix value.substring(0, firstDotIndex); // 小数点之前 let suffix value.substring(firstDotIndex 1); // 小数点之后 suffix suffix.replace(/\./g, ); // 去掉后面所有小数点 // 限制小数位数 if (opts.precision opts.precision 0) { // 截断多余的小数位注意这里不是四舍五入是直接保留 suffix suffix.substring(0, opts.precision); } value prefix . suffix; } } // 去掉非数字/负号/小数点之外的残留兜底 if (opts.negative) { value value.replace(/[^\d.-]/g, ); } else { value value.replace(/[^\d.]/g, ); } return value; } function setValue(el, value, binding) { const cleaned cleanNumber(value, binding); if (el.value ! cleaned) { // 保存光标位置 const selectionStart el.selectionStart; const selectionEnd el.selectionEnd; el.value cleaned; // 手动触发input事件确保v-model能同步更新 el.dispatchEvent(new Event(input, { bubbles: true })); // 恢复光标位置在赋值之后重新设置 if (el.selectionStart ! undefined) { try { // 这里通过requestAnimationFrame延迟一下因为部分浏览器在dispatchEvent之后会重置selection requestAnimationFrame(() { const len cleaned.length; const pos Math.min(selectionStart, len); el.setSelectionRange(pos, pos); }); } catch (e) { // ignore } } } } function handleInput(e, el, binding) { const value e.target.value; if (isComposing(el)) { return; } setValue(el, value, binding); } function handleKeydown(e, el, binding) { const opts Object.assign({ decimal: true, precision: 2, negative: false }, binding.value || {}); // 允许 Ctrl/Meta 组合键 if (e.ctrlKey || e.metaKey) { return; } // 允许退格、删除、Tab、方向键、Home、End const allowedKeys [Backspace, Delete, Tab, ArrowLeft, ArrowRight, ArrowUp, ArrowDown, Home, End]; if (allowedKeys.includes(e.key)) { return; } // 不允许字母e、E、加号 if (e.key e || e.key E || e.key ) { e.preventDefault(); return; } // 不允许负号 if (e.key - !opts.negative) { e.preventDefault(); return; } // 小数点处理 if (e.key .) { if (!opts.decimal) { e.preventDefault(); return; } // 如果已经有小数点或者有精度要求且已有小数位达到上限 const currentValue e.target.value; if (currentValue.includes(.)) { e.preventDefault(); return; } // 如果用户已经选中了一段包含小数点的文本可以允许输入新的小数点 // 这个分支比较少见先不额外处理 } // 如果当前值已经有小数位并且已经达到精度上限 // 且用户没有选中文本则拦截所有数字输入 if (opts.precision opts.precision 0 e.key 0 e.key 9) { const currentValue e.target.value; const dotIndex currentValue.indexOf(.); if (dotIndex ! -1) { const decimalPart currentValue.substring(dotIndex 1); if (decimalPart.length opts.precision) { // 如果是替换选中文本可以允许 if (e.target.selectionStart ! e.target.selectionEnd) { return; } e.preventDefault(); return; } } } // 只允许数字0-9、负号开头、小数点 if (!/^[0-9.-]$/.test(e.key)) { e.preventDefault(); return; } } function handleBlur(e, el, binding) { // 去掉末尾的小数点 let value e.target.value; if (value || value - || value .) { value ; } else if (value.endsWith(.)) { value value.slice(0, -1); } setValue(el, value, binding); } export default { mounted(el, binding) { el._numberInput { isComposing: false }; el.addEventListener(compositionstart, (e) { el._numberInput.isComposing true; }); el.addEventListener(compositionend, (e) { el._numberInput.isComposing false; setValue(el, e.target.value, binding); }); el.addEventListener(keydown, (e) handleKeydown(e, el, binding)); el.addEventListener(input, (e) handleInput(e, el, binding)); el.addEventListener(blur, (e) handleBlur(e, el, binding)); // 初始化清理一次 if (el.value) { setValue(el, el.value, binding); } }, updated(el, binding) { // 当通过v-model外部修改时也走一次清理防止外部传入非法值 if (el.value) { setValue(el, el.value, binding); } }, unmounted(el) { el._numberInput null; } };3.2 核心逻辑逐段说明先说cleanNumber函数。它接收当前输入值和binding配置返回一个净化后的合规字符串。整个处理核心放在这个纯函数里是为了方便测试也方便以后扩展。第一个要注意的点是精度限制这里用的是截断不是四舍五入。比如用户输入1.256precision是2那结果就是1.25而不是1.26。为什么不四舍五入因为当一个数字正在被输入的过程中用户还没有输入完你不可能在他刚敲完第三位小数时就去四舍五入。四舍五入应该放在blur失焦或者表单提交时做。input过程中做四舍五入反而会造成值的跳跃和不连贯。如果你需要在失焦时四舍五入可以在blur里额外做一轮parseFloat处理。第二个要注意的是空字符串、单独一个负号、单独一个小数点这些边缘情况都必须合法保留下来否则用户在输入过程中受阻会非常难受。比如用户想输入-0.5他得先输入负号再输入0如果你把单独的负号当非法值清理掉他就永远输入不了负数。setValue函数里我调用了el.dispatchEvent(new Event(input, { bubbles: true }))。这里必须用bubbles: true否则Vue的input监听器可能捕获不到。dispatchEvent是手动触发input事件让Vue的v-model能同步到这个新值。有些人可能会想直接通过el.__vue__去修改组件数据但那个方法太hack而且拿不到实例时就是null所以dispatchEvent是最稳妥、最标准的做法。3.3 为什么要在mounted里初始化清理一次这一步很多人会忽略。业务里经常出现这种场景后端接口返回了个12.345直接赋值给form.value.price而我们的input上挂了v-input-number{ precision: 2 }。如果不做初始化清理输入框上会直接显示12.345用户一编辑才会触发拦截体验不完整。在mounted和updated里都执行一次setValue能把显示值和数据模型在第一时间对齐避免出现框里显示的是非法值这种情况。需要注意这里setValue里又会dispatchEvent触发input事件然后v-model又把新值写回数据模型——所以整个流程是自洽的你外部赋了个非法值指令立刻帮你修正并同步回数据模型。这其实就是一个轻量的数据过滤器。4. 几个你必须落的实操细节光有核心代码还不够要把指令用到生产环境不出幺蛾子还差几个细节。4.1 全局注册指令在Vue 3项目里你可以在main.js或main.ts里全局注册import { createApp } from vue; import App from ./App.vue; import numberInput from /directives/number-input; const app createApp(App); app.directive(input-number, numberInput); app.mount(#app);这样一个项目里所有input都能用v-input-number。为什么选择全局注册而不是局部注册因为这种输入限制属于通用逻辑99%的页面都会用到全局注册省掉每个组件import、regist的重复操作。如果你只在某个特定模块里用也可以局部注册。4.2 与Element UI等组件库搭配使用我们很多项目里不是原生input而是Element UI的el-input。这种组件上挂自定义指令有个差异点el-input组件本身不是原生input你在组件上写v-input-number它会自动fallthrough到组件根元素——但el-input的根元素是一个div不是input你监听keydown、input事件时监听的是div上的事件而不是里面原生input的这就完全失效了。解决办法有两个第一个给el-input指定v-input-number时不通过组件透传而是放进原生事件监听里比如el-input v-modelvalue v-input-number{ decimal: true, precision: 2 } oninputthis.value this.value.replace(/[^0-9.]/g, ) /不推荐这样太hack。第二个改造指令让它支持传入一个选择器或者ref在mounted里去找到内部的原生input再绑定事件mounted(el, binding) { const nativeInput el.tagName INPUT ? el : el.querySelector(input); if (!nativeInput) return; // 后续所有监听都绑定在nativeInput上 }但这样setValue里对el.value的操作都要改成nativeInput.value逻辑会更复杂。我这里只讲原生input的版本因为组件库场景下更推荐的做法是——在el-input所在的业务组件里写一个统一的format函数或者给el-input包一层我们自己写的封装组件。组件库有它自己的value传递链强行去改它的DOM结构容易触发一些未知的render问题。4.3 光标跳动问题这个坑是写这类指令最容易翻车的如果你在input事件里直接el.value cleaned用户输入的数字一旦不合法光标就会跳到末尾。比如用户在中间插入一个非法字符你清洗掉之后他光标跑到最后去了前面输入的又得重新去点体验非常差。处理方式在赋值之前保存selectionStart和selectionEnd赋值之后在requestAnimationFrame里恢复光标位置。为什么用requestAnimationFrame而不是直接在当前线程设置因为el.dispatchEvent(new Event(input))可能会在Vue的响应式更新链路里改变DOM浏览器还没真正重绘的时候你去设置selection有可能会被后续的DOM操作给覆盖掉。放到下一帧执行DOM状态已经稳定这个时候恢复位置才可靠。4.4 输入法composition问题前面讲原理时已经提过这里再说一个实际案例你用macOS自带的中文输入法想输入十八你敲了s h i b a如果input事件没有被composition状态拦截每敲一个字母都会触发一次拦截和清洗拼音串就被打散了。但有了isComposing状态组合输入期间所有input事件都会被忽略直到compositionend触发后才做一次统一清洗用户至少能完整打出拼音上屏后的十八虽然也会被正则洗掉但用户体验是他打完拼音发现上屏了中文然后中文被清空输入框还是空的——而不是在拼音阶段就一直被打断。实际产品中如果这个输入框本来就是纯数字的我还会建议加一个inputmodedecimal移动端会调起数字键盘电脑端的用户体验则由指令的正则来兜底。5. 常见问题速查与排查思路这节记录我实际使用过程中遇到的几个高频问题每个都是症状—原因—解法的结构。5.1 数据没更新或更新延迟症状输入框显示值被清洗了但页面上绑定展示的数据还是旧值或者迟一拍才变。原因setValue里虽然手动触发了input事件但如果你在自定义指令里用的是el.dispatchEvent(new Event(input, { bubbles: true }))某些浏览器版本对非用户操作的事件信任度低Vue的v-model可能不会立即响应或者你在Vue内部input事件监听器之前就做了赋值和dispatch顺序错乱。排查方法先去掉指令里的dispatchEvent看看数据是否恢复同步。如果去掉之后同步那就是双写冲突如果去掉之后不同步那就是事件监听没绑上。最终方案dispatchEvent这行保留但把bubbles: true加上并且在mounted里确认监听绑定的是同一个DOM节点。如果是组件库的el-input事件是绑在内部原生input上的你dispatchEvent要dispatch在原生input上而不是组件根元素上。5.2 粘贴带格式内容症状从Excel或Word里复制了一个表格里的数字比如1,234.56粘贴到输入框里发现逗号、空格、换行符都混进去了正则虽然清掉了一部分但最终结果可能是1234.56——这还算好的有时候结果是1234.56后面还带着一串空格。原因粘贴的内容走了input事件但浏览器有些时候插入的内容会触发多次input事件你清洗一次之后后续的片段又插上来了。解法在paste事件里手动拦一道el.addEventListener(paste, (e) { e.preventDefault(); const pastedText (e.clipboardData || window.clipboardData).getData(text); // 提取数字部分 const cleaned cleanNumber(pastedText, binding); // 插入到当前光标位置简单处理直接替换选区 const start el.selectionStart; const end el.selectionEnd; const currentValue el.value; const newValue currentValue.substring(0, start) cleaned currentValue.substring(end); setValue(el, newValue, binding); });这样粘贴的内容在进入输入框之前就被规范过不会出现多重清洗导致光标乱跳的问题。5.3 输入框里出现e症状原生input在typenumber时浏览器允许输入e、E、、-这些字符因为科学计数法所以即使你写typenumber也能输入1e3。原因浏览器把input的type设为number时内部会用一套特殊的状态机普通正则清洗在它那里会失效。解法这个指令不依赖原生typenumber我直接建议统一使用typetext加inputmodedecimal。既绕开了e的问题又保留了移动端数字键盘。如果你项目中已有typenumber那就在指令里把el.type强制设为text。5.4 失焦后末尾小数点和负号问题症状用户输入12.之后他以为没问题直接点了别处v-model里存的是12.后端一校验就报格式错误。解法在blur事件里做几件事如果值为空字符串、-、.直接清空。如果以.结尾去掉末尾的小数点。如果precision限制小数位可以在这里做四舍五入或统一补零。比如precision2用户输入12失焦时你可以选择改成12.00这完全取决于业务需求。5.5 组件卸载后事件重复触发症状在v-if切换时指令绑定的DOM被移除但事件监听器没被回收。虽然Vue会自动解绑el相关的监听但如果你的回调函数是在外部定义的比如el.addEventListener(input, this.handleInput)匿名函数不会残留而如果用了el._numberInput保存状态组件卸载后这个对象还在如果后续有别的代码误操作el._numberInput才可能出现问题。解法在unmounted钩子里清理el._numberInput并且把事件监听器都改成具名函数方便移除。虽然Vue自身会管理DOM引用但养成手动cleanup的习惯总没错。6. 这个指令后续能扩展成什么样我现在这个指令能满足80%的场景但如果你愿意它还能再做一层抽象变成一个更完善的输入体系增加[min, max]范围校验超限自动钳制支持千分位分割显示v-model存原始数字显示层格式化成1,234.56支持只读、禁用状态下的值格式化配合表单校验库失焦时做统一校验和提示支持当输入值为空时自动回填默认值或0比如加min/max逻辑不复杂if (opts.min ! undefined numValue opts.min) { value String(opts.min); } if (opts.max ! undefined numValue opts.max) { value String(opts.max); }但要注意min/max不能放input处理里否则用户从0开始输入一个8如果min是20你会强制把它改成20用户就输不了9了。所以min/max只能在blur时处理或者提供一个压制模式开关给用户选择。千分位分割就复杂得多它不光是输入清洗还牵扯光标位置、选区长度变化、撤销重做等一连串问题。我目前只在展示层用过滤器输入层不做格式化因为收益有限风险不小。另外提一嘴Vue 3的指令钩子从bind/inserted换成了mounted/updated/unmounted如果你还在用Vue 2需要把代码里的钩子名替换回去。Vue 2对应关系是// Vue 2 bind(el, binding) {}, inserted(el, binding) {}, update(el, binding) {}, unbind(el) {}指令内部除了钩子名不同业务逻辑可以原样复用。7. 写在最后的一点实际体会这个指令我用了大概有半年了前后迭代过三版。第一版只做了keydown拦截结果忽略了粘贴和输入法第二版加了input清洗但光标乱跳被同事吐槽第三版才算真正能稳在业务线上跑。做这类小工具最难的不是把代码写出来而是把用户输入的各种诡异姿势都考虑进去——中文输入法、快捷键、浏览器自带补全、拖拽文本、bypass的浏览器扩展每个都可能让你的正则失效。如果你在实际使用中碰到了我这里没写到的问题比如某些浏览器版本对setSelectionRange不生效或者微信内置浏览器里composition事件出发顺序不一样那大概率你得在这些边界上再做一层兼容处理。前端这块东西永远没有一劳永逸但把通用逻辑沉淀成指令至少能帮你在下一个项目里省掉半天调bug的时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价