☰
input 只能输数字与两位小数:中文输入法、光标和框架实践
2026/9/30 2:35:31 网站建设 项目流程

限制一个 input 只能输数字、只允许出现一个小数点、小数点后面最多两位,这件事我在报价单、金额录入、库存数量、经纬度填写、工时填报这些场景里反复做过。它看起来是十分钟的活儿,真动手就会发现中文输入法能塞汉字进来,粘贴能带一长串脏数据进来,光标会在你删掉非法字符之后跳到末尾,移动端弹出来的键盘还可能是全键盘。我第一次做这个需求的时候直接用正则一把梭,结果用户在中文输入法里打了个汉字,页面上就真的出现了那个字;第二次改成type="number",又有人反馈输1e5提交报错、滚轮不小心把金额从 100 滚成 99。前后重写了三遍,我才把哪些事该在输入阶段拦、哪些该在失焦阶段补、哪些得留给后端兜底这几件事理清楚。下面就把这些年踩出来的东西完整摊开:从 input 标签自身的坑,到逐字符清洗函数怎么写,到 Vue、React、小程序里的落地方式,再到十几个能直接对照排查的疑难场景。刚入行的能照着抄一份能上线的代码,做过几年的也能在里面找到几个自己被坑过但没想明白原因的细节。

1. 先把需求拆干净:限制数字、小数点、限制位数是三件事

1.1 三个层次的需求,混在一起写必然出 bug

很多人写这个功能的时候习惯性把它当成一个需求,脑子里只有一句“只能输数字和小数点”。但只要你在真实项目里待过就知道,这里其实是三层独立的规则,混在一起写,边界情况一定会漏。

第一层是字符白名单:只允许 0-9 这十个字符,加上一个可选的小数点,加上一个可选的负号(如果业务允许负数)。这一层解决的是“别让汉字、字母、空格进来”的问题,本质上是一次字符过滤。

第二层是结构约束:小数点最多出现一次,负号只能出现在第一位,小数点前面不能是空的(或者要自动补 0)。这一层解决的是“1.2.3、--5、.5”这类结构上就不合法的输入。

第三层是位数和范围约束:小数点后最多 N 位、整数部分最多 M 位、数值要落在[min, max]区间内。这一层和前面两层有本质区别——前两层可以在每次按键时实时处理,第三层里有一部分(比如整数位长度)可以实时截断,另一部分(比如范围校验)最好放到失焦或者提交时处理。

为什么要这么拆?因为这三层的处理时机不同。字符过滤必须在输入的第一时间做,否则用户会看到一堆非法字符在框里闪一下再消失,体验很差;结构约束也适合实时做;但范围约束如果实时做,用户输入100想输1000的时候,刚敲到100就被你夹到99,这个交互是灾难性的。所以正确的做法是:前两层实时,第三层延迟到 blur 或者提交。

顺带说一个容易忽略的点:如果你用的是金额场景,整数部分的位数上限往往和业务的精度要求绑定。比如一个只支持万元级金额的系统,整数位给 8 位,小数位给 2 位,那么总长度上限就是 11 位,这个数字应该在写代码之前就跟产品确认清楚,而不是等测试提 bug 再回头改。

1.2 为什么不推荐直接用 type="number"

<input type="number">是最容易被误用的一个标签。它的语义是“这是一个数字”,看起来正好符合需求,但实际用起来有四个很硬的短板。

第一,它允许科学计数法和符号字符。在大多数桌面浏览器里,type="number"的输入框是接受e、E、+、-、.这几个字符的,因为它要支持1e5这样的科学计数法写法。用户随手敲了个e,输入框里就真的显示出来了,但此时input.value会返回空字符串(处于所谓的 bad input 状态),你的表单如果只读 value 做非空校验,会莫名其妙报“请填写金额”,而用户明明看到框里有内容。

第二,它没法限制小数位数。type="number"可以配合step="0.01"做步进约束,但step只影响上下箭头的行为和提交时的原生校验,用户手动敲1.23456是完全敲得进去的。想要限制位数,还是得回到 JS 里处理。

第三,它不支持maxlength。这是很多人踩过的坑,写了个maxlength="10"发现完全没生效。原因是maxlength在规范里只对文本类型的输入有效,number 类型不适用。

