拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

数字输入框限制小数点与位数:原生、Vue、React、小程序完整实战

数字输入框限制小数点与位数:原生、Vue、React、小程序完整实战

输入框限制数字、小数点、小数点后位数,几乎是每个做前端或者做过表单的人都躲不过的一道坎。看起来是最没技术含量的活儿,一个正则加一个 oninput 好像就完事了,但真正上过线的人都知道,这里面的坑能从周一踩到周五:用户打不出小数点、光标一输入就跳到末尾、中文输入法下疯狂抖动、粘贴一段带千分位的金额直接失效、后端接口收到一个 e 开头的字符串然后报错。我自己前前后后在支付、财务、后台配置这几类系统里写过不下十套数字输入框,每一套都改过至少三轮。这篇就把 input 输入框限制数字、小数点以及小数点后数字个数这件事,从前端原生写法到 Vue、React、小程序的落地差异,再到 C、Python 这类后端或脚本语言的同类问题,完整拆一遍。

如果你正在做一个金额输入框、数量输入框、比例输入框,或者你只是被keydown里那串正则搞得头晕,下面的内容应该能直接拿去用。我会尽量把每一步“为什么这么设计”讲清楚,而不是甩一段代码让你自己猜。

1. 先把需求拆干净:输入框到底要限制什么

很多人一上来就写正则,写到一半发现需求根本没说清。数字输入框的限制其实不是一件事,而是好几件事叠在一起,你得先把它们分开。

1.1 四种典型限制场景与各自的边界

我一般会把需求归到下面这几类里,每一类的规则都不一样:

  • 纯整数场景:数量、件数、库存、人数。规则最简单,只允许 0-9,不能有小数点,不能有负号(除非是库存调整)。
  • 固定小数位场景:金额、单价、税率。通常限制两位小数,部分财务场景要四位,币种不同位数也不同。
  • 自由小数位但有上限场景:比例、折扣、汇率。常见限制 2 到 6 位,允许用户中途修改。
  • 科学计算场景:允许负号、允许较大位数,甚至允许指数形式。这类反而不该用文本输入框硬管,交给专门的数值组件更合适。

把场景分清之后,有两个决策点必须先定下来,否则后面代码会反复推翻重写:第一,负号允不允许,允许的话是只能在开头还是任意位置;第二,前导零要不要保留,比如用户输入007,你是存007还是存7。我做财务系统的时候,前导零一律干掉,因为007和7在数值上等价,但存进数据库类型不一致会出问题。

1.2 中间态思维:为什么不能一边打字一边强制格式化

这是整个输入框限制里最核心的一个认知,也是绝大多数人第一次写会踩的坑。

用户输入是一个过程,不是一个结果。当用户想输入0.5时,他的按键顺序是0→.→5。在他按下.的那一瞬间,输入框里的内容是0.。如果你在 oninput 里立刻做“合法数字校验”,0.不是合法数字,你把它清掉了,那用户永远打不出0.5。

所以正确的做法是把输入值分成三种状态:

状态例子处理策略
非法状态1.2.3、abc、1-2立即过滤掉非法字符
中间态""、-、.、0.、12.允许保留,不做补全
终态0.50、12、-3.2失焦时收敛成规范格式

中间态是这套逻辑的灵魂。你要在“过滤非法字符”和“不阻止用户下一步输入”之间画一条线:非法字符该删就删,但结构合法的中间态必须留着。判断中间态用正则会比判断终态宽松得多,比如两位小数的中间态正则是^-?\d*(\.\d{0,2})?$,注意这里\d*和\d{0,2}都允许为空,就是为了放行0.这种状态。而失焦时的终态正则可以收紧成^-?\d+(\.\d{1,2})?$。

1.3 浮点数会骗人:精度问题的真实来源

还有一个反直觉的点:小数点后位数的限制,绝对不能用 parseFloat 之后再 toFixed 往返一趟。

