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+\.\d+:
const 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+保证指数至少一位数字。少了\d+,"1.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 里处理数字字符串这件事,其实可以写得很稳。