在项目中碰到过一个挺经典的字段需求:某个比例系数在数据库里定义成了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 为什么直接 type="number" 行不通
遇到数字输入,很多人的第一反应是<input type="number">,在 Element Plus 里也可以给 el-input 加type="number"。但这个方案坑很多:
- 在 Chrome 里,
type="number"允许输入e、E、+、-,因为科学计数法在数值类型里是合法的。用户输入1e5,el-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,这时候如果误操作多按了一个0,toFixed(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 函数很蠢。把它封装成自定义指令才是最省事的做法。
参数设计如下:
| 参数 | 默认值 | 说明 |
|---|---|---|
| precision | 6 | 小数位最大位数 |
| maxInt | 8 | 整数位最大位数 |
| allowNegative | false | 是否允许负数 |
| keepZero | false | 失焦时是否保留末尾0 |
模板里的用法:
<el-input v-model="form.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.4 | 12.34 |
| 小数位截断 | 0.1234567 | 0.123456 |
| 整数位截断 | 999999999.1 | 99999999.1 |
| 负号处理(允许负数) | -abc123 | -123 |
| 负号处理(不允许负数) | -abc123 | 123 |
| 中文输入法 | 拼音“a” | 组合期间不清空 |
| 粘贴 | 1,234.50元 | 1234.5 |
| 粘贴 | 1.234,56 | 1.23456 |
| 插入超长整数 | 在99999999前插入1 | 19999999 |
| 单独输入负号失焦 | - | 空字符串 |
| 单独输入小数点失焦 | . | 空字符串 |
| blur 保留6位 | 1.2且 keepZero=true | 1.200000 |
| blur 去掉末尾0 | 1.200000 | 1.2 |
这套清单覆盖了键盘输入、输入法、粘贴、拖拽、边界值、失焦处理这几大类。只要改动涉及过滤逻辑,就必须全部过一遍,不然很容易在某条旁路上漏掉。
我所经历的这个需求,最后做下来最大的收获不是那几行正则代码,而是意识到“表单输入限制”这件事的复杂度远超预期。如果你也是自己维护这类逻辑,建议尽早把它从页面里抽出来做成指令或者组件,不要在每个表单里复制粘贴。等到第10个页面开始用同一个指令,你会回来感谢这个决定的。