JS正则之正向预查与数字转换千分位

最近遇到一道面试题:

将一串数字表示成千分位格式,例如 10000000000.12,转换成 10,000,000,000.12。

如果直接用字符串拼接,这道题还是挺简单的。但如果要求用正则表达式,我就没了思路。后来在网上搜了一下,才知道需要用正向预查。网上的解法比较多,但有些只考虑了整数部分,有些只考虑了两位小数,不够通用。这里把这些方法分析一下,总结出一个自己满意的版本。

处理整数

首先看一下怎么处理整数部分。这里要用到“正向预查” (Lookahead assertion)或者叫“向前断言”。格式是x(?=y),表示 x 被 y 跟随时匹配 x。举个例子:

var reg = /a(?=b)/g;
console.log("abaaaaa".match(reg));  // ["a"]

只有当”a”在跟有”b”的情况下才会得到匹配,并且,匹配结果里不包括”b”。在"abaaaaa"里,只有第一个”a”满足条件,所以返回["a"]

回到原本的数字匹配问题,可以得到第一个思路:找到某些空字符,后面跟随3的整数倍的数字。正则如下:

var reg = /(?=(\d{3})+$)/g;
console.log("10000000000".match(reg));  // ["", "", ""]

首先,reg里正向预查格式x(?=y)x的位置是空的,也就是匹配空字符"",并且空字符后面必须带有3的整数倍的数字。又因为有$符号,说明要匹配到字符串末尾。满足要求的一共有三个位置的空字符,对应的是后面带有9位、6位、3位数字的空字符,结果返回["", "", ""]。使用str.replace(reg, ",")就可以达到要求:10,000,000,000

但是,如果原本的一串数字有12位,那么匹配的时候就会把首位的空字符也匹配到,结果就成了",100,000,000,000"。可以加一个非单词边界\B

var reg = /(?=(\B\d{3})+$)/g;

这样表示匹配的空字符后面不能跟随单词边界,即过滤掉了第一个匹配的空字符。这里补充一点,\b\B^$都是边界类断言,匹配时不包括在匹配中。换句话说,匹配的长度为0。

第二个思路,找到某些非空的数字,后面跟随3的整数倍的数字直到字符串末尾,即reg = /\d{1,3}(?=(\B\d{3})+$)/g;。举个例子:

"1234567890".match(/\d{1,3}(?=(\d{3})+$)/g)  // ["1", "234", "567"]

在匹配时,采用贪心算法逐步试出来的。对于正向预查格式x(?=y),x代表\d{1,3},表示可以是1位至3位的数字。

  1. 首先尝试 x = 123,发现到末尾的话,跟随的是”4567890”,不满足3的整数倍。
  2. 尝试 x = 12,这次后面跟随的是”34567890”,也不满足3的整数倍。
  3. 尝试 x = 1,这次后面跟随的是”234567890”,满足3的整数倍,匹配成功。
  4. 因为x至少匹配1位数字,经过上面三轮,已经尝试了所有从索引0开始的可能性,现在从索引1开始尝试,即 x = 234。发现到末尾的话,跟随的是”567890”,满足3的倍数,匹配成功。
  5. 尝试 x = 23,匹配不成功。
  6. 重复上述过程。

最后,需要在这些匹配到的字符后面添加逗号,方法是:"1234567890".replace(/\d{1,3}(?=(\d{3})+$)/g, '$&,')。其中,'$&'指的是匹配到的字符,'$&,'意思就是在匹配到的字符后面加个逗号。

处理小数

接下来处理小数部分,包括后面不止2位小数的情况。如果直接使用/\d{1,3}(?=(\d{3})+(?:\.\d+)?$)/g来匹配后面的多个小数,是不行的,例如

"1234567890.1234".match(/\d{1,3}(?=(\d{3})+(?:\.\d+)?$)/g)
// ["1", "234", "567", "1"]

这里,(?:x)是非捕获组,需要匹配“x”,但不记得匹配,不能从结果数组的元素中收回匹配的子字符串,即不能用$1, ..., $9

但结果里却匹配到了小数里的1,这是因为当正向预查到小数里的1时,后面恰好有3个整数数字,满足匹配条件。一个改进办法来自参考文章[2]中的评论。使用?<!exp进行“负向后瞻”或者叫“向后否定断言”。格式是(?<!y)x,表示x不跟随在y后面时匹配x。举个例子:

var reg = /(?<!l)o/g;
console.log("Hello".match(reg));  // null
console.log("World".match(reg));  // ["o"]

这里,表示“o”在不跟随“l”时才匹配,这样只有“World”里的“o”满足要求。

所以,如果要避免小数点后面的数字还会匹配,使用/(?<!\.)\d{1,3}(?=(\d{3})+(\.\d+)?$)/g。这样,只有当数字不跟随在小数点“.”后面时才匹配,换句话说,只会匹配整数部分。

"1234567890.1234".match(/(?<!\.)\d{1,3}(?=(\d{3})+(\.\d+)?$)/g)
// ["1", "234", "567"]
"1234567890.1234".replace(/(?<!\.)\d{1,3}(?=(\d{3})+(\.\d+)?$)/g, '$&,')
// "1,234,567,890.1234"

此外,在参考文章[1]中的评论区里提到了第三种办法,直接使用Number.prototype.toLocaleString。但如果数字含有多位小数,这个方法就会最多保留3位小数。

(123456789).toLocaleString('en-US')       // 123,456,789
(123456789.12).toLocaleString('en-US')    // 123,456,789.12
(123456789.1234).toLocaleString('en-US')  // 123,456,789.123

最后,把第一种匹配空字符的思路与处理小数的方法结合起来,可以得到:

"123456789".replace(/\B(?=(\d{3})+(?=\b))(?<=\b(?<!\.)\d*)/g, ',')
// "123,456,789"
"123456789.1234".replace(/\B(?=(\d{3})+(?=\b))(?<=\b(?<!\.)\d*)/g, ',')
// "123,456,789.1234"

这个方法同样来自文章[1]中的评论区。但这个方法没有匹配数字的方法好理解。大概思路是这样的,后半部分里(?<=\b(?<!\.)\d*)(?<!\.)\d*表示一个“负向后瞻”,表示匹配没有跟在小数点“.”后面的数字。那么(?<=\b(?<!\.)\d*)表示一个“后向预查”或“向后断言”,指的是匹配那些跟在一个单词边界\b后同时没有跟随在小数点后数字的空字符。

"123456789.1234".replace(/(?<=\b(?<!\.)\d*)/g, ',')  // ",1,2,3,4,5,6,7,8,9,.1234,"
// 使用 \B 去掉单词边界,这样前后的位置都去掉了。
"123456789.1234".replace(/\B(?<=\b(?<!\.)\d*)/g, ',')  // "1,2,3,4,5,6,7,8,9.1234"

前半部分(?=(\d{3})+(?=\b))就是指这些空字符后面必须跟3的整数倍的数字。同时满足的话,就只有整数部分的空字符。

总结

这个是最通用也容易理解的版本:

"123456789".replace(/(?<!\.)\d{1,3}(?=(\d{3})+(\.\d+)?$)/g, '$&,')
// "123,456,789"
"123456789.1234".replace(/(?<!\.)\d{1,3}(?=(\d{3})+(\.\d+)?$)/g, '$&,')
// "123,456,789.1234"

参考文章

  1. 把一串数字表示成千位分隔形式——JS正则表达式的应用
  2. javascript正则表达式—正向预查