数字输入框只允许数字和小数点:从 type=number 到正则清洗
2026/9/18 8:11:57 网站建设 项目流程

"1.2.3 这种值是怎么进到数据库里的?"——这个问题我大概被问过不下十次。每次顺着链路排查到最后,几乎都能追到同一个地方:一个看起来人畜无害的数字输入框,只写了一行<input type="number">,然后所有人默认它会把非法字符挡在门外。真实情况是,type="number"在浏览器里更像一条"建议"而不是一道"闸门":字母 e 能进去,加号减号能进去,第二个小数点也能进去,甚至在某些浏览器里值已经非法了,但e.target.value读出来是空字符串,导致你的过滤逻辑直接失灵。

这篇内容围绕一个非常具体的问题展开:输入框怎么限制只允许数字、限制只能有一个小数点、并且精确控制小数点后的位数。顺带把中文输入法、整段粘贴、移动端数字键盘、Vue/React 受控组件这些绕不开的坑一起说清楚。如果你正在写的是金额、数量、重量、比例、坐标这类字段,或者你已经被"用户输入了 1e5 导致后端报类型转换错误"折磨过,下面的内容基本可以直接照搬。

1. type="number" 到底能挡住什么,挡不住什么

很多人的第一反应是"我用原生 number 类型不就行了"。这个想法没错,但它解决的是键盘和校验提示层面的问题,跟"限制输入内容"是两回事。搞清楚原生属性的真实边界,后面选方案才不会走弯路。

1.1 原生 number 类型真正放行的字符

规范里type="number"允许输入的字符集合比你想象的宽:数字0-9、减号-、加号+、小数点.,以及指数符号eE。也就是说,用户在一个type="number"的框里敲出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直接变成空串,输入框"自己清空了",用户以为程序坏了。这类反馈排查起来非常费时间,因为复现路径依赖具体浏览器的实现差异。

所以我的结论很直接:只要涉及"小数点唯一""位数精确控制"这类需求,就不要指望type="number",老老实实用type="text"inputmode,把控制权拿到自己手里。

1.2 step、min、max 管的是校验,不是输入

minmaxstep这三个属性经常被误当成输入限制。它们实际的作用域是两处:表单校验(checkValidity():invalid伪类、提交时的原生提示)和上下箭头微调的步长。

<input type="number" step="0.01">举例。用户可以自由输入1.234,浏览器不会阻止,只是在你调用checkValidity()时把它标记为stepMismatch。而且这个校验的规则是"值必须是 step 的整数倍",浮点数参与运算时还有精度问题——0.01这种步长在某些值上会出现stepMismatch的误报,比如1.15在某些引擎上就因为二进制浮点表示不精确而判定失败。真正依赖它做金额校验,线上会莫名其妙地拦掉合法值。

maxlengthtype="number"上更是完全不生效,这一点知道的人不多。你写maxlength="10"想限制长度,实测下来用户照样能输入十几个字符。要限制长度,只能在清洗函数里自己按整数位数截断。

1.3 inputmode 与 pattern 在移动端的实际表现

inputmode是我最推荐保留的属性,它只影响移动端弹出的键盘类型,不参与任何校验,副作用最小。

  • inputmode="numeric":弹出纯数字键盘,iOS 上通常不带小数点。
  • inputmode="decimal":弹出带小数点的数字键盘,输入金额、重量这类字段用它。
  • inputmode="tel":电话拨号盘样式,部分安卓机型上也会带小数点。

pattern属性只做提交校验,不拦输入,而且它不参与自定义错误提示(除非你手动取validationMessage)。type="number"inputmode="decimal"是可以叠加的,但既然我们已经决定用type="text",那pattern的意义就只剩下"给读屏软件和表单校验库看的语义信息",实际拦截还是要靠 JS。

1.4 原生属性能力对照表

