JavaScirpt 货币转换成千分位正则 (非捕组获匹配详解)

如果给你一串数字,需要把他转换成货币的千分位格式,你会如何去做?比如:123123123 -> 123,123,123

1. 一个有意思的正则表达式的由来

这其实是个陈年老问题了,但是不知为何最近的出镜率特别高,所以决定这里讨论一下。

先看一种传统的思维:从右侧起每隔三位加一个逗号。于是就有了下面的方法:

function money(num){
    // 先把数字换成字符串,然后转换成数组,反转之后,再组合成字符串
    var reverseStr = num.toString().split('').reverse().join('');
    // 用正则替换,每隔3位加一个逗号
    reverseStr = reverseStr.replace(/(\d{3})/g,'$1,');
    // 处理正好三位的情况,如 123 -> ,123
    reverseStr = reverseStr.replace(/\,$/,'');
    // 把加了逗号的字符串反转回正常的顺序
    reverseStr = reverseStr.split('').reverse().join('');
    return reverseStr;
}

虽然这个方法能满足我们的需求,但是或多或少感觉有些low,也不是我们今天讨论的重点。

我们今天尝试使用一句简短的正则搞定这个问题,先上代码:

function money(num){
    return (''+num).replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,');
}

很简单的一个正则/(\d)(?=(\d{3})+(?!\d))/就搞定了一切。正则虽然短,但是并不简单,今天的目的,其实就是和大家一起来研究研究这个正则的内容。

我们先讨论这里涉及到的3个概念:

  1. 正则匹配的lastIndex
  2. 正则中形如(?=exp)零宽度正预测先行断言
  3. 正则中形如(!=exp)零宽度负预测先行断言

1.1 lastIndex

lastIndex:指的是上一次匹配的结果位置,也就是下一次匹配开始的位置,默认情况下为0(需要强调的是,只有在匹配模式是g或者y的情况下才有效,否则,每次匹配完成之后,lastIndex都会变成0)。这里之所以强调lastIndex是因为后面的断言(也叫非捕获)匹配会影响lastIndex的值,我们先看例子:

let re = /\d\d/g;
let str = '0123456789';
console.log(re.lastIndex);
// 0 
// 默认情况下 lastIndex 的值为0
console.log(re.exec(str));
// ["01",...] 匹配到了01
console.log(re.lastIndex);
// 2 
// 由于第一次匹配的结果是01,接下来的匹配应该从01之后的2开始,所以此时的lastIndex为2

同理,如果也可以手动修改lastIndex的值,匹配的结果也会受到影响。

let re = /\d\d/g;
let str = '0123456789';
re.lastIndex = 5;
// 手动修改 lastIndex 的值为5,下次匹配从第五未开始
console.log(re.exec(str));
// ["56",...]
console.log(re.lastIndex);
// 7 匹配完成之后,lastIndex自动修改到匹配的结果之后

1.2 (?=exp) 零宽度正预测先行断言 与 (?!exp) 零宽度负预测先行断言

第一次听到这个名字的感觉就好像不会中文一样,不知所云,内心一万只羊驼奔过。

在定义上它是指:它断言自身出现的位置的后面能匹配表达式exp,[一脸懵逼]表示我第一次没有看懂。

还是举一个简单的例子帮助大家理解,假设有人告诉你请你去找一个骑车的人,然后把这个人带过来(车不需要)你会怎么做?这句话抽象出来就其实就是用人骑车去匹配正则/人(?=骑车)/,结果要的是人而不需要车。

也正是由于上面括号内的表达式对结果没有影响,他们也属于非捕组获匹配

举个例子

var re = /ap(?=ple)/g;
console.log(re.exec('I like apple not app!'));
// ["ap", index: 7, input: "I like apple not app!"]
console.log(re.lastIndex)
// 9
console.log(re.exec('I like apple not app!'));
// null
console.log(re.lastIndex)
// 0

在上述例子中,第一个.exec会找到句子中ple之前的ap,那么第7-11个字符apple就符合我们的条件,但是由于(?=ple)是非捕获的,所以ple的并没有被计算到结果中,自然ple这3个字符也没有影响到lastIndex,所以lastIndex的值为 7+2=9 ,而不是7+5=11;

这里的非捕获很容忍让人误解,所以再强调一遍:

!!非捕获不会影响lastIndex的值!!

如果你明白了,请在脑海想一下下面的2个题目的结果是什么:

题目一

var re = /ap(?=ple)pie/g;
console.log(re.test('applepie'))

题目二

var re = /ap(?=ple)plepie/g;
console.log(re.test('applepie'))

不要作弊哦

答案是 ↓ ↓ ↓

题目一:false

题目二:true

如果没有猜对的话我们一起来看一下为什么:

实际上我们可以把/ap(?=ple)pie/分成2部分 ap(?=ple) + pie,在匹配字符串applepie的时候,经过了以下的步骤:

  1. 一开始lastIndex的值为0,ap(?=ple)匹配applepie 中的appleap字段,此时的结果是ap,lastIndex=2。
  2. 第一步过后lastIndex=2,后面的表达式是pie,表示从第二个字符开始后面应该紧接着的是pie,但是实际上第二个字符后面的是ple,这样条件就满足不了了,所以返回的就是false。  同理第二个正则/ap(?=ple)plepie/的第二步表示的是从第二个字符开始后面紧跟着的是plepie,完全符合我们的给出的字符串,所以结果是true

对于(?!exp) 零宽度负预测先行断言就不用多说了,他表示后面不跟着exp,同样也是非捕获(比如:.+?(?!xyz) 去匹配uvwuvwxyz的话只会匹配第一个)

2. /(\d)(?=(\d{3})+(?!\d))/的匹配步骤

说到这里如果你还是很迷茫的话,那么我们再说详细举一个例子来说明上面的正则是如何工作的。

我们以'1234567.88'.replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,')为例。

首先解释一下(?=(\d{3})+(?!\d))的意思,他表示某个匹配规则之后,有一个或者多个数字组+,每组由3个数字\d{3}组成(比如 123456,123,123456789,这些都是由个数字组组成的字符串),并且数字组之后不是数字(?!\d)(这个是用来找到结尾,只要后面不是数字我们都认为是结尾)。

最后我们用图表来解释这个过程:

index (\d)也就是$1 的值 (?=(\d{3})+(?!\d))匹配的结果 lastLindex 字符串结果
0 '1' (234)(567) 1 '1,234567'
1 '2' -- (24567无法分成2租) 2 '1,234567'
2 '3' -- (4567无法分成2租) 3 '1,234567'
3 '4' (567) 4 '1,234,567'
... ... ... ... ...
9 '8' -- 10 '1,234,567'

好了,就说这么多了,如果还有疑问的话,可以再后面给我留言。

Write a response...
Mofei Zhu
publish
nkj
2017-10-24 22:22
@Mofei  kk
0
 Replay
@nkj  
Replay
s
2017-07-18 17:13
s
0
 Replay
@s  
Replay
Mofei
2017-06-08 13:40
@剧中人  好棒耶,剧大神来捧场
0
 Replay
@Mofei  
Replay
Mofei
2017-06-08 13:39
@lgf  还是锋哥厉害 [嘿哈]
0
 Replay
@Mofei  
Replay
lgf
2017-06-08 13:37
好崇拜龙哥啊!
0
 Replay
@lgf  
Replay
剧中人
2017-06-07 23:38
好棒耶,又学到新姿势了!🤓
0
 Replay
@剧中人  
Replay