ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

前端数字输入限制与精度处理:从numeric(14,6)到v-number指令的完整实践

2026/9/23 7:11:02 拓冰建站 浏览量
前端数字输入限制与精度处理:从numeric(14,6)到v-number指令的完整实践 在项目中碰到过一个挺经典的字段需求某个比例系数在数据库里定义成了numeric(14,6)前端对应就是一个el-input。当时我简单粗暴地以为“只能输入数字”这种限制就是加个正则的事结果从输入法到光标位置到数据传输前前后后踩了一堆坑。这篇就把这套处理方法完整拆开讲清楚数字、小数、精度三个约束分别怎么做n(14,6)这类精度限制在前端到底意味着什么以及怎么把它沉淀成一个可复用的方案。适合正在做表单、做数据录入、或者被后端字段精度逼疯的兄弟参考。1. 先想清楚numeric(14,6) 到底在限制什么1.1 8位整数、6位小数、总长14位意味着什么很多前端同学看到numeric(14,6)第一反应是“最多14位数字”这个理解对了一半。numeric(14,6)的意思是一共14位十进制数字其中小数部分占6位整数部分只能是8位。也就是说这个字段的数值范围是-99999999.999999到99999999.999999。注意小数点本身不占位数正负号也不占位数。这类精度定义在数据库里特别常见尤其是金额、比例、坐标、计量相关的字段。价格类字段常用numeric(10,2)因为只要两位小数而比例、系数、GPS坐标这类需要更高小数精度的场景就会用numeric(14,6)甚至更高。明白了这个约束前端的工作就很明确不是“随便写个数字过滤”而是要确保用户输入的值永远落在8位整数、6位小数的范围内。后端能兜底但用户提交后收到一条“字段超长”的报错体验非常糟糕。前端从源头限制住才是这个需求的真实目标。1.2 为什么直接 typenumber 行不通遇到数字输入很多人的第一反应是input typenumber在 Element Plus 里也可以给 el-input 加typenumber。但这个方案坑很多在 Chrome 里typenumber允许输入e、E、、-因为科学计数法在数值类型里是合法的。用户输入1e5el-input 拿到的 value 是空字符串因为浏览器认为这是一个无效的数值格式结果就是 v-model 绑定的变量变成了空串。输入框右侧会出现一个上下箭头spin button鼠标滚轮滚动页面时不小心滚到输入框上值会突然变化容易误操作。对负号的处理不够可控输入-还是数字不同浏览器的行为不一致。样式也不统一Firefox 和 Chrome 对 number 类型 input 的渲染差异很大。所以这个需求本质上不能靠 HTML 自带的 number 类型解决要在 JS 层面做一套完整的“输入治理”方案限制非法字符、控制小数位、控制整数位、适配输入法、处理粘贴、同步 v-model。下面就是我踩过的坑和最终方案。2. 初版方案踩进的两个坑输入法与光标跳位2.1 正则过滤看着靠谱一敲拼音就露馅最朴素的实现是这样的function sanitize(val) { return String(val) .replace(/[^\d.]/g, ) // 只保留数字和小数点 .replace(/\.{2,}/g, .) // 多个小数点合并 }然后监听 input 事件把这个函数的结果回填给 v-model。这套逻辑处理纯英文输入、纯数字输入都没问题但中文输入法一上场就崩。用户在 el-input 里用拼音输入法打汉字时会先触发compositionstart然后拼音字母会先被填进 input 的 value 里比如想打“比例”输入拼音“bili”input 的 value 会变成bili。在拼音没有上屏前这个 value 一定不合法直接触发 input 事件里的正则过滤拼音字母瞬间被清空输入法直接“失位”用户打不出来字。解决这个问题必须引入 composition 事件。compositionstart时设置一个标志位compositionend时再恢复。在组合输入期间不能对 input 的 value 做任何过滤处理否则输入法会中断这是一个非常典型的国际化的输入细节很容易被忽略。2.2 改 input.value 的代价光标被强制甩到末尾第二个坑更隐蔽。当你通过正则过滤了一个非法字符然后把处理后的字符串重新赋给 input.value 时浏览器默认的光标位置会跳到文本末尾。举个例子用户输入了123.456789想在小数点前再插入一个数字比如把123.456789改成1234.456789。如果输入到“3”和“.”之间时多输入了一个字母“a”过滤逻辑把“a”去掉重新设置 value这时候光标会从123|.456789直接跳到123.456789|。用户眼睛看着光标突然跑到末尾体验极差。所以每当你过滤了某个输入都需要手动恢复光标位置。具体做法是记录下来 input 的selectionStart和selectionEnd重新赋值后再用setSelectionRange恢复。这里有个细节过滤可能删除了光标前面的字符直接恢复原位置可能稍有偏差但数字输入场景下非法字符通常就出现在光标当前位置附近原位置恢复在绝大多数情况下是够用的。3. 稳定版实现输入过滤、组合事件、光标补偿、旁路拦截3.1 分角色的事件处理策略把需求拆开看其实要处理的事件不止 input 一个。我在最终方案里明确划分了各个事件的职责事件处理时机职责compositionstart输入法开始组合输入设置标志位暂停过滤compositionend输入法组合结束恢复过滤处理最终上屏的值input每次输入变化过滤非法字符补偿光标paste用户粘贴内容拦截剪贴板内容只保留合法值drop拖拽内容到输入框拦截拖放的文本过滤后进入blur输入框失焦做最终规范化四舍五入到指定位数、处理空值核心原则是输入过程中只做字符级过滤失焦时才做数值级规范化。为什么要这么分我在下一个章节详细说这里先看过滤函数本身的设计。3.2 过滤函数 sanitizeValue 怎么把好数值关这是整个方案的地基。它要同时处理五件事去非法字符、处理负号、只保留一个小数点、截断小数位、截断整数位。function sanitizeValue(raw, { allowNegative false, precision 6, maxInt 8 } {}) { if (raw || raw null) return let str String(raw) // 1. 只保留数字、小数点和负号 str str.replace(/[^\d.-]/g, ) // 2. 负号只允许出现在开头且只能有一个 if (!allowNegative) { str str.replace(/-/g, ) } else { const isNegative str.startsWith(-) str str.replace(/-/g, ) if (isNegative) str - str } // 3. 只保留第一个小数点后续小数点移除 const firstDot str.indexOf(.) if (firstDot ! -1) { str str.slice(0, firstDot 1) str.slice(firstDot 1).replace(/\./g, ) } // 4. 小数位超出 precision 的直接截断 const dotIndex str.indexOf(.) if (dotIndex ! -1) { const intPart str.slice(0, dotIndex) const decPart str.slice(dotIndex 1, dotIndex 1 precision) str intPart . decPart } // 5. 整数位超出 maxInt 的截断保留高位数 const negative str.startsWith(-) const unsigned negative ? str.slice(1) : str const unsignedDot unsigned.indexOf(.) const unsignedInt unsignedDot ! -1 ? unsigned.slice(0, unsignedDot) : unsigned if (unsignedInt.length maxInt) { const newInt unsignedInt.slice(0, maxInt) const rest unsignedDot ! -1 ? unsigned.slice(unsignedDot) : str (negative ? - : ) newInt rest } return str }这里第3步有个很容易搞错的细节用户输入1.2.3时过滤结果应该是1.23而不是简单地报错。第5步为什么要保留高位数因为对数值来说最高位永远是最重要的比如用户本想输入99999999.123456前面多按了个1变成199999999.123456截断到8位整数时取左8位19999999虽然丢掉了一个9但至少高位没有被破坏。3.3 粘贴、拖拽、自动填充“偷渡”进来的非法值input 事件只能处理键盘输入粘贴和拖拽是两条需要单独设防的“旁路”。粘贴场景用户在别处复制了1,234.50元或者1.234,56这样的内容直接粘进输入框。如果不拦截 paste 事件浏览器会把整段文本放进 input.value触发 input 事件后再过滤。这样不是不能用但中间态会把非法字符带进来而且如果粘贴的是带格式的富文本还可能引起奇怪的表现。更好的做法是在 paste 事件里手动处理input.addEventListener(paste, (e) { e.preventDefault() const text e.clipboardData.getData(text/plain) if (text) { document.execCommand(insertText, false, sanitizeValue(text, config)) } })用document.execCommand(insertText)而不是直接改input.value是因为 execCommand 会触发原生 input 事件v-model 能跟随更新同时也绕开了手动改 value 时的光标问题。虽然 execCommand 被标记为废弃但在这个场景下依然是兼容性最好、代价最小的方案。拖拽场景同理在drop事件里preventDefault()从e.dataTransfer.getData(text/plain)取文本过滤。自动填充浏览器密码管理器、地址自动填充比较难从源头拦截所以 blur 时的规范化也是最后一道兜底防线。4. 输入时宽松失焦时严格精度处理的正确姿势4.1 为什么不能在 input 事件里直接 toFixed(6)这是很多初版方案会踩的另一个坑小数位超出6位了那就Number(value).toFixed(6)不就行了听起来合理但实际体验非常差。用户想输入0.000001这是一个合法的 numeric(14,6) 值。但在输入过程中他先敲0再敲.再敲0输入框里出现0.0这时候如果他加了一个0变成0.00还没什么。等他一路敲到0.00000的时候如果用toFixed(6)把0.00000格式化结果是0.000000看起来正常。问题在于用户接下来想敲最后一位1但在输入事件里每次输入都会重新执行toFixed一旦输入了第7个小数位比如用户想输入0.000001但中间停顿了一下输入了0.000000这时候如果误操作多按了一个0toFixed(6)会直接把它保留为0.000000用户继续输入1时因为小数位数已满第7位就输不进去了但明明他是想输入一个合法的带最后一位的数字。更麻烦的场景是用户在小数中间插入一个数字比如已经有0.123456他想改成0.1234567这在 numeric(14,6) 下本来应该被拒绝但如果用toFixed每次输入都会被重新舍入会出现“输入过程值不断变化”的跳变感。正确做法是input 事件只做字符级截断不到万不得已不做数值级舍入。也就是小数位超过6位大不了截掉但至少保证用户输入过程的连续性。用户在输入0.1234567时第7位会被截断输入框显示0.123456不会出现数字跳动。4.2 失焦时的数值舍入与整数位截断策略字符截断是“硬切”失焦时的舍入是“软处理”。用户输入0.1234569字符截断后变成0.123456这其实已经达到精度要求了。但如果业务要求四舍五入到6位那在 blur 时就应该把0.1234569变成0.123457。这就是为什么要把数值级处理放到 blur。function normalizeOnBlur(raw, { precision 6, allowNegative false, keepZero false }) { if (raw || raw - || raw .) return const sanitized sanitizeValue(raw, { allowNegative, precision }) if (sanitized || sanitized -) return const num Number(sanitized) if (Number.isNaN(num)) return let fixed num.toFixed(precision) if (!keepZero) { fixed fixed.replace(/0$/, ).replace(/\.$/, ) } return fixed }keepZero参数看业务如果字段定义成numeric(14,6)有些后端接口要求提交“固定6位小数”的字符串那就设置keepZero: true保证1.2会变成1.200000提交。要是后端宽容前端展示也想干净一点就默认去掉末尾的0。还有一个容易被忽略的边界只输入了一个负号-就失焦了。Number(-)结果是 NaN这里要单独判断返回空字符串。同理只输入一个.也要清空。这些看似不起眼实际测试时一定会碰到。5. 千分位显示、parser 回填与提交数据的精度兜底5.1 编辑态和展示态分离告别 formatter 的光标堆输入框显示千分位在很多业务里是刚需。99999999.999999这种数字不分离位符一眼看不出量级带上千分位变成99,999,999.999999才直观。Element Plus 的 el-input 提供了formatter和parser两个属性但我的实际体验是直接在输入过程中格式化光标会不断被推到末尾中间插入数字时体验非常差。所以更可靠的方案是编辑态和展示态分离聚焦时显示原始字符串失焦时格式化。具体做法是 focus 时把 input.value 换成不带千分位的原始串blur 时先 normalize再 format。function formatWithThousand(raw) { if (!raw) return raw const negative raw.startsWith(-) const body raw.replace(/-/g, ).replace(/,/g, ) const [intPart, decPart] body.split(.) const formattedInt intPart.replace(/\B(?(\d{3})(?!\d))/g, ,) return (negative ? - : ) formattedInt (decPart ! undefined ? . decPart : ) }focus 时这样处理input.addEventListener(focus, () { const normalized normalizeOnBlur(input.value, config) input.value normalized // 光标移到末尾 const len input.value.length input.setSelectionRange(len, len) })blur 时处理input.addEventListener(blur, () { const normalized normalizeOnBlur(input.value, config) input.value formatWithThousand(normalized) input.dispatchEvent(new Event(input, { bubbles: true })) })这个方案比 formatter 属性稳定得多唯一的代价是要自己维护 focus/blur 的逻辑。5.2 用字符串传值不把浮点运算留给 JS如果前端把输入框的值直接Number()转成数字再传给后端遇到numeric(14,6)这种字段会踩到 IEEE 754 的浮点精度坑。典型例子是0.1 0.2在 JS 里等于0.30000000000000004虽然大多数接口传输时序列化会处理但如果用了parseFloat再参与前端计算很容易把脏数传给后端。我的建议是提交时保持字符串传递。el-input 的 v-model 绑定的值始终是 string过滤和规范化过程也全程用字符串处理只有在toFixed舍入时才转一次数值转完再变回字符串。字符串形式不会丢精度后端拿到numeric(14,6)字段自己能正确处理。如果前端确实需要参与计算再引入 decimal.js 这类库比如import Decimal from decimal.js const result new Decimal(inputValue).plus(otherValue).toFixed(6)不要在业务代码里直接写a b去处理金钱或者精度敏感数字。6. 沉淀成自定义指令v-number 的前端复用方案6.1 指令参数与使用方式当项目里出现了第二个、第三个数字精度字段就会发现每个页面都贴一份 sanitize 函数很蠢。把它封装成自定义指令才是最省事的做法。参数设计如下参数默认值说明precision6小数位最大位数maxInt8整数位最大位数allowNegativefalse是否允许负数keepZerofalse失焦时是否保留末尾0模板里的用法el-input v-modelform.ratio v-number{ precision: 6, maxInt: 8, allowNegative: false } placeholder请输入比例系数 /如果换一个需求比如“只能输入1-99的整数”可以给指令增加 min/max 参数在 blur 时钳制范围。这个指令本质上是通用的数字输入治理器只要参数化足够细各种类数字需求都能覆盖。6.2 完整指令代码与几个隐藏细节完整实现如下Vue 3 自定义指令const findInput (el) { if (el.tagName INPUT) return el return el.querySelector(input) } function sanitizeValue(raw, { allowNegative false, precision 6, maxInt 8 } {}) { // 逻辑见 3.2 节这里省略重复代码 } function normalizeOnBlur(raw, config) { // 逻辑见 4.2 节 } const vNumber { mounted(el, binding) { const input findInput(el) if (!input) return const config binding.value || {} let composing false const sanitizer (raw) sanitizeValue(raw, config) input.addEventListener(compositionstart, () { composing true }) input.addEventListener(compositionend, () { composing false const start input.selectionStart const end input.selectionEnd input.value sanitizer(input.value) input.setSelectionRange(start, end) input.dispatchEvent(new Event(input, { bubbles: true })) }) input.addEventListener(input, () { if (composing) return const start input.selectionStart const end input.selectionEnd const before input.value const after sanitizer(before) if (before after) return input.value after input.setSelectionRange(start, end) input.dispatchEvent(new Event(input, { bubbles: true })) }) input.addEventListener(paste, (e) { e.preventDefault() const text e.clipboardData.getData(text/plain) if (text) { document.execCommand(insertText, false, sanitizer(text)) } }) input.addEventListener(drop, (e) { e.preventDefault() const text e.dataTransfer.getData(text/plain) if (text) { document.execCommand(insertText, false, sanitizer(text)) } }) input.addEventListener(blur, () { const normalized normalizeOnBlur(input.value, config) if (normalized input.value) return input.value normalized input.dispatchEvent(new Event(input, { bubbles: true })) }) } } export default vNumber几个隐藏细节findInput的el.querySelector(input)是为了兼容 el-input 这类组件。组件根节点通常不是 input 本身而是一个包裹的 div。过滤后手动dispatchEvent(new Event(input, { bubbles: true }))是为了同步 v-model。原生 input 事件触发后Vue 才能拿到更新后的值。但要注意这个事件会再次触发 input 监听器由于前后值相同会直接 return不会死循环。compositionend 里也要做过滤和光标补偿因为拼音上屏后的值不一定合法。document.execCommand(insertText)在部分浏览器要求 input 处于聚焦状态所以在 paste/drop 事件里调用时 input 一定是聚焦的没问题。6.3 回归测试清单改完代码不慌这个指令一旦成为公共资产后续改动必须有一个固定的测试清单。我每次改完都会跑一遍场景输入期望结果非法字符过滤abc12.3.412.34小数位截断0.12345670.123456整数位截断999999999.199999999.1负号处理允许负数-abc123-123负号处理不允许负数-abc123123中文输入法拼音“a”组合期间不清空粘贴1,234.50元1234.5粘贴1.234,561.23456插入超长整数在99999999前插入119999999单独输入负号失焦-空字符串单独输入小数点失焦.空字符串blur 保留6位1.2且 keepZerotrue1.200000blur 去掉末尾01.2000001.2这套清单覆盖了键盘输入、输入法、粘贴、拖拽、边界值、失焦处理这几大类。只要改动涉及过滤逻辑就必须全部过一遍不然很容易在某条旁路上漏掉。我所经历的这个需求最后做下来最大的收获不是那几行正则代码而是意识到“表单输入限制”这件事的复杂度远超预期。如果你也是自己维护这类逻辑建议尽早把它从页面里抽出来做成指令或者组件不要在每个表单里复制粘贴。等到第10个页面开始用同一个指令你会回来感谢这个决定的。