原因很简单,0.1 + 0.2在 IEEE 754 双精度下等于0.30000000000000004,这不是浏览器的 bug,是二进制浮点的固有特性。你用Number("1.005").toFixed(2),期望得到1.01,实际得到1.00,因为1.005在内存里其实约等于1.00499999999999989。
所以我在处理“截断到两位小数”这个动作时,全程用字符串切分,绝不经过 Number 转换。字符串截取是精确的,浮点运算是近似的,这个选择没有任何犹豫的余地。

至于最大最小值约束,比如max = 99999.99,我一般放在失焦收敛阶段做,而不是在输入过程中做。理由还是中间态:用户想输1000,打到1的时候就已经超过min = 100了,如果你输入中就纠正,用户会疯掉。

2. 方案选型:type="number" 到底能不能用

网上教程十篇有八篇告诉你<input type="number" min="0" step="0.01">就搞定了。这话对一半,剩下那一半坑能让你回滚上线。

2.1 type="number" 的真实行为与浏览器差异

先说结论:在需要精细控制输入过程的场景里,我不建议用 type="number"。理由有几条,都是实测出来的。

第一条,它的过滤器太窄又太宽。窄的地方是它只认英文半角数字和.-+e;宽的地方是它允许科学计数法,用户在输入框里敲1e5是完全合法的,后端接口如果按普通字符串解析,直接报错。这个是被投诉过很多次的经典问题。

第二条,maxlength属性对type="number"完全无效。这是 HTML 规范里明确写的,number 类型的输入框不支持 maxlength。很多人写了个maxlength="10",测的时候没超过,上线后用户直接粘了一串 30 位数字进来,字段溢出。

第三条,也是最要命的:type="number"的输入框不支持setSelectionRange。你在它上面调用光标控制 API,控制台会直接抛InvalidStateError: The input element's type ('number') does not support selection.。这意味着你没法做精细的光标恢复,过滤掉一个字符之后,光标必然跑到末尾。

第四条,滚轮问题。Chrome 下当type="number"的输入框获得焦点时,鼠标滚轮滚动会直接改变数值。用户在页面上滚动查看内容,手一滑金额就从 1000 变成 999 或者 1001,而且他完全不知道发生了什么。这个问题在财务系统里是致命的。

当然它也不是全无优点:移动端能唤起数字键盘,桌面端有上下步进箭头,浏览器会做基础的范围校验。所以我的取舍是:纯整数、位数固定、不需要光标控制、且明确禁止滚轮的简单场景可以用 number;金额、比例、汇率这类需要过程控制的场景,一律用type="text"。

2.2 type="text" + inputmode 的组合拳

用type="text"之后,最大的损失是移动端键盘。补救方法是inputmode这个属性:

<input type="text" inputmode="decimal" autocomplete="off" placeholder="请输入金额" />

inputmode="decimal"会让 iOS 和大部分安卓机型弹出带小数点的数字键盘,正好对应金额场景。如果是纯整数就用inputmode="numeric",会弹出纯数字键盘(部分机型没有小数点)。这个属性和type完全不冲突,是目前移动端数字输入的标准做法。

还有一个细节是autocomplete="off"。浏览器自带的输入历史下拉框在某些场景下很烦,比如身份证号、验证码、金额这类字段,弹出历史记录既不安全也影响体验。不过要注意,部分浏览器对autocomplete="off"的支持并不完整,Chrome 对新密码字段会强制忽略这个属性,如果确实要彻底禁用,可以配合一个随机 name 或者autocomplete="new-password"来绕过。

2.3 四种主流方案横向对比

把常见的几种做法列在一起,你就能看出为什么我推荐方案三:

方案实现方式优点主要问题
方案一type="number"+min/max/step写法最省事,移动端键盘自然支持e、maxlength 失效、滚轮改值、无法控制光标
方案二keydown里preventDefault能提前拦截按键挡不住输入法和粘贴,长按删除键、组合键行为异常
方案三input事件 + 正则过滤 + 失焦收敛兼容输入法、粘贴、拖拽,可控性强需要自己处理光标位置
方案四成熟的数值输入组件库开箱即用,细节处理到位体积成本,定制样式和特殊规则时要改源码

