Vue自定义指令实战:轻松实现数字输入框限制与小数精度控制
2026/9/18 16:17:14 网站建设 项目流程

做前端的兄弟应该都有过这种经历:产品经理轻飘飘丢过来一句"这个输入框只能输入数字,小数点后保留两位",然后你打开代码,欸,又要去处理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 :value="myValue" @input="myValue = $event.target.value" />

也就是说,你在input上监听input事件,手动去修改event.target.value,v-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-number="2"代表两位小数?实测下来,对象参数更清晰,而且扩展起来不用改接口。你以后想加"最大值、最小值、仅整数"这些限制,直接在对象里加字段就可以了。

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(拼音组合)期间也会触发,你这个指令每触发一次就把字母清掉一次,用户根本没法把拼音打完。

处理方案是用compositionstartcompositionend两个事件记录状态:

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.256,precision是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.jsmain.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!你监听keydowninput事件时,监听的是div上的事件,而不是里面原生input的,这就完全失效了。

解决办法有两个:

第一个,给el-input指定v-input-number时,不通过组件透传,而是放进原生事件监听里,比如:

<el-input v-model="value" v-input-number="{ decimal: true, precision: 2 }" oninput="this.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,用户输入的数字一旦不合法,光标就会跳到末尾。比如用户在中间插入一个非法字符,你清洗掉之后,他光标跑到最后去了,前面输入的又得重新去点,体验非常差。

处理方式:在赋值之前保存selectionStartselectionEnd,赋值之后在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触发后才做一次统一清洗,用户至少能完整打出拼音,上屏后的"十八"虽然也会被正则洗掉,但用户体验是:他打完拼音发现上屏了中文,然后中文被清空,输入框还是空的——而不是在拼音阶段就一直被打断。

实际产品中,如果这个输入框本来就是纯数字的,我还会建议加一个inputmode="decimal",移动端会调起数字键盘;电脑端的用户体验则由指令的正则来兜底。

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在type="number"时,浏览器允许输入eE+-这些字符(因为科学计数法),所以即使你写type="number",也能输入1e3原因:浏览器把input的type设为number时,内部会用一套特殊的状态机,普通正则清洗在它那里会失效。解法:这个指令不依赖原生type="number",我直接建议统一使用type="text"inputmode="decimal"。既绕开了e的问题,又保留了移动端数字键盘。如果你项目中已有type="number",那就在指令里把el.type强制设为text

5.4 失焦后末尾小数点和负号问题

症状:用户输入12.之后,他以为没问题直接点了别处,v-model里存的是12.,后端一校验就报格式错误。解法:在blur事件里做几件事:

  • 如果值为空字符串、-.,直接清空。
  • 如果以.结尾,去掉末尾的小数点。
  • 如果precision限制小数位,可以在这里做四舍五入或统一补零。比如precision=2,用户输入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的时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询