做前端这几年,业务表单里凡是涉及金额、数量、库存、手机号这类输入框,几乎都会碰到一个很缠人的需求:input 只能填数字,不能有乱七八糟的符号,最好还能控制小数点后保留几位。标题里的“Vue 自定义指令 - input 只能输入数字类型可设置小数点及小数点后几位”就是这类需求的经典代表。我第一次接到这个需求时,第一反应是给 input 加一个正则校验事件,结果被产品经理在验收时挑出一堆毛病:可以粘贴字母、可以输入多个小数点、中文输入法下还能混进全角数字……后来我把这套逻辑收拢成一条可复用的自定义指令,才算是真正解决了问题。这篇就完整记录我的设计思路、完整代码、踩坑过程和扩展玩法,适合正在写 Vue2/Vue3 项目、又不想在每个表单里复制粘贴正则的同学参考。
1. 需求拆解:数字输入限制到底在解决什么问题
1.1 表面需求与实际痛点
从标题看,需求可以拆成三层:第一,禁止 input 输入非数字字符;第二,允许配置小数点是否出现;第三,能设置小数点后最多保留几位。这三句话看起来很简单,但真正落到代码里时,你会发现它牵涉到“什么时候拦截”“怎么拦截”“拦截后光标往哪跑”“中文输入法怎么防穿透”“v-model 的值会不会跟界面不一致”等一系列问题。
我在实际项目里见到过最原始的写法,是在change或blur事件里做校验,发现非法值后弹个提示,再把输入框清空。这种方式用户体感非常差,输入半天保存时才被告知格式不对,而且清空所有内容比提示更让人崩溃。另一种常见写法是在input事件里写一大堆.replace(),每个页面复制一份,换一个表单要改三处位置,代码重复度极高,后面维护的人看了只想骂人。
1.2 为什么不直接用 type="number"
很多人会问,HTML 不是自带type="number"吗,为什么还要自己写指令?我实战下来发现这个原生方案有四个硬伤。
第一,type="number"在部分浏览器下允许输入字母e、正负号,甚至允许1.2.3这种半非法状态,因为它内部有一套自己的 parser,不同浏览器表现不一致。第二,滚动行为很诡异,在桌面端输入框上滚轮会改变数值,用户翻着页面数值就变了,很容易误操作。第三,移动端弹出的键盘虽然是数字键盘,但深层次的浮点输入、科学计数法等行为依然不可控。第四,它的样式在各浏览器下不统一,比如部分浏览器会在输入框右侧出现上下箭头,破坏设计稿。
相比之下,用type="text"配合inputmode属性,再自己控制输入内容,体验会稳定得多。
1.3 为什么选择自定义指令
页面里有多个表单,每个表单都需要同一套数字限制,最合理的做法就是把限制逻辑抽成公共能力。Vue 的自定义指令天生适合这类“操作 DOM”的封装,它可以直接绑定在 input 元素上,拦截原生事件,处理完毕后同步给 v-model,整个过程对业务组件透明。
自定义指令比封装组件更轻量。它不需要接收 value、emit 更新事件,也不需要关心组件层级,直接<input v-input-number.decimal="2" />就能生效。业务代码几乎零侵入,也不影响原有 v-model 的读写逻辑。这就是我最终选择自定义指令方案的原因。
2. 指令 API 设计:参数怎么定才顺手
2.1 把需求翻译成配置项
标题明确提到“可设置小数点及小数点后几位”,这句话翻译成参数就是两个核心开关:是否允许小数点、小数点后保留多少位。考虑到实际业务里金额为负数也很常见,比如“退款金额”“温度差”,我还会加入一个允许负数的配置项。
整理后的配置项如下:
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| decimal | boolean | false | 是否允许输入小数点 |
| decimalPlaces | number | 2 | 小数点后最多保留几位 |
| allowNegative | boolean | false | 是否允许负号,且负号只能出现在首位 |
默认值不设太多花活:不传任何配置时,指令只允许正整数。用户看到v-input-number就知道这是一个整数输入框,符合直觉。
2.2 用修饰符和参数组合出简洁写法
设计指令 API 时,我参考了很多开源组件库的做法,最后结合 Vue 指令语法,采用了“修饰符 + 参数 + 对象配置”三合一的方式:
<!-- 只允许非负整数,比如库存数量 --> <input v-input-number /> <!-- 允许小数,默认最多两位,比如金额 --> <input v-input-number.decimal /> <!-- 允许小数,且最多三位 --> <input v-input-number.decimal="3" /> <!-- 允许负数,比如温度、收支差额 --> <input v-input-number="{ allowNegative: true }" /> <!-- 更完整的组合 --> <input v-input-number="{ decimal: true, decimalPlaces: 4, allowNegative: false }" />其中decimal修饰符负责打开小数开关,v-input-number.decimal="3"里的3负责指定小数位数,对象写法则提供最完整的自由度。这种设计对使用者很友好:简单场景一行搞定,复杂场景也有兜底能力。
2.3 Vue2 和 Vue3 的钩子函数差异
标题没有指定 Vue 版本,所以我同时考虑了 Vue2 和 Vue3。Vue2 里自定义指令使用bind、inserted、update等钩子,Vue3 则统一为mounted、updated、unmounted。设计指令代码时,我会以 Vue3 为主,后面单独给 Vue2 版本,方便两个技术栈的同事直接抄作业。
需要特别注意的是,Vue3 中指令的mounted是在元素挂载后执行的,此时元素上的 v-model 事件监听器可能已经绑定,所以在指令内部手动修改el.value后,如果依赖原生 input 事件同步 v-model,可能会出现数据不同步。这个问题我在下一节会重点展开。
3. 核心实现:完整代码逐段拆解
3.1 先看完整指令代码(Vue3 版)
下面这个文件是我目前在项目中使用的基础版本,去掉了一些业务特化的逻辑,保留了最核心的数字过滤能力。
// directives/number-input.js export default { mounted(el, binding) { if (el.tagName !== 'INPUT') { console.warn('v-input-number 只能绑定在 input 元素上') return } // 解析配置 const config = resolveConfig(binding) // 记录输入法组合态 let isComposing = false // 从 input 事件里过滤非法字符 const filterInput = () => { if (isComposing) return const original = el.value const filtered = filterNumber(original, config) if (filtered !== original) { const cursor = el.selectionStart const diff = original.length - filtered.length el.value = filtered // 必须触发一次 input,才能让 v-model 同步到过滤后的值 el.dispatchEvent(new Event('input')) // 恢复光标位置 requestAnimationFrame(() => { const newPos = Math.max(cursor - diff, 0) el.setSelectionRange(newPos, newPos) }) } } const onCompositionStart = () => { isComposing = true } const onCompositionEnd = () => { isComposing = false // 拼音输入完成后,立即把全角数字、中文句号等清理掉 filterInput() } el.addEventListener('input', filterInput) el.addEventListener('compositionstart', onCompositionStart) el.addEventListener('compositionend', onCompositionEnd) // 设置移动端键盘类型 el.setAttribute('inputmode', config.decimal ? 'decimal' : 'numeric') // 存储清理函数,便于卸载时移除监听器 el.__numberInputCleanup = () => { el.removeEventListener('input', filterInput) el.removeEventListener('compositionstart', onCompositionStart) el.removeEventListener('compositionend', onCompositionEnd) } }, unmounted(el) { if (typeof el.__numberInputCleanup === 'function') { el.__numberInputCleanup() delete el.__numberInputCleanup } }, }这段代码里,resolveConfig和filterNumber是核心辅助函数,单独拆出来写得更清楚。
3.2 配置解析与过滤算法
配置解析部分要兼容上一节提到的几种用法。我的实现逻辑是:先用默认配置打底,如果存在decimal修饰符,就把decimal置为 true,同时把binding.value当作小数位数来解析;如果binding.value是对象,则直接合并覆盖。
function resolveConfig(binding) { const config = { decimal: false, decimalPlaces: 2, allowNegative: false, } const { value, modifiers } = binding if (modifiers && modifiers.decimal) { config.decimal = true if (typeof value === 'number' && value > 0) { config.decimalPlaces = Math.floor(value) } } if (value && typeof value === 'object') { Object.assign(config, { decimal: !!value.decimal, decimalPlaces: value.decimalPlaces ?? config.decimalPlaces, allowNegative: !!value.allowNegative, }) } // 兜底:不允许负数时,整数位数也不会用到负号 config.decimalPlaces = Math.max(0, config.decimalPlaces) return config }过滤算法是整篇的核心,我把拆成几步写。先处理全角转半角,把中文输入法常见的全角数字、中文句号统一转成英文半角;然后根据是否允许负数处理负号;最后按小数模式或整数模式处理剩余字符。
function filterNumber(original, config) { let value = original // 第 1 步:全角转半角,兼容中文输入法 value = value.replace(/[0-9]/g, (char) => { return String.fromCharCode(char.charCodeAt(0) - 0xfee0) }) value = value.replace(/。/g, '.') // 第 2 步:打散成字符数组,方便逐个判断 const chars = value.split('') // 第 3 步:临时用来标记是否已经出现过负号、小数点、数字 let hasMinus = false let hasDot = false let digitCountAfterDot = 0 let hasDigit = false const result = [] for (let i = 0; i < chars.length; i++) { const char = chars[i] if (char >= '0' && char <= '9') { // 纯数字,直接保留 if (hasDot) { digitCountAfterDot++ if (!config.decimal || digitCountAfterDot > config.decimalPlaces) { // 超过小数位限制,丢弃 continue } } hasDigit = true result.push(char) continue } if (char === '.' || char === '。') { if (!config.decimal) { // 不允许小数,直接丢弃小数点 continue } if (hasDot) { // 已经出现过小数点,丢弃第二个 continue } // 小数点前必须有数字,否则丢弃 if (!hasDigit) { continue } hasDot = true result.push('.') continue } if (char === '-') { // 只有允许负数,并且负号还没出现,并且负号必须在开头且后面还有内容时才能保留 if (!config.allowNegative) { continue } if (hasMinus || result.length !== 0) { continue } hasMinus = true result.push('-') continue } // 其余字符都丢弃 } return result.join('') }这套算法的优点在于,它不是一个简单的“替换非法字符”正则,而是逐个字符扫描,能精确控制负号位置、小数重复、小数位数限制。遇到-1-2-3,只保留开头的负号,最终输出-123;遇到12.34.56,后置小数点被丢弃,输出12.3456;如果配置只保留两位小数,12.3456会被裁剪成12.34。
3.3 手动触发 input 事件的必要性与光标恢复
在 3.1 的代码里,我修改了el.value之后调用了一次el.dispatchEvent(new Event('input'))。很多初写指令的同学不理解为什么需要这一步,甚至担心会造成死循环。这里解释一下事件顺序问题。
Vue 的v-model本质上是在元素上绑定了一个input事件监听器,通过它把值写回响应式数据。我在自定义指令的mounted里新增的过滤监听器,通常是在 v-model 的事件监听器之后才注册的。如果用户输入非法字符,v-model 的监听器会先执行,把原始非法值写入响应式数据;随后我的监听器把el.value改成过滤后的值。这时候如果不手动触发一次input,v-model 就永远不会知道 DOM 的值变了,最终界面显示的是“123”,但form.total里存的可能是“123abc”,保存时铁定出问题。
手动触发的input事件会再次进入过滤监听器,但因为此时值已经被过滤干净,filtered !== original的条件不成立,所以不会无限循环。实测多次,这个方案在 Vue2 和 Vue3 下都稳定。
光标恢复是另一个隐蔽问题。用户把光标移动到字符串中间输入一个非法字符,比如12a45,过滤后变成1245,浏览器默认会把光标甩到末尾。如果不恢复,用户想继续在中间输入,会发现光标位置完全错乱,体验非常差。我的做法是记录过滤前的selectionStart,计算过滤前后的长度差,然后把光标位置减掉长度差。因为字符删除都发生在光标之前,这个简单公式在大多数场景下够用。
3.4 注册方式与使用示例
注册指令有两种常见方式。局部注册适合只在某个组件里使用,全局注册适合系统里到处都要用。我的习惯是直接全局注册,因为数字输入实在太常用了。
// main.js 全局注册 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')局部注册则是在组件的directives配置里引入:
import numberInput from '@/directives/number-input' export default { directives: { inputNumber: numberInput, }, }模板里的实际用法:
<template> <div> <label>商品库存(整数)</label> <input v-model.trim="form.stock" v-input-number placeholder="请输入整数" /> <label>商品单价(最多两位小数)</label> <input v-model.trim="form.price" v-input-number.decimal="2" placeholder="0.00" /> <label>温度(支持负数和小数)</label> <input v-model.trim="form.temperature" v-input-number="{ decimal: true, decimalPlaces: 1, allowNegative: true }" /> </div> </template>需要注意,指令只负责过滤,不负责把字符串转成数字。提交表单前,我仍然建议对form.stock、form.price做一次Number()转换,或者直接让后端接收字符串再转,避免出现"0123"这种前导零字符串。
4. 实操中的常见坑与排查心得
4.1 中文输入法组合态导致的全角数字和中文句号
使用中文输入法时,用户输入拼音再选字的过程中,会触发compositionstart和compositionend事件。如果我在compositionstart到compositionend之间也拦截处理,会把正在组合的拼音字母一并去掉,导致用户打不出字。
比如用户想输入五笔编码,或是在输入框里临时打拼音,组合态下的英文字母会被我的过滤逻辑误删。解决方式是维护一个isComposing标志,组合态期间不执行过滤逻辑,组合结束后再统一过滤一次。
全角数字和中文字符是另一个坑。中文输入法下直接按数字键,可能出现123这种全角数字,中文句号“。”也经常被人当成小数点输入。按 3.2 的处理,全角数字统一转半角,中文句号转英文小数点,问题就解决了。这个细节如果不处理,用户在中英文混合输入时很容易觉得自己被“卡键盘”了。
4.2 粘贴内容时的兜底处理
input事件不仅能捕获键盘输入,也能捕获右键粘贴、拖拽放入等操作。正则过滤在处理粘贴场景时优势明显:用户从 Excel 或者网页里复制过来一段"123.45元",我的过滤逻辑会直接扔掉所有非数字字符,保留123.45。
但要注意一个特殊情况,用户粘贴的内容可能本身带有多个小数点,比如"1.2.3",此时算法只保留第一个小数点,输出1.23。这个行为在大多数业务里可以接受,但如果产品要求粘贴时遇到非法内容就直接清空而不是智能修正,那就需要额外做拦截,这里不展开。
4.3 移动端 type="number" 的体验问题
我最初做这个指令时,只是把元素类型改成了type="text",移动端弹出来的是全键盘,用户输入数字要切换符号,体验很差。后来才引入inputmode属性。
inputmode="decimal"在大部分移动端浏览器上会直接弹出带小数点的数字键盘,inputmode="numeric"弹出纯数字键盘。建议在使用指令时统一设置。如果项目中已经写了type="number",也建议改为type="text"加inputmode,既保留原生键盘优势,又避开type="number"的滚动改值等乱象。
我也遇到过一个 iOS 旧版本兼容问题:部分系统对inputmode支持不完整,会退回全键盘。这种情况下只能接受,至少功能上依然能过滤非法字符。
4.4 Vue2 版本怎么写
之前一直说的都是 Vue3 版本,如果你的项目还在 Vue2,指令钩子名需要换成bind和unbind。核心过滤逻辑可以完全复用,唯一需要注意的是事件监听器的绑定和销毁。
// directives/number-input.js (Vue2 版) export default { bind(el, binding) { const config = resolveConfig(binding) let isComposing = false const filterInput = () => { if (isComposing) return const original = el.value const filtered = filterNumber(original, config) if (filtered !== original) { const cursor = el.selectionStart const diff = original.length - filtered.length el.value = filtered el.dispatchEvent(new Event('input')) requestAnimationFrame(() => { const newPos = Math.max(cursor - diff, 0) el.setSelectionRange(newPos, newPos) }) } } const onCompositionStart = () => { isComposing = true } const onCompositionEnd = () => { isComposing = false filterInput() } el.addEventListener('input', filterInput) el.addEventListener('compositionstart', onCompositionStart) el.addEventListener('compositionend', onCompositionEnd) el.__numberInputCleanup = () => { el.removeEventListener('input', filterInput) el.removeEventListener('compositionstart', onCompositionStart) el.removeEventListener('compositionend', onCompositionEnd) } }, unbind(el) { if (typeof el.__numberInputCleanup === 'function') { el.__numberInputCleanup() delete el.__numberInputCleanup } }, }Vue2 里bind的执行时机比 Vue3 的mounted更早,但 v-model 的事件监听器在bind阶段是否已挂载也是不确定的。我之所以保留手动dispatchEvent(new Event('input')),就是为了兼容这种顺序不确定性,保证 v-model 最终拿到的是过滤后的值。
4.5 不要在指令里直接修改 modelValue
我在第一次写这个指令时,想当然地在过滤后执行了类似binding.instance[el.__model] = filtered的操作,结果导致数据更新和 DOM 更新相互触发,出现闪烁和重复过滤的问题。这个思路最终被我否掉。
正确做法是只操作原生 DOM,再使用原生input事件触发 v-model 自己的更新链路。自定义指令的定位是“增强原生输入行为”,而不是取代 Vue 的响应式系统。你想在指令里直接改数据,就得知道 v-model 具体绑定的是哪个字段,这在复杂组件、动态表单里根本维持不住。保持简单,别碰数据层,才是这个指令能稳定复用的关键。
5. 扩展玩法:负数开关、自动补齐与更多配置
5.1 自动修正前导零和多余的负号
前面的过滤算法只保证字符合法,但不保证数字格式完美。比如用户输入000123,界面会保留前导零;输入-0,也会原样保留。如果想更省心,可以在 3.2 的过滤循环末尾再补一次格式化逻辑。
我是这样扩展的:当结果以0开头且后面还有数字且没有小数点时,去掉所有前导零,只保留一位;如果结果是-0,且后面没有数字,就把负号也去掉。这段逻辑不复杂,但确实能提升表单数据的规范性,尤其适合商品数量、年龄这类整数输入。
5.2 blur 时自动补齐小数位
有很多业务场景要求金额必须显示成0.00而不是0或者0.0。输入过程中如果实时补零,用户会很烦躁,因为删东西都变得困难。我一般选择在blur时做格式化:如果配置允许小数,并且用户没有输入小数点,就自动补上.00;如果只输入了小数点而没有小数位,也补零到指定位数。
const formatOnBlur = () => { if (isComposing) return let value = el.value if (!value) return if (config.decimal && config.decimalPlaces > 0) { if (!value.includes('.')) { value += '.' + '0'.repeat(config.decimalPlaces) } else { const parts = value.split('.') const decimalPart = parts[1] || '' if (decimalPart.length < config.decimalPlaces) { value += '0'.repeat(config.decimalPlaces - decimalPart.length) } } el.value = value el.dispatchEvent(new Event('input')) } } el.addEventListener('blur', formatOnBlur)注意,这种格式化只适用于“展示给用户看”的场景。如果后端接口对金额有严格的数字格式要求,前端提交前还是要转一次数字类型。
5.3 无依赖、可扩展的完整版本
把完整代码整理到一起,方便直接复制使用。这个版本已经包含:小数开关、小数位数、负数开关、全角转半角、粘贴兜底、光标恢复、输入法组合态处理、失焦补零。对我来说,日常项目里的 80% 数字输入框问题,一套指令基本都覆盖了。
function resolveConfig(binding) { const config = { decimal: false, decimalPlaces: 2, allowNegative: false, autoZeroOnBlur: false, } const { value, modifiers } = binding if (modifiers && modifiers.decimal) { config.decimal = true if (typeof value === 'number' && value > 0) { config.decimalPlaces = Math.floor(value) } } if (value && typeof value === 'object') { Object.assign(config, { decimal: !!value.decimal, decimalPlaces: value.decimalPlaces ?? config.decimalPlaces, allowNegative: !!value.allowNegative, autoZeroOnBlur: !!value.autoZeroOnBlur, }) } config.decimalPlaces = Math.max(0, config.decimalPlaces) return config } function filterNumber(original, config) { let value = original value = value.replace(/[0-9]/g, (char) => { return String.fromCharCode(char.charCodeAt(0) - 0xfee0) }) value = value.replace(/。/g, '.') const chars = value.split('') let hasMinus = false let hasDot = false let digitCountAfterDot = 0 let hasDigit = false const result = [] for (let i = 0; i < chars.length; i++) { const char = chars[i] if (char >= '0' && char <= '9') { if (hasDot) { digitCountAfterDot++ if (!config.decimal || digitCountAfterDot > config.decimalPlaces) { continue } } hasDigit = true result.push(char) continue } if (char === '.') { if (!config.decimal || hasDot || !hasDigit) continue hasDot = true result.push('.') continue } if (char === '-') { if (!config.allowNegative || hasMinus || result.length !== 0) continue hasMinus = true result.push('-') continue } } return result.join('') }5.4 常见业务场景速查表
写文档时很重要的一件事,是让同事一眼看懂用什么配置。我在项目 README 里维护了一张速查表,这里也分享出来:
| 场景 | 推荐写法 |
|---|---|
| 非负整数(库存、人数、年龄) | v-input-number |
| 非负金额,最多两位小数 | v-input-number.decimal |
| 最多四位小数(汇率、单价精细计算) | v-input-number.decimal="4" |
| 正负数,允许小数(温度、账单差额) | v-input-number="{ decimal: true, allowNegative: true }" |
| 金额自动补齐两位(0.00) | v-input-number.decimal="2" |
6. 实测过程中的心得体会
这个指令从第一次写完到现在,已经在好几个项目里跑过,我踩过的坑和总结出的心得都写在上面了。想再强调一遍:指令内部只操作 DOM,不要碰数据层;过滤逻辑用逐字符扫描,不要迷信一个大正则;中文输入法组合态和光标恢复是隐藏的杀手,不处理这两个问题,用不了多久用户就会提工单。
如果业务里需要更复杂的金额输入体验,比如千分位分隔、最大值限制、精度四舍五入,建议在这些基础逻辑上再包一层组件处理,而不是把所有逻辑都塞进指令里。指令保持单一职责,后续维护才会轻松。
最后再补一个小建议:最好在项目里为这个指令写一份简单的单元测试,至少覆盖全角数字、多个小数点、负号位置、小数位裁剪这几类边界输入。我后来就是因为补了测试,才在一次重构中及时发现“中文句号被误删”的回归 bug。没用自动化测试之前,这种问题通常要等 QA 在真机上输入一遍才能暴露出来。