方案二我特意列出来,是因为它是最常见的“教程写法”,也是最容易出问题的写法。keydown事件在中文输入法下根本不会按你的预期触发,用户按数字键的时候触发的是229这个特殊键码,你拦不住;粘贴更不用说,Ctrl+V走的是paste事件,跟keydown没关系。所以keydown只能作为辅助,不能作为主拦截手段。

真正的主拦截点是input事件,因为它在值已经进入输入框之后触发,无论这个值来自键盘、输入法、粘贴还是拖拽,都会走到这里。你拿到的是一个完整的字符串,只需要做“过滤 + 回写”就够了,逻辑天然统一。

3. 手写一个可复用的数字输入过滤器

下面这套实现我在三个项目里用过,改一改就能适配不同的小数位要求。核心思路是把过滤拆成三段式管线,每段只干一件事。

3.1 整体设计:三段式过滤管线

三段分别是:

  1. 字符级清洗:全角转半角、去掉千分位和空格、剔除所有非数字 / 非小数点 / 非负号的字符。
  2. 结构级规范化:负号只留开头一个,小数点只留第一个,超过位数的小数直接截断,去掉多余前导零。
  3. 失焦收敛:把中间态补成终态,比如.5补成0.5、12.去掉尾点、-和.单独存在时清空,最后再做最大最小值收敛。

为什么要分成三段而不是一把梭?因为在输入过程中和失焦时,需要的严格程度不一样。输入中要宽松,只干掉明确非法的东西;失焦时要严格,输出一个能被后端直接使用、能被数据库字段直接接收的规范值。合成一个函数的话,你就得在里面写一堆 if 判断当前处于哪个阶段,反而更乱。

3.2 第一段:字符级清洗

字符清洗要处理的输入来源比想象中多,我列一下实际遇到过的:

  • 中文输入法下直接打出的全角数字123,字符码在\uFF10-\uFF19,和半角数字不是一回事。
  • 从 Excel 复制过来的金额,格式是1,234.56,带千分位逗号,有时候还是全角逗号。
  • 从 Word 或者某些 PDF 复制过来的负号,是 Unicode 的\u2212,而不是 ASCII 的-。
  • 全角句号。被误当成小数点。

处理方式就是一轮 replace 把它统一成半角字符,再用一个否定字符集把剩下的杂字符清掉:

