正则表达式数组去重
数组去重是前端常见的需求,也是经常要用到的功能,而实现数组去重的方式也有很多种,今天我们来着重分析一下使用正则表达式实现数组去重的原理,首先我们来看一下最终实现的代码。如下所示:
ts
const uniqueRegExp = <T>(arr: T[]) =>
arr
.sort()
.join(",,")
.replace(/(,|^)([^,]+)(,,\2)+(,|$)/g, "$1$2$4")
.replace(/,,+/g, ",")
.replace(/,$/, "")
.split(",");
分析
这个工具函数的实现看起来很简单,但是要理解还是有一定难度的,这其中主要涉及到了正则表达式,让我们一起来看一下吧。
首先我们对数组调用了默认排序规则的 sort 方法将数组进行排序,那么在这之前,我们有必要了解一下 sort 方法的默认排序规则。
数组的默认排序规则
默认情况下,sort 采用升序的规则来排序,即最小值在前面,最大值在后面,但要注意的是默认比较的是字符串值的大小,即使数组项是数值也会调用每一数组项的 String 方法来转换成字符串,然后进行比较。如下所示:
ts
let values = [0, 1, 5, 10, 15];
values.sort();
alert(values); // 0,1,10,15,5
这里我们采用的是默认的排序规则,紧接着我们调用 join 方法将排序后的数组转换成了字符串,注意这里的 join 方法参数我们采用的是",,"(即两个逗号),这里之所以采用两个逗号,主要是方便后面的正则表达式来匹配,没错接下来才是最难的地方,我们调用字符串的 replace 方法,然后传入一个正则表达式,替换掉值。
正则表达式
那么这个正则表达式是什么样的呢?如下所示:
ts
const regx = /(,|^)([^,]+)(,,\2)+(,|$)/g;
咋一看这个正则表达式很复杂,但我们将其拆分开来理解,那么就很简单了,在这之前,我们有必要了解一下与这里示例相关的正则表达式知识。如下:
()
表示匹配的是一个分组,因此前面的正则表达式一共有 4 个分组。|
表示匹配一个分支,例如: /test|case/表示匹配 test 和 case 两个字符串。[]
表示匹配一个字符组,如/[edf]/表示匹配 e,d,f 各个字符之一。^
与$
表示匹配开头与结尾,但如果^
放在一个字符组,表示为排除字符组,这里的^
就是取反的意思,如:/[^abc]/表示不能匹配 a,b,c 字符。+
等价于"{1,}",其中"{m,n}"就是量词,表示重复的意思,也就是说表示至少出现 m 次,至多出现 n 次,如果第二个值没有,则表示至多不限制,换句话说这里的"{1,}"的意识就是至少出现 1 次。g
是一个正则表达式修饰符,表示匹配全局。
有了以上的知识点,我们就可以将以上的正则表达式进行拆分,一共可以拆分成四个分组,首先是第一个分组(,|^)
,根据以上的知识点,我们可以知道这个分组表达的意思就是匹配逗号又或者不是逗号开头(不是逗号那就只能是空白了)。来看一个示例:
ts
const regx = /(,|^)/g;
const str = "1,23,44";
str.match(regx); // ['',',',',']
理解了(),|,^
这三个符号代表的含义就不难理解为什么会出现以上结果了。我们来尝试分析一下,首先从开头开始匹配逗号,没有找到,但是匹配开头,开头没有任何值,因此返回空白,然后因为全局修饰符的存在,我们要继续往后匹配,因此在第二个字符串索引中匹配到了逗号,所以返回逗号,继续匹配以此类推,最终匹配到了 2 个逗号,因此返回 2 个逗号字符串,直到字符串末尾结束,最终就返回组成的数组。
伪代码模拟
我们可以用伪代码来模拟一下查找过程,如下所示:
ts
const regx = /(,|^)/,
str = "1,23,44",
res = [];
let matchIndex = 0;
while (matchIndex < str.length) {
const value = str[matchIndex].match(regx);
if (value !== null) {
const result = value[0];
if (matchIndex === 0) {
res.push(result);
} else if (matchIndex > 0 && result !== "") {
// 需要注意这里为什么需要判断result不等于空字符串,因为第一次已经匹配到了开头,即空字符串,因此后续我们只需要匹配含有逗号的字符串即可,而不需要继续匹配开头
res.push(result);
}
}
matchIndex++;
}
console.log(res); // ['',',',',']
对于正则表达式的分组引用,我们还可以使用$1...$9
来获取,这几个属性是 RegExp 的实例属性,虽然分组的匹配可以是无限的,但这个实例的属性是有限制的,最多只能匹配到 9 个分组,因此只有 <math xmlns="http://www.w3.org/1998/Math/MathML"> 1 1 ~ </math>1 9,来看一个示例如下:
ts
const regx = /(,|^)/g,
str = "1,23,44";
str.match(regx);
console.log(RegExp.$1); // ','
console.log(RegExp.$2); // ''
也许你会好奇为什么会出现如上的结果,首先第二个结果应该是毫无疑问的,因为这里只有一个分组,而第一个结果之所以会这样,这是因为匹配的结果造成的。什么意思呢?观察我们前面的伪代码模拟实现(虽然实际实现不一定这样,但是思路是差不多的,可以参考 core.js 的 String.prototype.match 方法实现,原理是很相似的)。我们发现每一次查找,其实都会给正则表达式 RegExp 实例的 <math xmlns="http://www.w3.org/1998/Math/MathML"> 1 赋值属性,也就是说第一次我们匹配到了空白字符串,这时候 1 赋值属性,也就是说第一次我们匹配到了空白字符串,这时候 </math>1赋值属性,也就是说第一次我们匹配到了空白字符串,这时候1 的值就是空白,而第二次匹配到了逗号,这时候$1 的值就会变成逗号,以此类推,直到匹配到最后为止。看如下这个示例就是一个很好的说明:
ts
const regx = /(,|\?|^)/g,
str = "1,23,4?4";
str.match(regx);
console.log(RegExp.$1); // '?'
console.log(RegExp.$2); // ''
第二个分组
接着我们再来看第二个分组([^,]+)
。根据前面的知识点,我们显然就能理清这个分组表达的意思,就是说匹配除了逗号会出现一次之外的字符。如:
ts
const regx = /([^,]+)/g,
str = "1,23,4,4";
console.log(str.match(regx)); // ['1','23','4','4']
这个字符串除了逗号之外就是数字了,遇到逗号就要被截断,因此会出现如上四个数字组成的数组。我们再看第三个分组(,,\2)+
,这里值得推敲的就是这个'\2',实际上这里指的是 2 的以十六进制编码所表示的字符,也就是说这里的'\2'实际上指的是'\x02',除了这个之外,其它都好理解,总的说来就是匹配',,\2'即',,\x02'这个字符串至少出现一次,来看如下一个示例:
ts
const regx = /(,,\2)+/g,
str = ",,\x02,346363";
console.log(str.match(regx)); // [',,\x02']
这里为什么要加这个分组我们先不做解释,等我们将以上的分组说完了再来解释,然后我们先来看最后一个分组,即(,|$)
,顾名思义,就是匹配逗号或者是结尾。如下一个示例:
ts
const regx = /(,|$)/g,
str = "1,23,4,4";
console.log(str.match(regx)); // [',',',',',','']
有没有觉得和最开始的(,|^)
这个分组有点类似,其实就是这个分组的取反。最后将这些分组合在一起就变成了我们去匹配一个重复的字符串,而重复的结构就类似'xxx,,xxx,,xx',我们这个正则表达式就会匹配到'xxx,,xxx,'这样的字符串。如示例:
ts
const regx = /(,|^)([^,]+)(,,\2)+(,|$)/g;
const str = "1,,1,,2";
str.match(regx); // ['1,,1,'];
此时我们的分组RegExp.$1,RegExp.$2,RegExp.$3,RegExp.$4
的值分别就是'','1',',,1',','
,可以看到我们的RegExp.$3
就是我们的重复值,因此我们需要将这个值去掉,利用字符串的 replace 方法就可以去掉,如下所示:
ts
const regx = /(,|^)([^,]+)(,,\2)+(,|$)/g;
const str = "1,,1,,2";
str.replace(regx, "$1$2$4"); // '1,,2';
// 以上等价于 str.replace(regx,w = > `${RegExp.$1}${RegExp.$2}${RegExp.$4}`);
经历了去重之后的字符串结构类似'xxx,,xx',逗号之间的两个子字符串一定不相同,这时候我们只需要把两个逗号去掉,再调用 split 方法转成数组就可以了。也就是说我们还需要调用因此 replace 方法,如下所示:
ts
const regx = /(,|^)([^,]+)(,,\2)+(,|$)/g;
const str = "1,,1,,2";
str.replace(regx, "$1$2$4").replace(/,,+/g, ","); // '1,2'
另外由于我们的最后一个分组是(,|$)
这样的,也就是说我们的最后一个字符有可能会匹配到逗号,因此我们需要再调用一次 replace 方法,将这个逗号去掉,利用正则表达式就可以做到,当然这里如果匹配不到,那么这个 replace 方法就没有任何作用。如下所示:
ts
const regx = /(,|^)([^,]+)(,,\2)+(,|$)/g;
const str = "1,,1,,2";
// 最后一个replace表示如果结尾字符匹配到了逗号,则替换成空白,也就是去掉。
str.replace(regx, "$1$2$4").replace(/,,+/g, ",").replace(/,$/, ""); // '1,2'
最后
最后我们调用split(',')
,即以逗号分隔转换成数组,最终我们就达到了数组去重的目的,然后我们将以上的所有分析都组合起来,就得到了如下的完整的数组去重工具函数代码。
ts
const uniqueRegExp = <T>(arr: T[]) =>
arr
.sort()
.join(",,")
.replace(/(,|^)([^,]+)(,,\2)+(,|$)/g, "$1$2$4")
.replace(/,,+/g, ",")
.replace(/,$/, "")
.split(",");
总结
可以看到这个工具函数是非常复杂的,当然难点都是在正则表达式上,也就是第三个正则表达式去重,而且这里最精妙的地方就是'\2',因为这个分组的存在才导致能够匹配到重复字符串,因而达到数组去重的目的,在这里我们可以将这个分组理解为匹配重复的字符。
如果觉得本文对你有所帮助,感谢点个赞收藏,如有不同意见欢迎在评论区友好交流发表不同的意见。