第四,滚轮误触会改值。鼠标滚轮停在 number 输入框上滚动页面,数值会跟着加减,这在金额输入场景里非常危险。需要额外用wheel事件阻止默认行为,或者干脆别用 number。

那type="number"什么时候合适?纯粹做数值采集、不需要严格控制格式、并且能接受浏览器原生行为的内部系统,用它没问题。但凡是面向 C 端用户、涉及金额或者精度、需要严格控制输入格式的场景,我的建议是统一用type="text",配合inputmode控制键盘,逻辑自己写。

1.3 方案选型对照表

把这几种常见方案摊开对比一下,选型的时候心里有底:

方案能否限制小数位中文输入法表现移动端键盘光标稳定性推荐指数
type="number"原生不能一般数字键盘(部分机型带 e)好内部系统可用
type="text"+ 正则整串校验可以需要额外处理需配 inputmode会跳入门方案
type="text"+ input 事件清洗回写可以需要处理 composition需配 inputmode需要手动修正主流方案
type="text"+ beforeinput 拦截可以需处理 IME需配 inputmode几乎不跳体验最好,代码量大
第三方 mask 库可以库内部处理看库支持好快速上线首选

如果你项目周期紧,用成熟的掩码库是最省事的,代价是包体积和自定义能力受限。如果要求极致轻量或者有特殊格式要求,自己写清洗函数反而更可控。

2. 原理层:字符流进入输入框的那几百毫秒里发生了什么

2.1 input 事件的触发时机与 isComposing 状态

要写对这段逻辑,得先搞清楚浏览器在用户打字时到底做了几件事。用户在键盘上敲下一个字符,浏览器经历的过程大致是:keydown→beforeinput→ DOM 更新 →input→keyup。其中beforeinput是可以被preventDefault()拦下来的,而input事件触发的时候,DOM 里的 value 已经被改掉了,你只能事后补救。

关键的地方在于中文输入法、日文输入法这类需要组字过程的输入方式。以中文拼音为例,用户输入san的时候,输入框里会先出现一个候选框,此时如果输入框获得了输入焦点,浏览器会在每次候选变化时触发compositionupdate,但不一定触发input。用户按下空格确认,汉字“三”真正落到输入框里的那一刻,会依次触发compositionend和input。

问题来了:在组字过程中,如果浏览器触发了input事件,e.isComposing会是true;但compositionend之后紧接着触发的那个input,e.isComposing在部分浏览器里是false,在另一些里是true。这个不一致性是很多人代码出问题的根源——你在input里判断了isComposing就 return,结果compositionend之后那个真正落地的事件被漏掉了,汉字就留在框里了。

稳妥的做法是双保险:input事件里检查e.isComposing,为true就跳过;同时监听compositionend,在这个事件里再做一次清洗。因为清洗函数是幂等的,重复执行不会有副作用。

2.2 为什么“过滤 + 回写”一定会动光标

这个问题的本质是浏览器的光标位置是跟 DOM 字符串索引绑定的。假设输入框里是12.34,光标在第 3 位(也就是2和.之间)。用户又敲了一个.,值变成12..34,光标自动前进到第 4 位。此时你的清洗逻辑把多余的.删掉,值变回12.34。

如果你只是简单地el.value = '12.34',浏览器会把光标重置到字符串末尾,也就是第 5 位。用户接下来敲5,得到的是12.345而不是期望的12.534。在输入长长的小数的时候,这个体验会让人抓狂。

解决办法是在回写之前,先记录当前光标位置el.selectionStart,然后用“清洗后的前缀长度”作为新的光标位置。具体逻辑是:取原始字符串从 0 到光标位置的这一段,对它单独做一次清洗,得到的长度就是新光标应该待的位置。这个思路之所以成立,是因为用户输入时前面的内容是稳定的,只有光标附近可能产生非法字符。

有一个细节要注意:如果前缀清洗的结果因为补零而变长了(比如用户输入了.被补成0.),光标位置会比预期多 1,但这时候光标本来也应该在补出来的 0 后面,所以结果是对的,不用特殊处理。

