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位的数字。
- 首先尝试
x = 123
,发现到末尾的话,跟随的是”4567890”,不满足3的整数倍。 - 尝试
x = 12
,这次后面跟随的是”34567890”,也不满足3的整数倍。 - 尝试
x = 1
,这次后面跟随的是”234567890”,满足3的整数倍,匹配成功。 - 因为x至少匹配1位数字,经过上面三轮,已经尝试了所有从索引0开始的可能性,现在从索引1开始尝试,即
x = 234
。发现到末尾的话,跟随的是”567890”,满足3的倍数,匹配成功。 - 尝试
x = 23
,匹配不成功。 - 重复上述过程。
最后,需要在这些匹配到的字符后面添加逗号,方法是:"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"