属性/方案拦输入拦粘贴控制小数位移动端键盘备注
type="number"数字键盘非法值时value可能为空串
type="number" step="0.01"仅校验数字键盘浮点精度会误报
type="number" min/max数字键盘只在校验阶段生效
maxlength对 number 类型无效
pattern只参与校验
inputmode="decimal"带小数点键盘只影响键盘
JS +input事件清洗取决于inputmode推荐主方案

这张表基本能解释为什么"只写type="number""的页面,线上一定会收到脏数据。选型的核心判断标准只有一条:**这个字段的合法性约束是"要不要拦住用户",还是"提交时提醒一下"。**前者必须上 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是退格。你可以只对insertTextinsertFromPaste做字符合法性判断,删除操作一律放行,这样就不会因为拦截逻辑错误导致用户"删不掉字"。

要注意一点:insertCompositionText在多数浏览器里是不可取消的,preventDefault()无效。所以输入法组合态只能等组合结束后再清洗,这也正是为什么要配合后面的compositionend处理。

2.3 粘贴、拖拽、自动填充这三条旁路

只监听按键的人,一定会被这三条路绕过:

  • 粘贴:Ctrl+V 或者右键粘贴。keydown里 Ctrl 和 V 都是字母键相关,你的字符判断逻辑很容易放行。用beforeinputinsertFromPaste类型能精准识别。
  • 拖拽:把选中的文本直接拖进输入框。这条路径连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(/[0-9]/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 步的归一化很容易被忽略,但它挡掉的是最常见的脏输入:全角数字(用户用中文输入法敲出来的123)、全角句号当小数点、中文破折号当负号、从表格复制带进来的千分位逗号和空格。这些字符在肉眼上跟半角几乎一样,但在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 位"。这两件事在大多数情况下等价,但在浮点数上不等价。

举个例子,step="0.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.13.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之后,输入法会把最终的中文或者符号上屏,比如用户切到中文标点,可能上屏的是全角数字123或者全角句号。这时候再走一遍归一化和清洗,正好把全角字符转成半角,一举两得。

注意:compositionend在某些浏览器上的触发顺序在input之后,所以清洗逻辑不能只写在input里。两个地方都要覆盖,用composing标志保证不重复执行导致光标抖动。

5.2 全角数字和全角小数点的归一化

全角字符是清洗逻辑里最容易被漏掉的部分。用户用中文输入法打数字的时候,很容易打出全角数字:123.45。肉眼上跟半角几乎一样,但Number('123')NaNparseFloat也一样。等你发现的时候,数据已经存进去了。

归一化的映射关系记住这几条就够:

全角字符半角说明
0-90-9Unicode 相差 0xFEE0
.全角句号常被当小数点
-全角减号、破折号
去掉千分位或分隔符
全角空格去掉肉眼不可见

归一化的实现用charCodeAt0xfee0最简洁,的码点是0xFF10,减去0xFEE0正好得到0x30,也就是字符0。这个技巧比维护一张映射表更省事,覆盖全部十个数字。

5.3 从表格复制带千分位的内容

从 Excel 或者网页表格里复制一个数字,粘贴进来通常会带三种东西:千分位逗号、前后空格、不可见的零宽字符。前两种用常规清洗能处理,零宽字符(\u200b\ufeff)就得专门加一条替换规则。

s = s.replace(/[\u200b-\u200f\ufeff]/g, '');

判断规则上,千分位逗号我是直接删掉而不是解析。因为1,2341,23在字符层面无法区分哪个是千分位哪个是用户乱输的,直接删掉逗号得到1234123,虽然第二种情况结果不理想,但至少是合法数字,比报错好。

还有一个场景是从表格复制一整列,粘贴进来是1\n2\n3。这种多行内容粘到单行输入框里,换行符会被浏览器转成空格或者直接去掉。我一般会在清洗里主动把\n\r\t全部删掉,避免出现隐藏字符导致Number()返回NaN

5.4 移动端键盘上有没有小数点这件事

这是个纯体验问题,但影响很大:iOS 的inputmode="numeric"键盘没有小数点,安卓的部分机型inputmode="numeric"也不带小数点。用户要输入3.5,发现键盘上找不到小数点,只能切到符号键盘去点,体验直接掉一个档次。

解决办法就是前面提的:inputmode="decimal"。这个值在 iOS 和主流安卓浏览器上都会弹出带小数点的数字键盘。

另外要提一句type="number"在移动端的另一个问题:部分安卓机型的数字键盘只有数字,连小数点都没有,而且type="number"在某些定制系统上会触发输入框的"设备记忆"——用户之前输入过的数字会被系统记下来,下次点击时弹出建议列表,看起来像输入框里"自动冒出一堆历史数字"。这个现象在桌面浏览器的自动填充里也常见,解决方式是加autocomplete="off",并给输入框加一个语义化的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.value,Vue 的异步更新机制下,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 受控组件的经典问题是:onChangesetState之后,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参数只会带来组合状态的爆炸——allowDecimal=falseprecision=2的时候该听谁的?这种参数冲突后期维护起来非常痛苦。

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能同时挡掉NaNInfinity-Infinity三种情况,比isNaN更严格——isNaN(null)falseisNaN('')也是false,这两个隐式转换很容易放过空值。

后端的兜底同样不能省。我在项目里的做法是:字段上用数值类型声明,加范围注解,接口层记录一次"前端传来的原始字符串"用于排查。前端拦截是体验优化,后端校验是数据底线,两者不能互相替代。

7. 上线后的漏网输入清单

规则写得再全,上线之后还是会有意外的输入。下面这张表是我实际遇到过的问题和对应的修复方式,排查时可以直接按"现象 → 原因"的顺序对照。

现象根本原因修复方式
出现1.2.3只做了按键拦截,粘贴路径未覆盖input事件清洗
出现1e5使用了type="number"改用type="text"+ 清洗
输入框自己清空badInput状态下value为空串放弃type="number"
中文输入法打不了字组合期间做了拦截composing标志跳过
出现全角数字未做归一化补充全角转半角
粘贴后光标跳到末尾重新赋值后未恢复光标calcCaret恢复
失焦后值从3.1变成3.10再聚焦输入异常输入态做了格式化格式化只在失焦执行
提交后报类型不匹配传了字符串而非数值提交前做Number转换
滚动页面时值被改了数字输入框响应滚轮阻止wheel默认行为
值莫名其妙变成0空字符串被Number()转换空值显式返回null

最后一条"滚轮改值"值得单独说一句。带上下箭头的数字输入框,鼠标悬停在上面滚动页面时,值会被滚轮改变,这在表单页面上是非常危险的静默错误。解决方式是监听wheel事件并preventDefault,或者在focus时主动blur

el.addEventListener('wheel', (e) => e.preventDefault(), { passive: false });

这个问题的隐蔽之处在于,用户滚页面的时候根本不会注意到输入框里的数字变了,等到提交才发现金额不对。我在一个结算页面里遇到过,用户反馈"金额自己变成了负数",查了半天才定位到滚轮。

还有一个容易忽略的入口是浏览器记住的表单历史。用户在同一个name或者同一个id的输入框里输入过的内容,下次点击时会弹出一个下拉建议列表。这个列表里的值可能来自很久以前的版本,规则和现在不一样,选中之后就可能把旧的脏数据带进来。给输入框加autocomplete="off"能缓解,但各浏览器对它的尊重程度不一,最彻底的还是靠清洗层兜底——不管值从哪来,进到输入框里就得过一次规则,这条原则能覆盖绝大多数意外情况。

我在实际项目里的体会是,数字输入框这个需求看起来小,但它牵扯的东西一点不少:原生属性的语义边界、输入法的事件模型、光标位置的算法、框架的更新时机,任何一环没考虑到都会变成线上问题。如果只能记住一条,那就是别信type="number",用type="text"配上inputmode="decimal",把清洗逻辑老老实实写在一个函数里,输入态宽松、失焦态收敛、提交态严格,三态分开处理。这套组合我跟过几个项目下来,真机上基本没有翻过车。

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

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

立即咨询