2.3 拦截式(beforeinput)和清洗式(input)的取舍

两种思路的差别其实很大,选哪个取决于你对体验的要求。

清洗式的做法是等字符落进输入框,再把它擦掉。优点是实现简单,只需要一个清洗函数,粘贴、拖拽、输入法全都能覆盖到。缺点是在极短的一瞬间,非法字符会真实地出现在框里,如果用户手速快或者设备性能差,能看到明显的闪烁;同时因为要回写 DOM,光标需要手动修正,代码复杂度不低。

拦截式的做法是在beforeinput阶段就把非法输入挡在门外。e.inputType === 'insertText'的时候,你可以拿到将要插入的文本e.data,把它拼到“当前值 + 光标前的部分 + 光标后的部分”上,模拟出输入后的结果,用一个校验函数判断是否合法,不合法就preventDefault()。因为 DOM 从来没被改过,光标天然稳定,不会有任何跳动。

拦截式的代价是要覆盖的输入类型变多了:insertText之外,还有insertFromPaste、insertFromDrop、insertCompositionText(IME 组字期间)。其中insertCompositionText是不能随便拦的,拦了会破坏输入法的候选框行为,只能等compositionend之后再说。所以实践中常见的策略是:beforeinput拦字符插入,input事件做兜底清洗,两条腿走路。

我个人在 C 端高交互场景里偏好拦截式,在后台管理系统里用清洗式就够了,因为后台用户对光标跳动的容忍度通常高一些。

3. 手写一个能上线的数值输入框

3.1 清洗函数 sanitizeNumber 的完整实现

整个功能的核心就一个纯函数:给它一个任意字符串,返回清洗后的合法字符串。写成纯函数的好处是它可以单独做单元测试,也能在 Vue、React、小程序里无缝复用。

