
做前端这几年业务表单里凡是涉及金额、数量、库存、手机号这类输入框几乎都会碰到一个很缠人的需求input 只能填数字不能有乱七八糟的符号最好还能控制小数点后保留几位。标题里的“Vue 自定义指令 - input 只能输入数字类型可设置小数点及小数点后几位”就是这类需求的经典代表。我第一次接到这个需求时第一反应是给 input 加一个正则校验事件结果被产品经理在验收时挑出一堆毛病可以粘贴字母、可以输入多个小数点、中文输入法下还能混进全角数字……后来我把这套逻辑收拢成一条可复用的自定义指令才算是真正解决了问题。这篇就完整记录我的设计思路、完整代码、踩坑过程和扩展玩法适合正在写 Vue2/Vue3 项目、又不想在每个表单里复制粘贴正则的同学参考。1. 需求拆解数字输入限制到底在解决什么问题1.1 表面需求与实际痛点从标题看需求可以拆成三层第一禁止 input 输入非数字字符第二允许配置小数点是否出现第三能设置小数点后最多保留几位。这三句话看起来很简单但真正落到代码里时你会发现它牵涉到“什么时候拦截”“怎么拦截”“拦截后光标往哪跑”“中文输入法怎么防穿透”“v-model 的值会不会跟界面不一致”等一系列问题。我在实际项目里见到过最原始的写法是在change或blur事件里做校验发现非法值后弹个提示再把输入框清空。这种方式用户体感非常差输入半天保存时才被告知格式不对而且清空所有内容比提示更让人崩溃。另一种常见写法是在input事件里写一大堆.replace()每个页面复制一份换一个表单要改三处位置代码重复度极高后面维护的人看了只想骂人。1.2 为什么不直接用 typenumber很多人会问HTML 不是自带typenumber吗为什么还要自己写指令我实战下来发现这个原生方案有四个硬伤。第一typenumber在部分浏览器下允许输入字母e、正负号甚至允许1.2.3这种半非法状态因为它内部有一套自己的 parser不同浏览器表现不一致。第二滚动行为很诡异在桌面端输入框上滚轮会改变数值用户翻着页面数值就变了很容易误操作。第三移动端弹出的键盘虽然是数字键盘但深层次的浮点输入、科学计数法等行为依然不可控。第四它的样式在各浏览器下不统一比如部分浏览器会在输入框右侧出现上下箭头破坏设计稿。相比之下用typetext配合inputmode属性再自己控制输入内容体验会稳定得多。1.3 为什么选择自定义指令页面里有多个表单每个表单都需要同一套数字限制最合理的做法就是把限制逻辑抽成公共能力。Vue 的自定义指令天生适合这类“操作 DOM”的封装它可以直接绑定在 input 元素上拦截原生事件处理完毕后同步给 v-model整个过程对业务组件透明。自定义指令比封装组件更轻量。它不需要接收 value、emit 更新事件也不需要关心组件层级直接input v-input-number.decimal2 /就能生效。业务代码几乎零侵入也不影响原有 v-model 的读写逻辑。这就是我最终选择自定义指令方案的原因。2. 指令 API 设计参数怎么定才顺手2.1 把需求翻译成配置项标题明确提到“可设置小数点及小数点后几位”这句话翻译成参数就是两个核心开关是否允许小数点、小数点后保留多少位。考虑到实际业务里金额为负数也很常见比如“退款金额”“温度差”我还会加入一个允许负数的配置项。整理后的配置项如下配置项类型默认值说明decimalbooleanfalse是否允许输入小数点decimalPlacesnumber2小数点后最多保留几位allowNegativebooleanfalse是否允许负号且负号只能出现在首位默认值不设太多花活不传任何配置时指令只允许正整数。用户看到v-input-number就知道这是一个整数输入框符合直觉。2.2 用修饰符和参数组合出简洁写法设计指令 API 时我参考了很多开源组件库的做法最后结合 Vue 指令语法采用了“修饰符 参数 对象配置”三合一的方式!-- 只允许非负整数比如库存数量 -- input v-input-number / !-- 允许小数默认最多两位比如金额 -- input v-input-number.decimal / !-- 允许小数且最多三位 -- input v-input-number.decimal3 / !-- 允许负数比如温度、收支差额 -- input v-input-number{ allowNegative: true } / !-- 更完整的组合 -- input v-input-number{ decimal: true, decimalPlaces: 4, allowNegative: false } /其中decimal修饰符负责打开小数开关v-input-number.decimal3里的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(/[-]/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改成过滤后的值。这时候如果不手动触发一次inputv-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.trimform.stock v-input-number placeholder请输入整数 / label商品单价最多两位小数/label input v-model.trimform.price v-input-number.decimal2 placeholder0.00 / label温度支持负数和小数/label input v-model.trimform.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标志组合态期间不执行过滤逻辑组合结束后再统一过滤一次。全角数字和中文字符是另一个坑。中文输入法下直接按数字键可能出现这种全角数字中文句号“。”也经常被人当成小数点输入。按 3.2 的处理全角数字统一转半角中文句号转英文小数点问题就解决了。这个细节如果不处理用户在中英文混合输入时很容易觉得自己被“卡键盘”了。4.2 粘贴内容时的兜底处理input事件不仅能捕获键盘输入也能捕获右键粘贴、拖拽放入等操作。正则过滤在处理粘贴场景时优势明显用户从 Excel 或者网页里复制过来一段123.45元我的过滤逻辑会直接扔掉所有非数字字符保留123.45。但要注意一个特殊情况用户粘贴的内容可能本身带有多个小数点比如1.2.3此时算法只保留第一个小数点输出1.23。这个行为在大多数业务里可以接受但如果产品要求粘贴时遇到非法内容就直接清空而不是智能修正那就需要额外做拦截这里不展开。4.3 移动端 typenumber 的体验问题我最初做这个指令时只是把元素类型改成了typetext移动端弹出来的是全键盘用户输入数字要切换符号体验很差。后来才引入inputmode属性。inputmodedecimal在大部分移动端浏览器上会直接弹出带小数点的数字键盘inputmodenumeric弹出纯数字键盘。建议在使用指令时统一设置。如果项目中已经写了typenumber也建议改为typetext加inputmode既保留原生键盘优势又避开typenumber的滚动改值等乱象。我也遇到过一个 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(/[-]/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.decimal4正负数允许小数温度、账单差额v-input-number{ decimal: true, allowNegative: true }金额自动补齐两位0.00v-input-number.decimal26. 实测过程中的心得体会这个指令从第一次写完到现在已经在好几个项目里跑过我踩过的坑和总结出的心得都写在上面了。想再强调一遍指令内部只操作 DOM不要碰数据层过滤逻辑用逐字符扫描不要迷信一个大正则中文输入法组合态和光标恢复是隐藏的杀手不处理这两个问题用不了多久用户就会提工单。如果业务里需要更复杂的金额输入体验比如千分位分隔、最大值限制、精度四舍五入建议在这些基础逻辑上再包一层组件处理而不是把所有逻辑都塞进指令里。指令保持单一职责后续维护才会轻松。最后再补一个小建议最好在项目里为这个指令写一份简单的单元测试至少覆盖全角数字、多个小数点、负号位置、小数位裁剪这几类边界输入。我后来就是因为补了测试才在一次重构中及时发现“中文句号被误删”的回归 bug。没用自动化测试之前这种问题通常要等 QA 在真机上输入一遍才能暴露出来。