function cleanChars(raw) { return String(raw) // 全角数字转半角 .replace(/[\uFF10-\uFF19]/g, c => String.fromCharCode(c.charCodeAt(0) - 0xFEE0)) // 全角点、中文句号统一成半角点 .replace(/[\uFF0E\u3002]/g, '.') // 全角减号、Unicode 减号统一成半角减号 .replace(/[\uFF0D\u2212\u2013\u2014]/g, '-') // 去掉千分位逗号和空白 .replace(/[,\uFF0C\s]/g, '') // 剩下的非数字、非点、非减号一律清掉 .replace(/[^\d.\-]/g, ''); }

注意:全角转半角的偏移量是固定的0xFEE0,因为全角数字和半角数字在 Unicode 编码表里是等距排列的。这个技巧比写十行映射表干净得多,也可以用在全角字母上(\uFF21-\uFF3A对应大写字母)。

3.3 第二段:结构级规范化

清洗完之后,字符串里可能还会出现1.2.3、--5、0007这类结构问题,要在这里收拾干净。

负号的规则是:如果配置了允许负数,就检查原串里有没有负号,有就把所有负号删掉,再在最前面补一个;如果不允许负数,直接全部删掉。这个“先全删再加回一个”的思路比各种位置判断简单得多,也不会有遗漏。

小数点的规则同理:找到第一个小数点的位置,把它后面的所有小数点全删掉。这样1.2.3会变成1.23,用户看到的是自己的输入被打断,而不是整串数字被清空,体验上好很多。

然后是小数的位数截断。这一步必须用字符串切分,不能用数学方法:

const dot = str.indexOf('.'); if (dot !== -1) { str = str.slice(0, dot + 1) + str.slice(dot + 1, dot + 1 + decimals); }

slice是精确的,toFixed是有舍入误差的。如果业务上要求四舍五入而不是直接截断,那也建议在失焦阶段用Math.round(num * 100) / 100或者专门的十进制库来做,别在输入过程中做,否则用户改一位数你的值就一直在跳。

最后是前导零。这里有个细节:007要变成7,但0.5不能动,0单独存在也不能动。所以我用的正则是/^(-?)0+(?=\d)/,配合替换成$1。它的含义是“开头一个可选负号,跟着一个或多个 0,但后面必须还有一个数字”。007匹配成功变成7,0.5因为 0 后面是点不是数字,不匹配,保持原样。

3.4 第三段:失焦收敛

失焦要做三件事:补全、清空无效值、做范围收敛。

补全主要针对小数点开头的情况。用户可能输入.5,这在数值意义上等于0.5,但直接提交给后端会解析失败,所以失焦时补一个0。反过来,结尾的单点12.要去掉点变成12,因为12.在多数语言的数值解析里是合法的,但存字符串的时候看着别扭,而且再来一次拼接容易出错。

清空无效值是指那几种纯粹的中间态:空字符串、单独的-、单独的.、-.。这些在失焦时必须变成空字符串,否则提交上去后端会报类型转换错误。我在日志里见过太多次failed to deserialize ... target type的报错,追根溯源就是前端把一个孤零零的-提交上去了。

范围收敛放在最后,max和min直接比较截断后的数值。这里有个小顺序问题:必须先截断小数位,再做范围比较。因为如果你的max是99.99,用户输入100.001,截断后是100.00,超了,收敛到99.99;如果先比较再截断,逻辑就乱了。

3.5 完整代码

把上面三段拼起来,就是一个可直接复用的过滤器:

function createNumberFilter(options = {}) { const cfg = Object.assign({ decimals: 2, // 小数位数,0 表示只允许整数 allowNegative: false, // 是否允许负数 max: null, // 最大值,null 表示不限制 min: null, // 最小值,null 表示不限制 }, options); // 输入中:只做清洗和结构规范,保留合法中间态 function intermediate(raw) { let str = cleanChars(raw); // 负号:只在开头保留一个 const hasMinus = cfg.allowNegative && str.includes('-'); str = str.replace(/-/g, ''); if (hasMinus) str = '-' + str; // 小数点:只保留第一个 const dot = str.indexOf('.'); if (dot !== -1) { str = str.slice(0, dot + 1) + str.slice(dot + 1).replace(/\./g, ''); } // 小数位截断 if (cfg.decimals === 0) { str = str.split('.')[0]; } else { const i = str.indexOf('.'); if (i !== -1) { str = str.slice(0, i + 1) + str.slice(i + 1, i + 1 + cfg.decimals); } } // 去掉多余前导零,但保留 "0." 和 "0" str = str.replace(/^(-?)0+(?=\d)/, '$1'); return str; } // 失焦:收敛成终态 function finalize(raw) { if (raw === '' || raw === '-' || raw === '.' || raw === '-.') return ''; let sign = ''; let str = raw; if (str.startsWith('-')) { sign = '-'; str = str.slice(1); } if (str === '') return ''; let [intPart, decPart] = str.split('.'); intPart = (intPart || '').replace(/^0+(?=\d)/, ''); if (intPart === '') intPart = '0'; if (cfg.decimals === 0) { decPart = ''; } else if (decPart) { decPart = decPart.slice(0, cfg.decimals); } let result = decPart ? `${intPart}.${decPart}` : intPart; if (cfg.max !== null && Number(result) > cfg.max) result = String(cfg.max); if (cfg.min !== null && Number(result) < cfg.min) result = String(cfg.min); // 避免出现 -0 if (sign === '-' && Number(result) === 0) sign = ''; return sign + result; } return { intermediate, finalize }; }

绑定到输入框的那层,核心是输入事件里的“过滤 + 回写 + 光标恢复”三步:

function bindNumberInput(el, options) { const filter = createNumberFilter(options); let composing = false; function apply() { const raw = el.value; const clean = filter.intermediate(raw); if (clean === raw) return; // 用过滤前的光标位置,去过滤后的前缀里找对应位置 const start = el.selectionStart ?? clean.length; const cursor = filter.intermediate(raw.slice(0, start)).length; el.value = clean; try { el.setSelectionRange(cursor, cursor); } catch (err) { // number 类型不支持 selection API,忽略即可 } } el.addEventListener('compositionstart', () => { composing = true; }); el.addEventListener('compositionend', () => { composing = false; apply(); }); el.addEventListener('input', (e) => { if (composing || e.isComposing) return; apply(); }); el.addEventListener('blur', () => { const raw = el.value; const final = filter.finalize(raw); if (final !== raw) { el.value = final; // 通知外部框架值已变化 el.dispatchEvent(new Event('input', { bubbles: true })); } }); }

3.6 光标位置的保持技巧

上面那段filter.intermediate(raw.slice(0, start)).length是解决光标跳动的关键,值得单独说一下原理。

当你把输入框的值改掉之后,浏览器会把光标重置。在很多情况下它会被放到末尾,这就是“光标一直往后跳”的来源。要把它放回原来的位置,你需要的不是“原来的索引”,而是“原来索引之前的那部分内容,经过同样过滤之后还剩多少个字符”。

所以做法是:把原始字符串从开头截到光标位置,对这一小段单独跑一遍过滤,得到的长度就是新光标应该待的位置。举个实际例子,原值是1,234,光标在第 3 位(1,之后)。过滤后整个值变成1234,如果你直接用原索引 3,光标会跑到123后面,位置错了;而把前缀1,单独过滤得到1,长度是 1,光标就正确落在1和2之间。

有一个已知的小误差:前缀单独过滤的结果,跟“整体过滤后取前 N 个字符”在某些边界情况下可能不一致,比如前导零的合并。在数字场景里这种差异极小,可以接受;如果你的场景对光标位置极其敏感,就得改成“整体过滤 + 标记位映射”的复杂方案,但我不建议为了这点精度把代码复杂度抬上去。

4. 主流框架里的落地差异

原生 DOM 那套逻辑是基础,但真正落到项目里,你大概率是在 Vue 或 React 里写,这两个框架对“手动改 input.value”这件事的态度完全不一样。

4.1 原生 DOM 与事件绑定顺序

先把原生场景的坑说清楚,因为框架的问题本质上是同一件事。

input事件的触发时机是值已经写入 DOM 之后,所以你可以在里面放心地读el.value。但如果你同时监听了keydown、keypress、beforeinput、input、change,要清楚它们的顺序是keydown→beforeinput→input→change。beforeinput是个好东西,能拿到inputType和即将插入的数据,理论上可以在值进入 DOM 之前就阻止,但它对输入法合成的处理很不一致,而且insertCompositionText类型的输入是不可取消的,你调preventDefault也没用。所以我还是推荐在input里做过滤。

另一个细节是change事件。它在输入框失去焦点且值有变化时触发,很多老代码用change来做格式化。问题是如果用户输入完直接点提交按钮,某些情况下change和按钮的click顺序会有微妙差异,导致提交的是未格式化的值。所以格式化一定要绑在blur上,而且要在提交前再做一次兜底校验。

4.2 Vue 中 v-model 的坑

Vue 里最容易被误解的就是v-model.number。很多人以为加了.number修饰符就能限制输入,实际上它只做一件事:在值变化时尝试用parseFloat转换,转不出来就返回原字符串。它完全不做输入限制,你照样可以输入abc,只不过v-model的值会是字符串"abc"而不是数字。

真正要限制输入,你得放弃v-model的双向绑定写法,改成手动控制:

<template> <input ref="inputRef" :value="displayValue" inputmode="decimal" @input="onInput" @blur="onBlur" /> </template>

但这里有个副作用要提前知道:当你在onInput里过滤完、更新了displayValue,如果新值和 DOM 里的值不一样,Vue 会重新渲染这个 input,光标就会跳。如果新值和旧值一样,Vue 不会 patch DOM,光标就保住了。所以过滤逻辑要尽可能做到“值不变时不触发更新”,这也是为什么我在上面的apply()里加了if (clean === raw) return;这个提前返回。

更省心的做法是把光标恢复直接写在 Vue 的nextTick里:

async function onInput(e) { const el = e.target; const raw = el.value; const clean = filter.intermediate(raw); if (clean === raw) return; const start = el.selectionStart; const cursor = filter.intermediate(raw.slice(0, start)).length; this.displayValue = clean; await this.$nextTick(); el.setSelectionRange(cursor, cursor); }

如果你在项目里用自定义指令封装,可以做成v-number="{ decimals: 2, max: 9999 }"的形式,指令的mounted里绑事件、unmounted里解绑,用起来就很干净了。

4.3 React 受控组件的光标跳动

React 的问题更明显一些,因为它是严格的受控组件模型:value由 state 决定,你在onChange里setState,React 重新渲染,DOM 的 value 被覆盖成 state 的值。

如果用户输入1.2.3,你把 state 设成1.23,React 发现 state 变了,重新渲染,input 的 value 从1.2.3变成1.23,光标跳到末尾。这就是 React 里光标跳动的完整链路。

解决思路有三个层次,按复杂度递增:

最省事的一层:输入过程中不做任何过滤,只在onBlur里做收敛。用户体验上,用户输入1.2.3的时候输入框里就是这个样子,失焦之后才变成1.23。对很多人来说这完全可以接受,代码量也最少。

中间一层:过滤但保持 state 不变。在onChange里直接用ref改 DOM 的 value,不调用setState,只在失焦时才同步 state。这属于“半受控”,能用但不推荐,因为 state 和视图会有短暂不同步,如果别的地方依赖这个 state 就会出问题。

最完整的一层:过滤 +useLayoutEffect恢复光标。

const inputRef = useRef(null); const cursorRef = useRef(null); function handleChange(e) { const raw = e.target.value; const clean = filter.intermediate(raw); if (clean === raw) return; const start = e.target.selectionStart; cursorRef.current = filter.intermediate(raw.slice(0, start)).length; setValue(clean); } useLayoutEffect(() => { if (cursorRef.current !== null && inputRef.current) { inputRef.current.setSelectionRange(cursorRef.current, cursorRef.current); cursorRef.current = null; } }, [value]);

用useLayoutEffect而不是useEffect的原因很直接:useLayoutEffect在 DOM 更新之后、浏览器绘制之前同步执行,此时设置光标用户看不到任何闪动;换成useEffect会有一帧的延迟,快速输入时能肉眼看到光标先跳到末尾再跳回来。

4.4 小程序与移动端

小程序里没有inputmode这一说,取而代之的是type属性的几个枚举值:text、number、digit、idcard。其中digit是带小数点的数字键盘,正好对应金额场景,number是纯数字键盘。这比 Web 端还省事一点。

但小程序有个 Web 端没有的坑:type="digit"下用户仍然可以输入多个小数点,微信自己不做拦截,你必须在bindinput里自己过滤。而且小程序的bindinput返回值可以直接决定输入框显示什么内容,这倒是个便利——你直接return clean就行了,不需要手动操作 DOM,也不存在光标问题,因为框架帮你处理了。代价是你对光标的控制权也没了,遇到需要精细控制的场景会比较被动。

移动端还有一个经典问题是键盘遮挡。输入框在页面底部时,软键盘弹出会盖住输入框,用户看不见自己在打什么。这个跟数字限制本身无关,但会严重影响体验,一般靠监听页面高度变化、把输入框滚动进可视区域来解决。

5. 常见问题速查与避坑实录

这部分是我这几年攒下来的实际问题清单,基本覆盖了你能遇到的绝大多数情况。

5.1 光标跳到末尾

现象:输入到中间某位想改一个数字,一敲键盘光标就跑到最后。
原因:过滤后直接赋值el.value = clean,浏览器把光标重置到了末尾。
解法:用 3.6 节那套“前缀单独过滤取长度”的方法恢复光标。如果框架层面已经有延迟渲染,注意在对应的生命周期里恢复,React 用useLayoutEffect,Vue 用nextTick或$nextTick。
额外注意:type="number"上调setSelectionRange会抛异常,一定要包try/catch,或者干脆换成type="text"。

5.2 中文输入法、全角数字与科学计数法

中文输入法的处理是必须做的,否则会出很诡异的问题。用户在拼音输入状态下敲数字,input事件会在拼音还没上屏的时候触发,此时el.value里可能包含临时的拼音字母。如果你不过滤就回写,用户正在拼的字就被打断了。

标准解法是把compositionstart和compositionend两个事件用起来,在合成期间挂起过滤:

let composing = false; el.addEventListener('compositionstart', () => composing = true); el.addEventListener('compositionend', () => { composing = false; apply(); }); el.addEventListener('input', e => { if (composing || e.isComposing) return; apply(); });

另外,输入法在无候选状态下直接敲数字键,键码通常是229,这就是为什么在keydown里判断按键码的做法在中文环境下完全失效。别在keydown上做判断,这是踩过的坑。

全角数字已经在前面的清洗里处理了。科学计数法的话,只要把e、E这类字符在清洗阶段当非法字符删掉,就根本不会出现。

5.3 粘贴、拖拽与浏览器自动填充

粘贴是最容易被忽略的入口。用户从 Excel 复制1,234.56,或者从别的地方复制¥1,234.56,input事件会一次性触发,值直接进去。好消息是你的input处理函数天然覆盖了粘贴,因为粘贴之后必然会触发input。坏消息是如果粘贴的内容很长,光标位置的处理会更复杂一些,但用前缀过滤的算法依然能算对。

拖拽文本也是同理,drop之后会触发input。这两类入口都不需要额外写代码,只要你的主逻辑挂在input上,就自动兜住了。反过来,如果你用的是keydown拦截方案,这两个入口全部漏掉。

自动填充这一块,autocomplete="off"能解决大部分情况,但 Chrome 对某些场景会忽略它。如果发现自动填充后值绕过了过滤逻辑,可以在blur和提交前各做一次兜底过滤,双保险。

5.4 常见问题速查表

问题现象大概率原因处理办法
用户输入0.就被清空用终态正则做了实时校验拆出中间态正则,允许0.、.、-通过
光标每次输入都跳到末尾过滤后直接赋值未恢复光标用前缀过滤法计算新光标位置并setSelectionRange
输入框里出现了字母e用了type="number"改用type="text"+inputmode="decimal"
设置了 maxlength 但没生效type="number"不支持 maxlength换 text 类型,或自己在过滤里限制长度
中文输入法下输入卡顿、乱码合成事件期间执行了过滤监听compositionstart/end,合成期间挂起
粘贴带逗号的金额后值错误千分位逗号没清洗清洗阶段加一步去掉逗号
滚轮滚一下就改了金额type="number"的默认行为换 text 类型,或监听wheel并preventDefault
提交时报类型转换失败提交了-、.这类中间态失焦和提交前都做终态收敛,空中间态清空
后端收到的值多出小数点后好几位只在前端显示层截断提交前用字符串切分截断,不依赖 toFixed
值经过 toFixed 后不对浮点精度问题全程用字符串处理,或引入十进制计算库

6. 跳出前端:不同技术栈里的同一类问题

数字输入限制这件事不只存在于浏览器里。只要涉及“用户输入数字”和“数值显示格式”,每个技术栈都会遇到同一类问题,只是表现形式不同。

6.1 存储精度与显示精度别混为一谈

有一个概念必须先分清:存储精度和显示精度是两回事。

存储精度是数据在数据库、文件、内存里的实际精度。比如数量字段用DECIMAL(10,2)存,那它永远只能有两位小数,多余的部分在写入时就被数据库截断或者报错。

显示精度是你在界面上、在报表里、在地图属性表里看到的格式。举个常见的现象:在属性表或者数据库客户端里,有的字段会显示成.35而不是0.35,小数点前的 0 不见了。这不是数据丢了,是那个工具的字段格式配置里设置了“省略前导零”。同样的现象在数据可视化工具的小数位数设置、电子表格的小数位格式里都会出现。遇到这种情况,先去查显示格式配置,别急着改数据。

同理,各种统计软件里的“保留几位小数”设置,绝大多数只影响显示,不影响参与计算的值。如果你需要真正把精度截断,得用专门的取整函数处理,而不是改显示格式。

6.2 各语言里的数值解析与格式化

不同语言处理这个问题的姿势差异挺大的,我列几个最常打交道的:

技术栈输入获取方式推荐做法
Pythoninput()永远返回字符串先strip(),再Decimal解析,配合try/except兜底
C#字符串转数值用decimal.TryParse而不是int.TryParse处理带小数的场景,TryParse模式天然避免异常
C 语言字符串转浮点用strtod并检查endptr,格式化输出用"%.2f",但要注意它是四舍五入而非截断
JavaBigDecimal金额一律用BigDecimal,构造时传字符串,不要传 double
SQL字段类型决定精度用DECIMAL/NUMERIC,不要用FLOAT存金额
电子表格 / 统计软件单元格格式区分“格式设置”和“实际值”,导出前确认导出的是值不是显示文本

Python 那个input()是典型例子:它永远返回字符串,返回的"12.5"和数字12.5完全不是一回事。新手最常见的错误就是直接拿input()的结果做算术,然后报类型错误或者得到字符串拼接的结果。正确姿势是配合异常处理来做解析,解析失败给用户一个明确的提示,而不是让程序崩掉。

C# 的TryParse模式值得单独提一句,因为它把“解析失败”作为返回值而不是异常,这在处理用户输入这种高度不可控的场景里非常合适。异常的开销远大于返回一个false,更重要的是用返回值判断,代码的流程是线性的,不会到处都是try/catch。

C 语言这边,printf("%.2f", x)做的是四舍五入,不是截断。如果你需要截断,得先手动处理,比如floor(x * 100) / 100。另外 C 的浮点输出受舍入模式影响,金融场景下不要用double算钱,这是老生常谈了。

我个人在实际项目里的体会是,数字输入限制这件事真正难的从来不是正则本身,而是你愿不愿意花时间把中间态想清楚。我见过太多代码,正则写得漂漂亮亮,但用户输入0.的时候被清空,然后客服收到一堆“为什么打不出小数”的反馈。后来我的习惯是,任何数字输入框上线前,我都会手动测一遍这几个用例:0.、.5、-、007、1.2.3、123(全角)、1,234.56(带千分位)、还有一段中文输入法状态下敲的数字。这八个用例跑通,基本就没什么问题了。

最后再分享一个小技巧:如果你的项目里数字输入框比较多,别每个都临时写一遍,直接把这个过滤器做成一个自定义指令或者一个受控组件,把小数位数、是否允许负数、最大最小值做成配置项。这样后面加需求的时候改配置就行,不至于像我现在这样,翻出三年前的项目还得先读一遍当时的正则。

返回列表