/** * 把任意字符串清洗成合法数值字符串 * @param {string} raw 原始输入 * @param {object} opt * @param {number} opt.decimals 允许的小数位数,0 表示只允许整数,默认 2 * @param {boolean} opt.allowNegative 是否允许负号,默认 false * @param {boolean} opt.fillZero 小数点前是否补 0(.5 -> 0.5),默认 true * @returns {string} */ export function sanitizeNumber(raw, opt = {}) { const { decimals = 2, allowNegative = false, fillZero = true, } = opt; let s = String(raw ?? ''); // 1. 全角数字、全角句号、中文句号统一转成半角 s = s.replace(/[\uFF10-\uFF19]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0xFEE0) ); s = s.replace(/[\u3002\uFF0E]/g, '.'); // 2. 先判断符号位,负号只保留在最前面那一个 let sign = ''; if (allowNegative && s.trim().startsWith('-')) sign = '-'; // 3. 剔除所有非数字、非小数点的字符 s = s.replace(/[^\d.]/g, ''); // 4. 只保留第一个小数点,后面的全部删掉 const dotIdx = s.indexOf('.'); if (dotIdx !== -1) { s = s.slice(0, dotIdx + 1) + s.slice(dotIdx + 1).replace(/\./g, ''); } // 5. 按 decimals 截断小数部分 if (decimals <= 0) { s = s.replace(/\./g, ''); } else { const i = s.indexOf('.'); if (i !== -1 && s.length - i - 1 > decimals) { s = s.slice(0, i + 1 + decimals); } } // 6. 清理前导零:0001 -> 1,但 0.5 要原样保留 s = s.replace(/^0+(\d)/, '$1'); // 7. 小数点前补零,避免出现 ".5" 这种显示 if (fillZero && s.startsWith('.')) s = '0' + s; // 8. 空串和孤立的点返回空;允许负数时保留一个负号方便继续输入 if (s === '' || s === '.') { return sign === '-' ? '-' : ''; } return sign + s; }

这段代码里有几个点值得展开说。

第 1 步的全角转换是必须的。中文输入法在中文标点模式下,用户敲句号得到的是全角句号。或者全角点.,这两个字符在视觉上和半角点几乎一样,但参与数值解析时会直接变成NaN。同样,全角数字0-9在移动端输入法里也很容易出现。这一步的成本是两次正则替换,收益是省掉大量“为什么用户说输对了但提交失败”的工单。

第 4 步的顺序很关键。一定要先找到第一个点的位置,再对它后面的部分做去点处理。如果直接写s.replace(/\./g, '')再补回来,逻辑会绕很多。

第 6 步的前导零处理要格外小心。正则^0+(\d)匹配“开头一串零后面跟着一个数字”,把它替换成那个数字。对于0.5,因为0后面是.而不是数字,正则不匹配,所以0.5被完整保留;对于0001,匹配000+1,替换成1;对于00,回溯匹配0+0,替换成0。三种情况都符合预期。这个细节在很多网上的实现里是错的,它们会写成s.replace(/^0+/, ''),结果0.5被吃掉零变成.5,或者0直接变成空串。

关于热词里那个“小数点前不显示 0”的问题,其实和这里的第 7 步是同一个事情的两种表现。不管是地图工具里的经纬度属性字段,还是数据可视化工具里的数值轴标签,显示成.5还是0.5都是由格式化环节决定的。输入框这一层要做的是尽早把格式统一掉,避免非法形态向后传递。同理,像 C 语言里用printf("%.2f", x)做输出格式化,或者 JS 里用toFixed(2),它们管的是“显示成几位”,和“允许输入几位”是两码事,不要指望用格式化函数来替代输入限制。

3.2 光标位置怎么算才不会跳

清洗函数写好了,接下来就是把它接到事件上,并且把光标稳住。

function applySanitize(el, opt) { const raw = el.value; const caret = el.selectionStart ?? raw.length; const cleaned = sanitizeNumber(raw, opt); if (cleaned === raw) return; // 用“光标前那一段”清洗后的长度当作新光标位置 const prefixCleaned = sanitizeNumber(raw.slice(0, caret), opt); el.value = cleaned; const pos = Math.min(prefixCleaned.length, cleaned.length); if (typeof el.setSelectionRange === 'function') { el.setSelectionRange(pos, pos); } }

这段逻辑值得逐行解释。el.selectionStart拿到的是一次输入之后浏览器给出的光标位置,它对应的是未清洗前的字符串索引。raw.slice(0, caret)截出光标左边的所有内容,对它做一次清洗,得到的长度就是“如果不发生任何非法输入,光标应该在的位置”。最后用Math.min兜一下,防止前缀清洗后长度超过整体清洗结果(比如补零的情况在整体上被截断)。

有一个场景需要说明:如果用户是在中间删字符,比如12.34删掉.变成1234,这时候 caret 是 2,前缀是12,清洗后还是12,长度为 2,光标停在 2,符合预期。如果用户选中一段文本然后替换,selectionStart和selectionEnd不一致,我们这个算法只用selectionStart,在替换场景下会把光标放到选中起点对应的位置,虽然不是最完美的,但比跳到末尾好得多。真正要做到完美,需要比较替换前后的差异,代码量会翻倍,收益有限,不划算。

3.3 粘贴、拖拽、失焦补全的兜底

input事件的好处是它是“万金油”——不管是键盘输入、粘贴、拖拽、还是语音输入,只要 DOM 值变了就会触发。所以上面这套清洗逻辑,天然覆盖了粘贴场景。用户从 Excel 里复制一列带千分位逗号的数字粘贴进来,清洗函数会把逗号全部剔掉,得到纯净的数字串。

但有两个场景input覆盖不到,需要单独处理。

第一个是失焦补全。用户输入1.之后直接点了提交,框里留着一个孤零零的小数点,解析成数字是1,但显示上不干净。最好的做法是在blur事件里做一次收尾:如果值以.结尾就删掉它;如果启用了范围限制,这时候再做 clamp;如果业务要求固定两位小数,可以在这里补.00。

function finalizeNumber(el, opt) { let v = el.value; if (v === '' || v === '-') { el.value = ''; return; } if (v.endsWith('.')) v = v.slice(0, -1); const num = Number(v); if (Number.isFinite(num)) { if (opt.min != null && num < opt.min) v = String(opt.min); if (opt.max != null && num > opt.max) v = String(opt.max); } if (v !== el.value) { el.value = v; el.dispatchEvent(new Event('input', { bubbles: true })); } }

第二个是被原生 JS 修改值的场景。比如你在代码里用el.value = xxx回填表单,这种情况不会触发input事件(这是规范定义的行为,程序性赋值不触发)——input事件是浏览器对用户操作的响应,只有真实输入或特殊合成事件才会触发它。这时候清洗逻辑不会执行,所以回填数据一定要在程序侧就保证格式合法,不能指望事件兜底。这是很多人在做表单联动时踩过的坑:A 字段改了自动算出 B 字段的值,B 字段因为是程序赋值的所以没有走清洗,结果填进去一个三位小数。

4. 落到具体框架:Vue、React、小程序和移动端

4.1 Vue3 指令封装

Vue 里用自定义指令是最舒服的,模板上一行v-number="{ decimals: 2, min: 0, max: 9999 }"就够了,不侵入业务逻辑。

import { sanitizeNumber } from './sanitizeNumber'; const DEFAULTS = { decimals: 2, allowNegative: false, min: null, max: null }; const optOf = (binding) => ({ ...DEFAULTS, ...(binding.value || {}) }); function clean(el, opt) { const raw = el.value; const caret = el.selectionStart ?? raw.length; const cleaned = sanitizeNumber(raw, opt); if (cleaned === raw) return; const prefixCleaned = sanitizeNumber(raw.slice(0, caret), opt); el.value = cleaned; const pos = Math.min(prefixCleaned.length, cleaned.length); el.setSelectionRange?.(pos, pos); el.dispatchEvent(new Event('input', { bubbles: true })); } function finalize(el, opt) { let v = el.value; if (v.endsWith('.')) v = v.slice(0, -1); const num = v === '' ? null : Number(v); if (num !== null && Number.isFinite(num)) { if (opt.min != null && num < opt.min) v = String(opt.min); if (opt.max != null && num > opt.max) v = String(opt.max); } if (v !== el.value) { el.value = v; el.dispatchEvent(new Event('input', { bubbles: true })); } } export const vNumber = { mounted(el, binding) { const opt = optOf(binding); el.__numOpt = opt; el.__onInput = (e) => { if (!e.isComposing) clean(el, el.__numOpt); }; el.__onCompEnd = () => clean(el, el.__numOpt); el.__onBlur = () => finalize(el, el.__numOpt); el.addEventListener('input', el.__onInput); el.addEventListener('compositionend', el.__onCompEnd); el.addEventListener('blur', el.__onBlur); }, updated(el, binding) { el.__numOpt = optOf(binding); }, unmounted(el) { el.removeEventListener('input', el.__onInput); el.removeEventListener('compositionend', el.__onCompEnd); el.removeEventListener('blur', el.__onBlur); }, };

有一个细节要注意:指令内部做了el.value = cleaned之后,我手动派发了一次input事件,这是为了让v-model能同步到清洗后的值。如果不派发,v-model绑定的变量里存的还是清洗前的脏数据,提交的时候就会带着非法字符走。这个手动派发在 Vue 里是安全的,因为 Vue 的v-model监听的就是原生input事件。

4.2 React 受控组件那个不刷新的坑

React 里做这件事有一个非常经典的坑,我见过很多人在这个上面卡半天。

受控组件的模式是:value来自 state,用户输入触发onChange,你在onChange里setState,React 重新渲染并把新的值写回 DOM。问题在于,当你的清洗逻辑把非法字符删掉之后,新值可能和旧值相等。比如 state 里是12,用户敲了个字母a,框里变成12a,你清洗后得到12,调用setText('12')。因为12 === 12,React 判断状态没变,不会触发重新渲染,也就不会把 DOM 里的12a改回去。结果就是屏幕上显示着12a,state 里存着12,两边不一致。

解决方案是在onChange里手动同步一次 DOM:

import { useState } from 'react'; import { sanitizeNumber } from './sanitizeNumber'; export default function NumberField({ value, onChange, decimals = 2, min, max, ...rest }) { const [text, setText] = useState(() => sanitizeNumber(String(value ?? ''), { decimals })); const handleChange = (e) => { if (e.target.isComposing) return; const raw = e.target.value; const cleaned = sanitizeNumber(raw, { decimals }); if (cleaned !== raw) { // 关键:绕过 React 的 diff,直接改 DOM e.target.value = cleaned; } setText(cleaned); onChange?.(cleaned); }; const handleBlur = (e) => { let v = e.target.value; if (v.endsWith('.')) v = v.slice(0, -1); const num = v === '' ? null : Number(v); if (num !== null && Number.isFinite(num)) { if (min != null && num < min) v = String(min); if (max != null && num > max) v = String(max); } if (v !== e.target.value) { e.target.value = v; setText(v); onChange?.(v); } }; return ( <input type="text" inputMode={decimals > 0 ? 'decimal' : 'numeric'} autoComplete="off" spellCheck={false} value={text} onChange={handleChange} onBlur={handleBlur} {...rest} /> ); }

这里用了一个本地 state 缓存文本,把格式化后的字符串向上抛给父组件,父组件拿到的一定是合法值。这么做的好处是父组件的表单校验逻辑不用关心格式问题,只需要判断空值和范围就行。

注意:直接在onChange里改e.target.value属于绕过 React 的受控机制,在 React 18 的并发渲染下需要留意。如果遇到极端情况下的显示不同步,可以把本地 state 换成useReducer并强制递增版本号,或者干脆退化成非受控组件用defaultValue+ref读取。

4.3 移动端键盘与 inputmode 的正确组合

桌面端和移动端的键盘策略完全不同。移动端上,type和inputmode决定了弹出什么键盘,这两个属性要配合使用。

属性组合iOS 表现Android 表现适用场景
type="number"数字键盘,含小数点多数含小数点,部分机型含 e不推荐,问题多
type="text" inputmode="decimal"数字键盘带小数点数字键盘带小数点推荐,小数场景
type="text" inputmode="numeric"纯数字键盘纯数字键盘推荐,整数场景
type="text" pattern="[0-9]*"数字键盘数字键盘兼容老设备
type="tel"电话键盘(含 + -)电话键盘手机号场景

我的建议是组合使用:type="text" inputmode="decimal" pattern="[0-9.]*" autoComplete="off"。其中pattern主要是给那些不认inputmode的老安卓 WebView 做兜底,autoComplete="off"是为了减少浏览器历史下拉的干扰。

关于浏览器记住历史输入这件事,这里多说一句。Chrome 和 Edge 对autoComplete="off"的支持是有选择性执行的,对于某些字段它们仍然会弹出历史记录。如果你的场景绝对不能接受下拉干扰(比如验证码、连续录入的表格),除了autocomplete="off"之外,还可以给name属性加一个随机后缀,或者把输入框放在一个<form autocomplete="off">里。这些都是治标不治本的偏方,但实测有效。

小程序是另一套体系,没有compositionend事件,但input事件的detail里也没有isComposing字段。好消息是小程序的input组件直接提供了type="digit"(带小数点的数字键盘)和type="number"(纯数字键盘),键盘层面的问题交给它就行,格式清洗还是在bindinput里做,逻辑和上面完全一致。

5. 疑难场景速查:这些坑我基本都踩过

5.1 常见问题速查表

下面这张表是我这些年积累下来最常被问到的情况,遇到问题可以直接对照查:

现象大概率原因处理方式
输入中文后框里留着汉字只判了isComposing没听compositionend两个事件都要处理,清洗函数保持幂等
删掉非法字符后光标跳到末尾直接赋value没修正光标用清洗后的前缀长度重设selectionStart
maxlength完全不起作用用了type="number"换成type="text",或自己限制长度
提交时报“请输入金额”但框里明明有值type="number"的 bad input 状态换 text 类型,或读value时判断validity.badInput
粘贴一整列数据只进去一部分清洗函数里小数点截断逻辑生效这是预期行为,如需批量录入改用多行输入
移动端只能弹全键盘没配inputmode加inputMode="decimal"
React 里值对不上清洗后值不变导致不重渲染onChange里手动同步e.target.value
Vue 里v-model拿到脏数据指令改值后没派发 input改完值dispatchEvent(new Event('input'))
用户输入1e5导致报错number 类型允许科学计数法换 text 类型,或在清洗时剔除e
输入.5显示成.5没做小数点前补零清洗函数里补0
整数位太长没限制整数部分长度单独限制整数位数,或者用范围校验兜底
复制过往内容带空格清洗时没剔除空白正则里用[^\d.]而不是[^0-9.],前者包含空格

5.2 小数精度和 toFixed 的边界

这一块是和输入框强相关的,顺便说清楚。

用户输入了0.1和0.2,你在结算页面上做加法得到0.30000000000000004,这是二进制浮点数的经典问题。输入框能做的只有字符层面的限制,它改变不了 JS 里Number的存储方式。

一般来说有两种处理策略。一种是全程用字符串传递,前端不做数值运算,把"0.1"和"0.2"原样发给后端,由后端用Decimal类型处理。这种方式最稳,适合金额场景。我记得在 Python 里做金额校验时用的就是decimal.Decimal,它的精度控制和前端的字符串处理能对上,不会出现对不上的情况。另一种是前端做运算,但结果统一用定点数方式格式化,比如所有金额运算放大 100 倍变成整数后再除回来。

toFixed有一个广为人知的坑:它不是四舍五入,而是银行家舍入(部分环境下)或者说基于二进制近似值做舍入,(1.005).toFixed(2)得到的是"1.00"而不是"1.01"。如果你的场景对舍入敏感,正确做法是先用Math.round(1.005 * 100) / 100或者用字符串手动处理,别直接依赖toFixed。

另外一个容易混淆的是输入限制和显示格式化的边界。输入限制管的是“能输入什么”,显示格式化管的是“显示成什么样”。比如财务对账要求金额一定是两位小数,用户在输入框里输5,你应该在失焦的时候显示成5.00,但用户重新聚焦编辑的时候,又应该变回5方便他改。这个“聚焦去格式、失焦加格式”的模式在金额输入里非常常见,实现方式就是在focus和blur里分别处理。

5.3 后端校验不能省

最后必须强调一件事:前端所有的输入限制都只是体验优化,没有任何安全意义。用户打开开发者工具改一下 DOM,或者直接用接口工具发请求,你的所有前端校验都会失效。服务端一定要对接收到的字段做完整的类型校验、范围校验和精度校验。

服务端校验有几个点要特别注意。第一,不要把字符串直接转成浮点数再比较,金额类的字段应该用定点数或高精度类型解析。第二,要注意NaN和Infinity的边界,很多语言在解析非法字符串时会返回特殊值而不是报错,如果不判断就会把异常数据写进数据库。第三,要校验小数位数,用户绕过前端传一个1.2345678过来,如果你的数据库字段是decimal(10,2),可能会被静默截断,导致对账差异。

我一般会在服务端写一个和前端清洗函数逻辑对称的校验函数,命名上保持一致(比如都叫sanitizeNumber),这样两边行为一致,排查问题的时候也省事。前后端逻辑不一致导致的“前端看着没问题,后端报参数错误”这类工单,我见过太多了。

5.4 一个容易被忽略的场景:多个输入框的联动

表单里经常有两个以上数值字段互相影响,比如“单价 × 数量 = 总价”,“起始金额”和“结束金额”要有大小关系。这种情况下,程序赋值引发的清洗缺失问题就暴露出来了。

正确做法是在计算逻辑里就走一遍清洗函数,而不是依赖输入框的事件。比如:

const qty = sanitizeNumber(qtyEl.value, { decimals: 0 }); const price = sanitizeNumber(priceEl.value, { decimals: 2 }); const total = (Number(qty) * Number(price)).toFixed(2); totalEl.value = total; // 程序赋值,不会再触发 input 事件

这样即便totalEl上挂了清洗逻辑没执行,值本身也是合法的。原则就是:凡是进入输入框的值,无论来源是用户还是代码,都应该保证格式合法。事件只是用户输入这条路径上的守门人,代码路径得自己守。

我在实际项目里的做法是把sanitizeNumber这个纯函数放在一个共享的 utils 目录里,所有涉及数值的地方都强制过一遍它,包括表单回填、计算赋值、从接口拿数据初始化。多写一行代码,能省掉后面一堆“为什么这个值是 1.2000000000000002”的排查时间。

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

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

立即咨询