
1.2.3 这种值是怎么进到数据库里的——这个问题我大概被问过不下十次。每次顺着链路排查到最后几乎都能追到同一个地方一个看起来人畜无害的数字输入框只写了一行input typenumber然后所有人默认它会把非法字符挡在门外。真实情况是typenumber在浏览器里更像一条建议而不是一道闸门字母 e 能进去加号减号能进去第二个小数点也能进去甚至在某些浏览器里值已经非法了但e.target.value读出来是空字符串导致你的过滤逻辑直接失灵。这篇内容围绕一个非常具体的问题展开输入框怎么限制只允许数字、限制只能有一个小数点、并且精确控制小数点后的位数。顺带把中文输入法、整段粘贴、移动端数字键盘、Vue/React 受控组件这些绕不开的坑一起说清楚。如果你正在写的是金额、数量、重量、比例、坐标这类字段或者你已经被用户输入了 1e5 导致后端报类型转换错误折磨过下面的内容基本可以直接照搬。1. typenumber 到底能挡住什么挡不住什么很多人的第一反应是我用原生 number 类型不就行了。这个想法没错但它解决的是键盘和校验提示层面的问题跟限制输入内容是两回事。搞清楚原生属性的真实边界后面选方案才不会走弯路。1.1 原生 number 类型真正放行的字符规范里typenumber允许输入的字符集合比你想象的宽数字0-9、减号-、加号、小数点.以及指数符号e和E。也就是说用户在一个typenumber的框里敲出1e5、-1.2.3、1浏览器在输入阶段并不会拦你它只是把这些内容标记为badInput。更麻烦的是取值时机。当输入处于badInput状态时input.value在 Chrome 里返回的是空字符串。于是你写if (isNaN(Number(el.value))) return这种校验看起来是拦截了非法输入实际上用户只要敲一个e整个值就变成空串你后续的el.value el.value.replace(...)清洗逻辑拿到的是一片空白光标和内容全部乱掉。我踩过最典型的一个坑是输入12.之后继续输入5中间态没问题但用户想删除小数点按了一次退格变成12再输入.得到12..这时候浏览器认为值非法value直接变成空串输入框自己清空了用户以为程序坏了。这类反馈排查起来非常费时间因为复现路径依赖具体浏览器的实现差异。所以我的结论很直接只要涉及小数点唯一位数精确控制这类需求就不要指望typenumber老老实实用typetext加inputmode把控制权拿到自己手里。1.2 step、min、max 管的是校验不是输入min、max、step这三个属性经常被误当成输入限制。它们实际的作用域是两处表单校验checkValidity()、:invalid伪类、提交时的原生提示和上下箭头微调的步长。拿input typenumber step0.01举例。用户可以自由输入1.234浏览器不会阻止只是在你调用checkValidity()时把它标记为stepMismatch。而且这个校验的规则是值必须是 step 的整数倍浮点数参与运算时还有精度问题——0.01这种步长在某些值上会出现stepMismatch的误报比如1.15在某些引擎上就因为二进制浮点表示不精确而判定失败。真正依赖它做金额校验线上会莫名其妙地拦掉合法值。maxlength在typenumber上更是完全不生效这一点知道的人不多。你写maxlength10想限制长度实测下来用户照样能输入十几个字符。要限制长度只能在清洗函数里自己按整数位数截断。1.3 inputmode 与 pattern 在移动端的实际表现inputmode是我最推荐保留的属性它只影响移动端弹出的键盘类型不参与任何校验副作用最小。inputmodenumeric弹出纯数字键盘iOS 上通常不带小数点。inputmodedecimal弹出带小数点的数字键盘输入金额、重量这类字段用它。inputmodetel电话拨号盘样式部分安卓机型上也会带小数点。pattern属性只做提交校验不拦输入而且它不参与自定义错误提示除非你手动取validationMessage。typenumber和inputmodedecimal是可以叠加的但既然我们已经决定用typetext那pattern的意义就只剩下给读屏软件和表单校验库看的语义信息实际拦截还是要靠 JS。1.4 原生属性能力对照表属性/方案拦输入拦粘贴控制小数位移动端键盘备注typenumber否否否数字键盘非法值时value可能为空串typenumber step0.01否否仅校验数字键盘浮点精度会误报typenumber min/max否否否数字键盘只在校验阶段生效maxlength否否否无对 number 类型无效pattern否否否无只参与校验inputmodedecimal否否否带小数点键盘只影响键盘JS input事件清洗是是是取决于inputmode推荐主方案这张表基本能解释为什么只写typenumber的页面线上一定会收到脏数据。选型的核心判断标准只有一条**这个字段的合法性约束是要不要拦住用户还是提交时提醒一下。**前者必须上 JS后者原生属性才够用。2. 拦截式思路keydown 与 beforeinput 的取舍拦输入最直觉的做法是监听按键判断按下的字符是不是数字不是就直接preventDefault()。这条路能用但只在桌面端、只在英文输入状态下成立一旦用户切到中文输入法或者用手机打开页面判断逻辑会大面积失效。2.1 keydown 判键为什么会在中文输入法下翻车中文输入法的输入过程是组合而不是逐键输入。用户敲1的时候拼音候选窗还没上屏keydown事件的key值往往是Process或者UnidentifiedkeyCode是 229。你在这种事件上做preventDefault()要么拦不住要么连正常的组合输入一起干掉了用户会发现输入框打不了字。移动端的表现更糟。安卓软键盘在keydown里给出的key大量情况下都是Unidentified你根本没有可判断的字符。这就是为什么单纯依赖keydown的方案在真机上测试基本都会翻车。keypress已经废弃不用考虑。keyup更晚副作用更大也不合适。2.2 beforeinput 能拿到的信息比你想的多beforeinput是目前最贴近输入发生前的原生事件现代浏览器支持度已经够用。它比keydown强的地方在于它描述的是将要发生的输入动作而不是按下的物理键。el.addEventListener(beforeinput, (e) { // e.inputType: insertText / insertFromPaste / insertCompositionText / deleteContentBackward ... // e.data: 即将插入的文本删除类事件为 null console.log(e.inputType, e.data); });inputType的取值能帮你精确区分场景insertText是普通敲键insertFromPaste是粘贴insertCompositionText是输入法组合过程中产生的文本deleteContentBackward是退格。你可以只对insertText和insertFromPaste做字符合法性判断删除操作一律放行这样就不会因为拦截逻辑错误导致用户删不掉字。要注意一点insertCompositionText在多数浏览器里是不可取消的preventDefault()无效。所以输入法组合态只能等组合结束后再清洗这也正是为什么要配合后面的compositionend处理。2.3 粘贴、拖拽、自动填充这三条旁路只监听按键的人一定会被这三条路绕过粘贴CtrlV 或者右键粘贴。keydown里 Ctrl 和 V 都是字母键相关你的字符判断逻辑很容易放行。用beforeinput的insertFromPaste类型能精准识别。拖拽把选中的文本直接拖进输入框。这条路径连keydown都不会触发但会触发input。浏览器自动填充 / 密码管理器 / 表单恢复值直接写进去不产生任何键盘事件有些场景连input事件的时机都很奇怪。我自己的处理原则是beforeinput用来做精准拦截比如第二个小数点直接不让进input事件用来做兜底清洗不管值从哪来进入输入框之后必须过一遍规则。两道防线叠起来才能覆盖全部入口。2.4 一段可以抄走的拦截代码下面这段是拦截层的实现逻辑是只处理文本插入删除放行插入内容里如果出现非法字符或第二个小数点直接拦掉。function bindNumberGuard(el, { precision 2, allowNegative false } {}) { el.addEventListener(beforeinput, (e) { if (e.inputType ! insertText e.inputType ! insertFromPaste) return; const text e.data; if (text null) return; const next insertAt(el.value, text, el.selectionStart, el.selectionEnd); if (!isValidNumberString(next, { precision, allowNegative })) { e.preventDefault(); } }); } function insertAt(value, text, start, end) { return value.slice(0, start) text value.slice(end); } function isValidNumberString(str, { precision, allowNegative }) { const re allowNegative ? new RegExp(^-?\\d*(\\.\\d{0,${precision}})?$) : new RegExp(^\\d*(\\.\\d{0,${precision}})?$); if (!re.test(str)) return false; // 单独一个小数点或负号允许作为输入中间态 if (str . || str - || str -.) return true; return true; }这套逻辑的好处是预判先拼出插入后的完整字符串再用正则判断这个结果合不合法合法才放行。比逐字符判断更贴近最终状态也不会出现用户能打出非法中间态的问题。但拦截层有个天然缺陷如果你把中间态卡得太严用户体验会很差。比如用户想输入0.5先打.你的正则不允许以小数点开头直接拦掉用户就必须先打0再打小数点多一步操作。所以我的做法是允许.5、-、1.这类中间态存在等失焦或者提交时再统一补前导零、补尾零。3. 清洗式思路input 事件加正则的完整链路拦截层解决的是不让非法字符进来清洗层解决的是不管怎么进来的出去的时候必须是合法的。两层是互补关系我实际做过的项目里清洗层是必须有的那一层拦截层反而经常被裁掉因为拦截逻辑写不好容易误伤。3.1 先写进去再修正为什么比拦截更稳清洗式的核心思路是不阻止用户输入等值变化后立刻读出来按规则修正再把修正后的值写回去。这样做的好处很实际第一兼容性最好。它只依赖input事件所有浏览器、所有输入法、所有粘贴方式都会触发不用去猜inputType的分类。第二行为可预期。用户输入什么都能看到反馈输入框内容会自己变干净比按键没反应更好理解。第三代码集中。所有规则都在一个sanitize函数里测试用例好写改规则不用动事件绑定。代价是要处理光标位置。这个后面单独说。3.2 正则拆解整数位、小数点、小数位分段管控与其写一个能匹配所有情况的大正则不如拆成几步做串行处理每一步只干一件事出了问题好定位。这是我常用的清洗顺序function sanitizeNumber(raw, { precision 2, allowNegative false } {}) { if (raw null) return ; // 1. 归一化全角转半角去掉千分位和空格 let s raw .replace(/[-]/g, (c) String.fromCharCode(c.charCodeAt(0) - 0xfee0)) .replace(/[。]/g, .) .replace(/[—]/g, -) .replace(/[,\s]/g, ) .replace(/[^\d.-]/g, ); // 2. 负号处理只允许出现在开头且只保留一个 const hasNeg allowNegative s.indexOf(-) ! -1; s s.replace(/-/g, ); if (hasNeg) s - s; // 3. 小数点只保留第一个 const firstDot s.indexOf(.); if (firstDot ! -1) { const head s.slice(0, firstDot 1); const tail s.slice(firstDot 1).replace(/\./g, ); s head tail; } // 4. 小数位截断 const dot s.indexOf(.); if (dot ! -1 precision 0) { s s.slice(0, dot 1 precision); } // 5. 只留下负号或只留下小数点时作为空值处理 if (s . || s -) s ; if (s -.) s -; return s; }第 1 步的归一化很容易被忽略但它挡掉的是最常见的脏输入全角数字用户用中文输入法敲出来的、全角句号当小数点、中文破折号当负号、从表格复制带进来的千分位逗号和空格。这些字符在肉眼上跟半角几乎一样但在Number()转换时全部变成NaN。这一步做完后面的正则才能用干净的数据源工作。第 5 步是刻意的用户删到只剩一个小数点的时候如果保留.他继续输入5得到.5很多场景下这个值提交上去后端不接受。我倾向于把孤立的.直接清空用户重新输入0.5也就多敲一个键。3.3 光标跳位问题的定位与修复清洗式方案最典型的 bug 是用户把光标放在中间插入一个字符输入框的值被改完之后光标啪地跳到末尾用户接着输入的内容跑到最后去了。根因不难理解你给el.value重新赋值浏览器默认把光标放到末尾部分浏览器会做一次尽力而为的保留但规律不稳定不能依赖。修复思路是手动算回新的光标位置算法就一句话——数清楚光标前有多少个有效字符再在新字符串里找到第 N 个有效字符的位置。function calcCaret(oldValue, oldPos, newValue) { const isValidChar (c) /[\d.-]/.test(c); // 旧串里光标之前有几个有效字符 let count 0; for (let i 0; i oldPos i oldValue.length; i) { if (isValidChar(oldValue[i])) count; } if (count 0) return 0; // 新串里找到第 count 个有效字符光标放到它后面 let seen 0; for (let i 0; i newValue.length; i) { if (isValidChar(newValue[i])) { seen; if (seen count) return i 1; } } return newValue.length; }配合事件处理el.addEventListener(input, () { const oldValue el.value; const oldPos el.selectionStart; const cleaned sanitizeNumber(oldValue, { precision: 2 }); if (cleaned ! oldValue) { el.value cleaned; const newPos calcCaret(oldValue, oldPos, cleaned); el.setSelectionRange(newPos, newPos); } });注意calcCaret的入参要用清洗前的值和光标位置。这里有个细节如果用户是选中一段内容然后粘贴selectionStart ! selectionEnd上面的算法会有一点偏差因为选择区被替换掉了但实测下来偏差通常在一个字符以内用户几乎感知不到。如果你要求极致精确可以把selectionEnd也传进去按选择区之前的有效字符数来算。提示直接给el.value赋值会丢失输入法的组合状态所以清洗逻辑一定要放在compositionend之后或者用isComposing判断跳过否则用户在拼音组合过程中会看到输入框内容被反复改写。3.4 输入中途该截断还是该补零这是我在实际项目里反复被问到的问题。用户输入3.14产品要求保留两位小数那用户想输入3.1415的时候应该怎么样我的答案是输入过程中做截断失焦时做补零提交前做最终校验。输入过程中如果做四舍五入用户打到3.145你在input里把它变成3.15用户接着打9想变成3.1459结果发现变成了3.159——体验是灾难性的。而截断是多出来的不进去用户能立刻感知到边界在哪心理模型清晰。补零只在失焦时做比如3.1补成3.10。这个动作必须放在失焦因为用户在输入过程中3.1是合法中间态你补成3.10之后光标位置会变化用户继续输入容易出现意外。四舍五入只在两种情况下做一是失焦时用户明确输入了超出精度的位数但这时候其实已经被截断了所以基本不会触发二是提交前对后端传来的历史数据做展示格式化。业务上如果真的需要用户输入 3.145 保存为 3.15那应该在失焦时提示已按两位小数处理而不是悄悄改值。4. 小数位数控制precision、截断与失焦格式化小数位控制是这类需求里最容易做过头的地方。很多人一上来就想我要严格限制两位小数然后在输入过程中做各种补零、四舍五入最后把输入框做成一个反人类的东西。合理的做法是把输入态和展示态分开两者用不同的规则。4.1 用 step 配合 checkValidity 做校验的现实局限前面提过step的浮点精度问题这里补充一个更实际的理由step的校验语义是值是 step 的整数倍而不是小数位不超过 N 位。这两件事在大多数情况下等价但在浮点数上不等价。举个例子step0.1的情况下0.3应该是合法的但因为0.3 / 0.1在二进制浮点里等于2.9999999999999996某些浏览器引擎会判定它stepMismatch。这类问题在不同浏览器、不同版本上表现不一致排查成本极高。我现在的做法是彻底放弃step做精度校验改成在清洗函数里按字符串长度截断在提交前用正则做最终校验const MONEY_RE /^-?(0|[1-9]\d*)(\.\d{1,2})?$/; function validateMoney(str) { if (!MONEY_RE.test(str)) return { ok: false, msg: 请输入最多两位小数的数字 }; return { ok: true }; }正则处理字符串完全避开浮点运算结果稳定可控。唯一要注意的是正则里用的是\d{1,2}而不是\d{0,2}——1.这种末尾带小数点的值不应该通过最终校验但它必须被允许作为输入中间态。输入态用宽松正则提交态用严格正则这是两个不同的约束。4.2 三种精度策略截断、补零、四舍五入把三种策略放在一起对比会更清楚策略触发时机优点风险截断输入过程中反馈即时不加戏用户输入的精度被默默丢弃补零失焦时展示统一如3.1→3.10光标位置会变不能放输入态四舍五入失焦或提交前符合财务直觉与用户看到的中间值不一致我见过最糟糕的组合是输入过程中四舍五入 实时补零用户每敲一个键输入框内容都大变样光标到处飞最后只能靠输入框坏了来定性。实际项目里我的默认组合是输入态截断、失焦补零、提交前按业务要求做四舍五入并明确告知用户。这三条规则各管一段互不干扰。4.3 值用字符串存、金额用整数存前端用字符串存值是最省事的方案。原因有三第一输入中间态1.、-、空字符串用字符串能原样表达用number类型存的话1.会变成1用户小数点白敲了。第二Number()等于0这个隐式转换非常容易埋雷。用户清空输入框你以为拿到null实际拿到0提交上去变成金额为 0。第三浮点运算不参与输入过程0.1 0.2 ! 0.3这类问题就不会出现在你意想不到的地方。至于金额本身如果业务精度要求高比如对账、结算存储层用分这个整数单位提交时做一次Math.round(parseFloat(str) * 100)比一路用浮点数算下来安全得多。这个转换放在提交前做输入过程中始终是字符串。4.4 失焦格式化与再次聚焦的还原失焦格式化的典型实现el.addEventListener(blur, () { const s el.value; if (s || s - || s .) { el.value ; return; } // 补零到指定小数位 const dot s.indexOf(.); if (dot -1) { el.value precision 0 ? s . 0.repeat(precision) : s; } else { const decimals s.length - dot - 1; if (decimals precision) { el.value s 0.repeat(precision - decimals); } } });再次聚焦的时候要不要把它还原成3.1我的做法是不还原。用户看到3.10再聚焦光标放在末尾继续输入变成3.105其实也顺。如果强行还原成3.1用户反而会疑惑我刚刚的 0 呢。但有个场景要特殊处理只读展示和可编辑状态共用同一个元素的时候格式化后的3.10在只读态合适编辑态会干扰输入。这种情况我一般拆成两个元素或者用focus事件把格式化后的值还原为数值字符串。取舍标准是这条数据是给人看的还是给人改的两者混在一起怎么选都会别扭。5. 输入法、全角字符与移动端键盘的坑前面几节的方案在桌面端英文输入下已经能跑通但真实用户的行为远比这复杂。这一节讲的是那些只在真机上、只在特定输入法下才出现的问题也是我在测试阶段花时间最多的地方。5.1 compositionstart 与 compositionend 的配合中文输入法的组合过程必须单独处理。核心原则是组合期间不清洗、不拦截组合结束后统一清洗。let composing false; el.addEventListener(compositionstart, () { composing true; }); el.addEventListener(compositionend, () { composing false; // 组合结束跑一次完整清洗 const cleaned sanitizeNumber(el.value, { precision: 2 }); if (cleaned ! el.value) { const pos calcCaret(el.value, el.selectionStart, cleaned); el.value cleaned; el.setSelectionRange(pos, pos); } }); el.addEventListener(input, () { if (composing) return; // 组合中不处理 // ...常规清洗 });为什么组合期间不能清洗因为组合过程中输入框里显示的是拼音字母比如shi如果你这时候跑清洗逻辑字母被当成非法字符删掉用户的拼音直接消失输入法状态也会错乱最终表现为打不了字。compositionend之后输入法会把最终的中文或者符号上屏比如用户切到中文标点可能上屏的是全角数字或者全角句号。这时候再走一遍归一化和清洗正好把全角字符转成半角一举两得。注意compositionend在某些浏览器上的触发顺序在input之后所以清洗逻辑不能只写在input里。两个地方都要覆盖用composing标志保证不重复执行导致光标抖动。5.2 全角数字和全角小数点的归一化全角字符是清洗逻辑里最容易被漏掉的部分。用户用中文输入法打数字的时候很容易打出全角数字。肉眼上跟半角几乎一样但Number()是NaNparseFloat也一样。等你发现的时候数据已经存进去了。归一化的映射关系记住这几条就够全角字符半角说明-0-9Unicode 相差 0xFEE0。.全角句号常被当小数点—-全角减号、破折号、去掉千分位或分隔符全角空格去掉肉眼不可见归一化的实现用charCodeAt减0xfee0最简洁的码点是0xFF10减去0xFEE0正好得到0x30也就是字符0。这个技巧比维护一张映射表更省事覆盖全部十个数字。5.3 从表格复制带千分位的内容从 Excel 或者网页表格里复制一个数字粘贴进来通常会带三种东西千分位逗号、前后空格、不可见的零宽字符。前两种用常规清洗能处理零宽字符\u200b、\ufeff就得专门加一条替换规则。s s.replace(/[\u200b-\u200f\ufeff]/g, );判断规则上千分位逗号我是直接删掉而不是解析。因为1,234和1,23在字符层面无法区分哪个是千分位哪个是用户乱输的直接删掉逗号得到1234和123虽然第二种情况结果不理想但至少是合法数字比报错好。还有一个场景是从表格复制一整列粘贴进来是1\n2\n3。这种多行内容粘到单行输入框里换行符会被浏览器转成空格或者直接去掉。我一般会在清洗里主动把\n、\r、\t全部删掉避免出现隐藏字符导致Number()返回NaN。5.4 移动端键盘上有没有小数点这件事这是个纯体验问题但影响很大iOS 的inputmodenumeric键盘没有小数点安卓的部分机型inputmodenumeric也不带小数点。用户要输入3.5发现键盘上找不到小数点只能切到符号键盘去点体验直接掉一个档次。解决办法就是前面提的inputmodedecimal。这个值在 iOS 和主流安卓浏览器上都会弹出带小数点的数字键盘。另外要提一句typenumber在移动端的另一个问题部分安卓机型的数字键盘只有数字连小数点都没有而且typenumber在某些定制系统上会触发输入框的设备记忆——用户之前输入过的数字会被系统记下来下次点击时弹出建议列表看起来像输入框里自动冒出一堆历史数字。这个现象在桌面浏览器的自动填充里也常见解决方式是加autocompleteoff并给输入框加一个语义化的name避免浏览器把它当成信用卡号或者电话号码这类敏感字段来记忆。6. Vue 与 React 里的受控输入怎么落地框架环境下的问题会更微妙一些因为值的更新走的是框架的数据流el.value的直接赋值可能会和框架的状态同步机制打架。这一段分别说 Vue 和 React 的坑以及组件参数怎么设计。6.1 Vue 中 v-model 与手动回写 value 的冲突Vue 的v-model本质上绑定的是:value加input。当你在input里修改了modelValue或者直接改el.value会出现两种情况第一种你只改了el.value没改数据下次组件重新渲染时值会被数据覆盖回去表现为输入框内容闪一下又变回来了。第二种你改了数据但没同步el.valueVue 的异步更新机制下DOM 的更新时机在下一个 tick如果你紧接着读取el.value拿到的还是旧值。我处理这类问题的写法是在input事件里先算清洗结果如果结果和当前值不同就把清洗结果 emit 出去然后在nextTick里处理光标function onInput(e) { const raw e.target.value; const oldPos e.target.selectionStart; const cleaned sanitizeNumber(raw, { precision: props.precision }); if (cleaned ! raw) { emit(update:modelValue, cleaned); nextTick(() { const pos calcCaret(raw, oldPos, cleaned); e.target.setSelectionRange(pos, pos); }); } else { emit(update:modelValue, raw); } }关键点是nextTick。Vue 更新 DOM 是异步批量的直接在当前 tick 调setSelectionRange可能作用在一个即将被覆盖的值上光标会闪。放在nextTick里DOM 已经和cleaned对齐再设光标就稳了。6.2 React 中 setState 之后光标会跳到末尾React 受控组件的经典问题是onChange里setState之后React 会把value重新写回 DOM即使新旧值完全相同也会把光标顶到末尾。这个行为在 React 16 之后因为合成事件的差异有所变化但受控输入里仍然普遍存在。稳妥的处理方式是用useLayoutEffect或者useRef记录待恢复的光标位置const inputRef useRef(null); const caretRef useRef(null); const handleChange (e) { const raw e.target.value; const oldPos e.target.selectionStart; const cleaned sanitizeNumber(raw, { precision }); if (cleaned ! raw) { caretRef.current calcCaret(raw, oldPos, cleaned); } setValue(cleaned); }; useLayoutEffect(() { if (caretRef.current ! null inputRef.current) { inputRef.current.setSelectionRange(caretRef.current, caretRef.current); caretRef.current null; } }, [value]);用useLayoutEffect而不是useEffect是因为它执行在浏览器绘制之前光标位置不会出现一帧的跳动。这个细节在快速输入的时候体感很明显用useEffect的话能肉眼看到光标跳一下再跳回来。6.3 一个通用组件的参数设计把上面所有规则收拢一个可复用的数字输入组件大概需要这几个参数参数类型默认值作用precisionnumber2小数点后最大位数minnumber-Infinity最小值失焦时校验maxnumberInfinity最大值失焦时校验allowNegativebooleanfalse是否允许负号integerDigitsnumber15整数部分最大位数formatOnBlurbooleantrue失焦是否补零placeholderstring占位提示参数设计的经验就一条**能通过precision推导出来的东西不要做成参数。**比如是否允许小数完全等价于precision 0单独开一个allowDecimal参数只会带来组合状态的爆炸——allowDecimalfalse且precision2的时候该听谁的这种参数冲突后期维护起来非常痛苦。integerDigits这个参数看着多余其实很有用。它能防住用户按住数字键不放导致的超长字符串也能在清洗函数里顺手截断避免整数部分超出后端的字段长度限制。默认给 15 是因为 JS 的Number.MAX_SAFE_INTEGER是 16 位留一位余量。6.4 提交前的类型转换与后端兜底前端再严密提交前也必须做一次类型转换和校验。原因很直接你传给后端的应该是number或者对应的数值类型而不是用户输入的字符串。传字符串过去遇到强类型反序列化的后端比如 Java 的RequestBody绑定到BigDecimal字段会直接报类型不匹配的错误日志里出现类似反序列化失败字段缺失或类型不正确这类信息排查起来还得前后端一起对一遍。转换的时候注意这几个坑function toSubmitValue(str, { precision }) { if (str || str - || str .) return null; const n Number(str); if (!Number.isFinite(n)) return null; // 按精度做一次四舍五入避免浮点尾巴 return Number(n.toFixed(precision)); }toFixed会返回字符串套一层Number是为了让 JSON 序列化之后是数字而不是3.15。Number.isFinite能同时挡掉NaN、Infinity、-Infinity三种情况比isNaN更严格——isNaN(null)是falseisNaN()也是false这两个隐式转换很容易放过空值。后端的兜底同样不能省。我在项目里的做法是字段上用数值类型声明加范围注解接口层记录一次前端传来的原始字符串用于排查。前端拦截是体验优化后端校验是数据底线两者不能互相替代。7. 上线后的漏网输入清单规则写得再全上线之后还是会有意外的输入。下面这张表是我实际遇到过的问题和对应的修复方式排查时可以直接按现象 → 原因的顺序对照。现象根本原因修复方式出现1.2.3只做了按键拦截粘贴路径未覆盖补input事件清洗出现1e5使用了typenumber改用typetext 清洗输入框自己清空badInput状态下value为空串放弃typenumber中文输入法打不了字组合期间做了拦截用composing标志跳过出现全角数字未做归一化补充全角转半角粘贴后光标跳到末尾重新赋值后未恢复光标用calcCaret恢复失焦后值从3.1变成3.10再聚焦输入异常输入态做了格式化格式化只在失焦执行提交后报类型不匹配传了字符串而非数值提交前做Number转换滚动页面时值被改了数字输入框响应滚轮阻止wheel默认行为值莫名其妙变成0空字符串被Number()转换空值显式返回null最后一条滚轮改值值得单独说一句。带上下箭头的数字输入框鼠标悬停在上面滚动页面时值会被滚轮改变这在表单页面上是非常危险的静默错误。解决方式是监听wheel事件并preventDefault或者在focus时主动blurel.addEventListener(wheel, (e) e.preventDefault(), { passive: false });这个问题的隐蔽之处在于用户滚页面的时候根本不会注意到输入框里的数字变了等到提交才发现金额不对。我在一个结算页面里遇到过用户反馈金额自己变成了负数查了半天才定位到滚轮。还有一个容易忽略的入口是浏览器记住的表单历史。用户在同一个name或者同一个id的输入框里输入过的内容下次点击时会弹出一个下拉建议列表。这个列表里的值可能来自很久以前的版本规则和现在不一样选中之后就可能把旧的脏数据带进来。给输入框加autocompleteoff能缓解但各浏览器对它的尊重程度不一最彻底的还是靠清洗层兜底——不管值从哪来进到输入框里就得过一次规则这条原则能覆盖绝大多数意外情况。我在实际项目里的体会是数字输入框这个需求看起来小但它牵扯的东西一点不少原生属性的语义边界、输入法的事件模型、光标位置的算法、框架的更新时机任何一环没考虑到都会变成线上问题。如果只能记住一条那就是别信typenumber用typetext配上inputmodedecimal把清洗逻辑老老实实写在一个函数里输入态宽松、失焦态收敛、提交态严格三态分开处理。这套组合我跟过几个项目下来真机上基本没有翻过车。