JavaScript数字正则实战:整数、小数、科学计数法匹配与校验

📅 发布时间:2026/10/11 4:30:16
JavaScript数字正则实战:整数、小数、科学计数法匹配与校验
1. 先想清楚你要匹配的“哪种数字”——写正则前的三个决定正则表达式匹配数字看着是个入门级需求但实际写起来特别容易出现“改完一个 bug 又冒出另一个 bug”的情况。很多人上来就写/\d/然后拿它去校验用户输入结果发现负数、小数、空字符串全都不符合预期。这个锅不怪正则怪我们在写正则之前没有先定义清楚到底什么算“数字”。先说一个最容易被忽略的点字符串里的数字和数学意义上的数字不是一回事。字符串123、abc123、1.5、-0.00、1e3都包含数字信息但它们在正则里的形态完全不同。正则没有内置的“数字”概念它只有字符序列的规则匹配能力所以我们必须先想清楚自己的需求属于下面哪一种场景场景类型典型需求是否允许负号是否允许小数是否允许前导零是否允许指数表单整数输入校验年龄、数量、编号通常不允许不允许通常不允许不允许表单小数输入校验价格、得分、利率有时允许允许允许通常不允许从文本中提取数字日志解析、爬虫清洗视情况视情况视情况视情况金额校验支付、财务可能允许固定小数位不允许不允许把这个表先列出来是因为很多生产事故就是“拿着场景 A 的正则去做场景 B 的校验”。比如我用^\d$去验证 HTML 表单里的价格输入用户填了12.5就直接被拦截了反过来用/\.\d/去提取日志里的版本号结果把v1.2.3里的.2.3也提出来了。第二个决定是你要的是“全字匹配”还是“包含匹配”。全字匹配要求整个字符串就是一个数字适合表单校验。这种情况必须用^和$把正则两端锁住否则abc123会被\d命中中间那段。包含匹配从一段混合文本里把数字找出来适合爬虫和信息提取。这种情况不能随便加^$反而要依赖\b或者前后字符的负向断言来划清边界。第三个决定是你用的是 JavaScript那要特别注意正则对象的全局标志g带来的lastIndex副作用。这个问题往下会单独用一节讲但它确实值得在动手之前就敲个警钟。2. 整数正则的三种写法——从“能跑”到“严谨”整数是数字匹配里最简单的子集但简单不代表随便写。我们先从最基础的版本开始一步一坑地进化到能上生产的写法。2.1 最基础的版本^\d$const integerRegex /^\d$/; console.log(integerRegex.test(123)); // true console.log(integerRegex.test(0)); // true console.log(integerRegex.test(0123)); // true注意这里 console.log(integerRegex.test(-123)); // false console.log(integerRegex.test(1.5)); // false console.log(integerRegex.test()); // false这个版本只解决了一个问题整个字符串由一位或多位数字组成。\d在 JavaScript 里等价于[0-9]对 ASCII 数字完全够用不需要额外考虑 Unicode 数字字符这一条后面不再重复。它为什么不能直接上生产两个原因。第一0123这种带前导零的字符串被判定为合法。在很多场景下这不是问题比如读取一段编码字符串但如果你在做一个计数器输入框用户填007往往会引发后续业务逻辑上的类型转换错误。第二负号完全没有处理如果业务允许输入-5这个正则直接误判。2.2 支持正负号、剔除前导零的版本const strictIntegerRegex /^[-]?([1-9]\d*|0)$/; console.log(strictIntegerRegex.test(123)); // true console.log(strictIntegerRegex.test(0)); // true console.log(strictIntegerRegex.test(123)); // true console.log(strictIntegerRegex.test(-123)); // true console.log(strictIntegerRegex.test(0123)); // false console.log(strictIntegerRegex.test(00)); // false拆开看这个正则的三块^[-]?表示开头可以有一个正号或负号也可以没有。([1-9]\d*|0)是整个表达式的关键。[1-9]\d*表示最高位是 1 到 9 的数字后面可以跟任意个数字这样0123就被挡在门外了|0单独把单独的零放进白名单。不能用[0-9]\d*替代否则又回到前导零的问题。很多人在这一步会写出^[-]?\d$然后抱怨“怎么没有排除掉 0123”。不是正则多难是语义漏了\d描述的是“任意数字连串”它管不了首位是否为 0这类限制必须要用字符类[1-9]显式声明。有一点要提醒如果业务允许007这类字符串作为编号传入那 2.2 这个版本就过于严格了。此时用^[-]?\d$更合适。正则没有绝对的“标准答案”只有“符合当前场景的就是好的”。一个原则是只要这个数字后续会进行算术运算我通常都会顺手拒绝前导零避免走 parseInt 或 Number 转换时的隐式坑。2.3 关于“整数只有一个 0”的边界有个细节很容易忽略/^[-]?([1-9]\d*|0)$/对-0是判定为 false 的。如果你跑一下const re /^[-]?([1-9]\d*|0)$/; console.log(re.test(-0)); // false console.log(re.test(0)); // false理由不复杂-0在字符串层面属于“符号位加数字”但数学上0和-0的数值一样符号位没有意义。大部分业务场景会希望-0不合法所以这个行为一般不用改。如果你做的是温度记录、盈亏展示这类“符号位有语义”的场景那可以把负零也放进白名单正则改成/^[-]?(0|[1-9]\d*)$/这相当于把-和从“可有可无”变成“必须有或没有都可以”但把0单独拎出来。注意写法顺序不同刚才的版本是0在或分支里现在是直接允许符号加零。实际项目中遇到这种需求不多但遇到过一次之后就不会再忘了业务规则永远是优先于正则技巧的。3. 小数正则点号转义、精度限制、整数位可空小数是 JS 数字正则里最容易出错的区域坑密集度极高。我把带小数点的匹配需求拆成三层来写每一层解决一类问题。3.1 点号必须转义——最经典的新手错误先看这个错误示范const wrong /^\d.\d$/; console.log(wrong.test(12.5)); // true console.log(wrong.test(12x5)); // 也返回 true为什么12x5也是 true因为在正则里点号.不是“小数点”而是“匹配任意一个字符”的通配符。\d.\d的意思是“若干数字紧接着任意一个字符再紧接着若干数字”。这个坑我亲眼见过不止一次甚至有人在生产环境排了半天 bug最后发现是正则写成了\d.\d导致用户输入12a5都能通过校验后台 Next() 转数字的时候报 NaN。正确的写法是给点号加反斜杠转义const correct /^\d\.\d$/; console.log(correct.test(12.5)); // true console.log(correct.test(12x5)); // false console.log(correct.test(.5)); // false console.log(correct.test(12.)); // false到这里你别以为完了因为^\d\.\d$做了两个假设小数点两边都必须有数字整数部分和小数部分都至少需要一位。这两个假设在需求里经常站不住脚。比如用户输入.5合法小数的语义通常允许再比如输入12.它不该合法但有些场景又希望宽容处理。我们往下拆。3.2 支持.5这种“整数位可空”的写法const flexibleDecimal /^[-]?(?:\d\.\d*|\.\d)$/; console.log(flexibleDecimal.test(12.5)); // true console.log(flexibleDecimal.test(12.)); // true注意这个 console.log(flexibleDecimal.test(.5)); // true console.log(flexibleDecimal.test(-12.34)); // true这里用了非捕获分组(?:...)里面两个分支\d\.\d*整数位至少一位小数点后可以没有数字所以12.是合法的。\.\d整数位为空也可以但小数点后必须有数字所以.5合法。如果不想让12.合法就把第一个分支改成\d\.\dconst stricter /^[-]?(?:\d\.\d|\.\d)$/;到底是“允许12.”还是“不允许”完全取决于业务。我的经验是在浏览器表单里用户把12.提交上来大概率是输入中途被截断或者误操作直接在失焦校验阶段拦截掉更符合直觉但在数据清洗场景下有些 ERP 导出的 CSV 会写12.你又不得不在清洗层接受它。所以“宽容的清洗正则”和“严格的校验正则”最好分开维护。3.3 限制小数位数金额和评分场景必备很多场景需要强制“最多两位小数”或者“必须刚好两位”。比如价格输入、评分、利率录入。最多两位小数同时整数位为 0 时不允许写成空const price /^(?:[1-9]\d*|0)(?:\.\d{1,2})?$/; console.log(price.test(12)); // true console.log(price.test(12.5)); // true console.log(price.test(12.55)); // true console.log(price.test(12.555)); // false console.log(price.test(0)); // true console.log(price.test(0.1)); // true console.log(price.test(0.10)); // true说明几个设计点(?:[1-9]\d*|0)依然保持“没有前导零”的规则012.5会被拒。(?:\.\d{1,2})?中外层?表示小数部分可以不存在\d{1,2}限定了小数位数为 1 到 2 位。这里没有处理负号做价格表单时按需加上[-]?。如果业务要求“必须固定两位”就改成(?:\.\d{2})且此时小数部分不能再是可选项const exactTwo /^[-]?(?:[1-9]\d*|0)\.\d{2}$/; console.log(exactTwo.test(12.50)); // true console.log(exactTwo.test(12.5)); // false这类固定位数的写法在导出财务报表、生成金额展示字符串、解析 CSV 时特别好用不会因为12.5和12.50的尾部零差异导致数据对齐错乱。不过要记住正则只做格式验证不做数值范围验证。比如9999.99在这个正则下合法但你还要在代码里再判断它是否超过业务上限而不是尝试把范围写进正则。3.4 要不要支持科学计数法很多教程把科学计数法当成进阶内容一笔带过但我发现它在实际项目中出现的频率比想象中高很多尤其当你处理后端返回的大文件大小、大数据量数值或者在做 JSON 数据清洗时。科学计数法的字符串长这样1.5e3、-2.5E-2、6e8。它们的规律是一个普通数字加上e或E再加上可选的正负号最后是整数指数。正则写法const numeric /^[-]?(?:\d\.?\d*|\.\d)(?:[eE][-]?\d)?$/; console.log(numeric.test(123)); // true console.log(numeric.test(12.5)); // true console.log(numeric.test(.5)); // true console.log(numeric.test(1.5e3)); // true console.log(numeric.test(2.5E-2)); // true console.log(numeric.test(6e)); // false指数部分不能为空正则的尾段(?:[eE][-]?\d)?表示科学计数法部分整体可有可无[eE]匹配e或E[-]?允许指数带符号\d保证指数至少一位数字。少了\d1.5e这种残废字符串也能进来这是新手很容易漏的点。我对科学计数法的建议是如果 UI 层表单校验不要开放它用户不会期望在价格框里输入1.5e3如果是数据清洗、JSON 解析、日志提取那必须支持因为 JavaScript 的JSON.parse本身就能处理1e3你不允许它就变成一个数据丢失的坑。另外Number(1.5e3)可以直接转成1500所以清洗层用正则校验完之后不需要额外写字符串解析逻辑。4. 实战现场表单校验、文本提取、数字替换讲完各种写法之后我们把它放进三个真实场景里跑一遍。这三个场景基本覆盖了日常开发里八成以上的数字正则使用需求。4.1 表单输入校验以一个商品价格输入框为例假设我们有这样一个需求一个商品价格输入框允许用户输入最多两位小数的价格允许0.5这样的写法但整数部分不允许前导零非空校验。我一般这样写const priceRegex /^(?:[1-9]\d*|0)(?:\.\d{1,2})?$/; function validatePrice(input) { const value input.value.trim(); if (!value) { return 价格不能为空; } if (!priceRegex.test(value)) { return 价格格式不合法请输入大于等于0的数字最多两位小数; } const num Number(value); if (num 0 || num 99999.99) { return 价格超出允许范围; } return ; }这里有一个容易被忽略的细节我先用value.trim()把首尾空格去掉再用正则校验。很多用户复制粘贴价格时会带入空格直接test()会把它们判为非法体验很差。在第三方依赖不可用的情况下trim()是最朴素也最有效的手段。另外注意Number(value)这一步。我在前面的章节提过“正则不负责数值范围校验”这里就是一个具体的配合示例格式正则判断“长得像不像数字”Number()转换后比较大小判断“在不在合理范围内”。两条规则分开写可读性和可维护性都强得多。给input挂事件的示例const input document.getElementById(price-input); input.addEventListener(blur, () { const msg validatePrice(input); if (msg) { input.setCustomValidity(msg); } else { input.setCustomValidity(); } });不要用input事件实时校验否则用户输入12.的时候会有短暂的红色提示闪现等打完下一位数字又消失非常容易造成误导。等blur或者表单submit的时候一次性校验对用户更友好。4.2 从一段混合文本中提取所有数字这个场景在日志分析、爬虫清洗、命令输出解析里太常见了。比如我们要从一段服务器日志里提取所有内存占用百分比const logLine worker-01 memory: 32.5% | worker-02 memory: 16.2%; const memoryPattern /(\d(?:\.\d)?)%/g; const matches [...logLine.matchAll(memoryPattern)]; matches.forEach(m { console.log(m[1]); // 32.5, 16.2 });关键点有三个g全局标志不能丢否则match()只会返回第一个匹配结果。(\d(?:\.\d)?)这段在提取场景里的写法和小数校验场景不同它允许整数或小数但不要求一定有小数点。因为日志里可能出现64%这种整数形式。末尾接一个%字面量用普通字符限定数字的右边界比到处写\b更精准。在混合文本里\b对数字和百分号之间的判断没问题但如果你提取的是邮箱里的数字、订单号里的数字边界符号可能就不是空白了这时候直接在正则里写清楚“数字后面跟着哪个字符”往往更可控。如果环境不支持matchAll比如老旧的代码库还停留在 ES2015 之前的运行环境可以用exec循环const memoryPattern /(\d(?:\.\d)?)%/g; let match; while ((match memoryPattern.exec(logLine)) ! null) { console.log(match[1]); }两种写法等价但matchAll返回的是可展开的迭代器配合展开运算符用起来最顺手。4.3 千分位格式化正则替换的一个经典用法数字正则不只用来校验和提取也能用来给数字字符串加千分位分隔符。这也是我工作中经常会用到的一个功能写法很经典function addThousandSeparator(str) { return str.replace(/\B(?(\d{3})(?!\d))/g, ,); } console.log(addThousandSeparator(1234567.89)); // 1,234,567.89这里不展开所有细节但有一点值得提\B表示“不是单词边界”(?(\d{3})(?!\d))是一个正向预查用来从右向左每三位数字插入一个逗号。这个正则只加逗号不做数字格式校验所以它不改动小数点位置也不会给小数部分错误加逗号。把正则在“提取”和“替换”两种场景下的用途对照着看更容易理解同一个语法结构在不同场景里的不同写法。5. 常见的坑与排查速查表正则这东西写过几年之后你会发现大部分 bug 不是“不会写”而是“写得太顺手”。以下这几个坑是我实际工作中踩过、或者帮别人排查过的统一整理成速查表方便你直接对照。症状疑似原因修复方向12x5通过小数校验点号没有转义/^\d\.\d$/而不是/^\d.\d$/0123通过整数校验用了\d但没有排除前导零首位改用[1-9]\d*把0单独列出1.5e3被拒绝但数据合法正则没有处理科学计数法追加(?:[eE][-]?\d)?提取时只拿到了第一个数字忘记加g标志用matchAll或exec循环同一个正则在 Chrome 表现和本地手动测试不同正则对象带g标志lastIndex状态残留每次用re.lastIndex 0重置或者不全局复用对象 纯空格通过了校验直接test()没有trim()校验前value.trim()-0通过/未通过但业务要求不一致符号位和零的组合逻辑没单独定义按业务规则显式允许或禁止[-]?0算法题里\d匹配了中文全角数字某些正则风格里\d包含 Unicode 数字JS 原生\d等价[0-9]此处不是问题若用 regex 库或其它语言需确认语义其中第二条“正则对象带g标志导致lastIndex状态残留”值得展开说说因为它是最隐蔽的 bug 之一。const re /\d/g; console.log(re.test(123)); // true console.log(re.test(123)); // false为什么会这样因为带有g标志的正则对象在每次test()或exec()时都会更新自己的lastIndex。第一次test(123)把lastIndex指向了 3第二次test(123)从 3 开始往后找找不到任何数字于是返回 false并且把lastIndex重置成 0。这个“幽灵状态”在用户反复点击按钮时很容易造成偶发的校验失败而且极难排查。如果你确实需要复用同一个带g的正则对象要么在每次test()前手动重置re.lastIndex 0;要么干脆用String.prototype.match(/\d/g)这种不共享状态的写法。这一点对test()尤其危险因为很多人以为test()是纯函数忘了它也会修改正则对象内部状态。我日常写校验时大部分情况下根本不加g只在需要提取全部匹配时才加这样天然规避了这类问题。6. 我踩完这么多坑之后的几条绕行原则最后分享几条我这些年处理 JS 数字正则时沉淀下来的经验不算什么高深技术但确实能让后续维护你的人少几次抓耳挠腮。第一条不要试图用一条正则通杀所有数字场景。整数校验、小数校验、科学计数法校验、文本提取这四个需求用四条不同的正则各自命名清楚放到一个工具模块里集中管理。看起来重复代码多了但每条正则的意图都一目了然将来业务规则变化时只需要改对应的方法不会误伤其他场景。第二条正则里必须写注释的场景请用逻辑分组代替注释。JavaScript 原生正则虽然支持x修饰符ES2018 起但有兼容性考量。如果你不想引入编译步骤最实用的办法是限制每段正则的长度并配合命名分组提高可读性const orderPattern /^(?sign[-]?)(?int[1-9]\d*|0)(?:\.(?decimal\d{1,2}))?$/; const match orderPattern.exec(12.34); if (match) { const { sign, int, decimal } match.groups; console.log(sign, int, decimal); // , 12, 34 }具名捕获组在 ES2018 之后成为标准现在几乎所有主流环境都能放心用。它最大的好处是后续代码可以按match.groups.xxx取值不再需要数match[1]、match[2]的括号位置。第三条校验用test()提取用match()/exec()替换用replace()不要混用。这个原则对应着三种不同的返回语义test()只关心“能不能匹配上”match()想拿“具体匹配到了什么”replace()要做“基于匹配内容的重组”。混用的常见后果是有人为了实现“校验不能匹配”在结果前面加!再加g标志然后被lastIndex坑得摸不着头脑。第四条给正则加个“输入样例”测试函数。我会在每个数字正则文件里放一组断言用例覆盖合法输入、非法输入、边界输入这样每次改规则都不会因为回归测试缺失而悄悄带出问题。一个最简单的测试function testRegex(regex, cases) { cases.forEach(({ input, expected }) { const actual regex.test(input); if (actual ! expected) { console.error(FAIL: ${input} - expected ${expected}, got ${actual}); } }); } testRegex(priceRegex, [ { input: 12.5, expected: true }, { input: 012.5, expected: false }, { input: -1, expected: false }, { input: 0.10, expected: true }, { input: 12., expected: false }, ]);别小看这几行它在需求迭代时能帮你省下大量手动点页面的时间。正则这种表达式写的时候觉得读得懂过两周再看基本就是天书有一堆断言兜底比什么注释都好使。数字正则的难点从来不在语法本身而在于不同场景下对“数字”的定义完全不同。想清楚边界条件把规则拆散再配合验证用例你会发现在 JS 里处理数字字符串这件事其实可以写得很稳。