Javascript 捕获组
模式的一部分可以用括号括起来 (...)
。这被称为“捕获组(capturing group)”。
这有两个影响:
- 它允许将匹配的一部分作为结果数组中的单独项。
- 如果我们将量词放在括号后,则它将括号视为一个整体。
示例
让我们看看在示例中的括号是如何工作的。
示例:gogogo
不带括号,模式 go+
表示 g
字符,其后 o
重复一次或多次。例如 goooo
或 gooooooooo
。
括号将字符组合,所以 (go)+
匹配 go
,gogo
,gogogo
等。
alert( 'Gogogo now!'.match(/(go)+/i) ); // "Gogogo"
示例:域名
让我们做些更复杂的事 —— 搜索域名的正则表达式。
例如:
mail.com
users.mail.com
smith.users.mail.com
正如我们所看到的,一个域名由重复的单词组成,每个单词后面有一个点,除了最后一个单词。
在正则表达式中是 (\w+\.)+\w+
:
let regexp = /(\w+\.)+\w+/g;
alert( "site.com my.site.com".match(regexp) ); // site.com,my.site.com
搜索有效,但该模式无法匹配带有连字符的域名,例如 my-site.com,因为连字符不属于 \w
类。
我们可以通过用 [\w-]
替换 \w
来匹配除最后一个单词以外的每个单词:([\w-]+\.)+\w+
。
示例:电子邮件
扩展一下上面这个示例。我们可以基于它为电子邮件创建一个正则表达式。
电子邮件的格式为:name@domain
。名称可以是任何单词,允许使用连字符和点。在正则表达式中为 [-.\w]+
。
模式:
let regexp = /[-.\w]+@([\w-]+\.)+[\w-]+/g;
alert("my@mail.com @ his@site.com.uk".match(regexp)); // my@mail.com, his@site.com.uk
该正则表达式并不完美的,但多数情况下都能正确匹配,并且有助于修复输入邮箱时的意外错误输入。唯一真正可靠的电子邮件检查只能通过发送电子邮件来完成。
匹配中的括号的内容
括号被从左到右编号。正则引擎会记住它们各自匹配的内容,并允许在结果中获取它。
方法 str.match(regexp)
,如果 regexp
没有修饰符 g
,将查找第一个匹配项,并将它作为数组返回:
- 在索引
0
处:完整的匹配项。 - 在索引
1
处:第一个括号的内容。 - 在索引
2
处:第二个括号的内容。 - ……等等……
例如,我们想找到 HTML 标签 <.*?>
并处理它们。将标签内容(尖括号内的内容)放在单独的变量中会很方便。
让我们将内部内容包装在括号中,像这样:<(.*?)>
。
现在,我们在结果数组中得到了标签的整体 <h1>
及其内容 h1
:
let str = '<h1>Hello, world!</h1>';
let tag = str.match(/<(.*?)>/);
alert( tag[0] ); // <h1>
alert( tag[1] ); // h1
嵌套组
括号可以嵌套。在这种情况下,编号也从左到右。
例如,在搜索标签 <span class="my">
时,我们可能会对以下内容感兴趣:
- 整个标签的内容:
span class="my"
。 - 标签名称:
span
。 - 标签特性:
class="my"
。
让我们为它们添加括号:<(([a-z]+)\s*([^>]*))>
。
这是它们的编号方式(根据左括号从左到右):
验证:
let str = '<span class="my">';
let regexp = /<(([a-z]+)\s*([^>]*))>/;
let result = str.match(regexp);
alert(result[0]); // <span class="my">
alert(result[1]); // span class="my"
alert(result[2]); // span
alert(result[3]); // class="my"
result
的索引 0 中始终保存的是正则表达式的完整匹配项。
然后是按左括号从左到右编号的组。第一组返回为 result[1]
。它包含了整个标签内容。
然后是 result[2]
,从第二个左括号开始分组 ([a-z]+)
—— 标签名称,然后在 result[3]
中:([^>]*)
。
字符串中每个组的内容:
可选组
即使组是可选的并且在匹配项中不存在(例如,具有量词 (...)?
),也存在相应的 result
数组项,并且等于 undefined
。
例如,让我们考虑正则表达式 a(z)?(c)?
。它查找 "a"
,后面是可选的 "z"
,然后是可选的 "c"
。
如果我们在单个字母的字符串上运行 a
,则结果为:
let match = 'a'.match(/a(z)?(c)?/);
alert( match.length ); // 3
alert( match[0] ); // a(完整的匹配项)
alert( match[1] ); // undefined
alert( match[2] ); // undefined
数组的长度为 3
,但所有组均为空。
对字符串 ac
的匹配会更复杂:
let match = 'ac'.match(/a(z)?(c)?/)
alert( match.length ); // 3
alert( match[0] ); // ac(完整的匹配项)
alert( match[1] ); // undefined, 因为没有 (z)? 的匹配项
alert( match[2] ); // c
数组长度依然是:3
。但没有组 (z)?
的匹配项,所以结果是 ["ac", undefined, "c"]
。
带有组搜索所有匹配项:matchAll
matchAll
是一个新方法,可能需要使用 polyfill旧的浏览器不支持
matchAll
。
可能需要进行 polyfill,例如 https://github.com/ljharb/String.prototype.matchAll.
当我们搜索所有匹配项(修饰符 g
)时,match
方法不会返回组的内容。
例如,让我们查找字符串中的所有标签:
let str = '<h1> <h2>';
let tags = str.match(/<(.*?)>/g);
alert( tags ); // <h1>,<h2>
结果是一个匹配数组,但没有每个匹配项的详细信息。但是实际上,我们通常需要在结果中获取捕获组的内容。
要获取它们,我们应该使用方法 str.matchAll(regexp)
进行搜索。
在使用 match
很长一段时间后,它才被作为“新的改进版本”被加入到 JavaScript 中。
就像 match
一样,它寻找匹配项,但有 3 个区别:
- 它返回的不是数组,而是一个可迭代的对象。
- 当存在修饰符
g
时,它将每个匹配项以包含组的数组的形式返回。 - 如果没有匹配项,则返回的不是
null
,而是一个空的可迭代对象。
例如:
let results = '<h1> <h2>'.matchAll(/<(.*?)>/gi);
// results —— 不是数组,而是一个迭代对象
alert(results); // [object RegExp String Iterator]
alert(results[0]); // undefined (*)
results = Array.from(results); // 让我们将其转换为数组
alert(results[0]); // <h1>,h1(第一个标签)
alert(results[1]); // <h2>,h2(第二个标签)
我们可以看到,第一个区别非常重要,如 (*)
行所示。我们无法获得 results[0]
的匹配项,因为该对象并不是伪数组。我们可以使用 Array.from
把它变成一个真正的 Array
。在 Iterable object(可迭代对象) 一文中有关于伪数组和可迭代对象的更多详细内容。
如果我们只需要遍历结果,则 Array.from
没有必要:
let results = '<h1> <h2>'.matchAll(/<(.*?)>/gi);
for(let result of results) {
alert(result);
// 第一个 alert:<h1>,h1
// 第二个:<h2>,h2
}
……或使用解构:
let [tag1, tag2] = '<h1> <h2>'.matchAll(/<(.*?)>/gi);
matchAll
返回的每个匹配项,与不带修饰符 g
的 match
所返回的格式相同:具有额外 index
(字符串中的匹配索引)属性和 input
(源字符串)的数组:
let results = '<h1> <h2>'.matchAll(/<(.*?)>/gi);
let [tag1, tag2] = results;
alert( tag1[0] ); // <h1>
alert( tag1[1] ); // h1
alert( tag1.index ); // 0
alert( tag1.input ); // <h1> <h2>
为什么
matchAll
的结果是可迭代对象而不是数组?为什么这个方法这样设计?原因很简单 —— 为了优化。
调用
matchAll
不会执行搜索。相反,它返回一个可迭代对象,最初没有结果。每次我们迭代它时才会执行搜索,例如在循环中。
因此,这将根据需要找出尽可能多的结果,而不是全部。
例如,文本中可能有 100 个匹配项,但在一个
for..of
循环中,我们找到了 5 个匹配项,然后觉得足够了并做出一个break
。这时引擎就不会花时间查找其他 95 个匹配。
命名组
用数字记录组很困难。对于简单的模式,它是可行的,但对于更复杂的模式,计算括号很不方便。我们有一个更好的选择:给括号命名。
在左括号后紧跟着放置 ?<name>
即可完成对括号的命名。
例如,让我们查找 “year-month-day” 格式的日期:
let dateRegexp = /(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})/;
let str = "2019-04-30";
let groups = str.match(dateRegexp).groups;
alert(groups.year); // 2019
alert(groups.month); // 04
alert(groups.day); // 30
正如你所看到的,匹配的组在 .groups
属性中。
要查找所有日期,我们可以添加修饰符 g
。
我们还需要 matchAll
以获取完整的组匹配:
let dateRegexp = /(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})/g;
let str = "2019-10-30 2020-01-01";
let results = str.matchAll(dateRegexp);
for(let result of results) {
let {year, month, day} = result.groups;
alert(`${day}.${month}.${year}`);
// 第一个 alert:30.10.2019
// 第二个:01.01.2020
}
替换中的捕获组
让我们能够替换 str
中 regexp
的所有匹配项的方法 str.replace(regexp, replacement)
允许我们在 replacement
字符串中使用括号中的内容。这使用 $n
来完成,其中 n
是组号。
例如,
let str = "John Bull";
let regexp = /(\w+) (\w+)/;
alert( str.replace(regexp, '$2, $1') ); // Bull, John
对于命名的括号,引用为 $<name>
。
例如,让我们将日期格式从 “year-month-day” 更改为 “day.month.year”:
let regexp = /(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})/g;
let str = "2019-10-30, 2020-01-01";
alert( str.replace(regexp, '{#content}lt;day>.{#content}lt;month>.{#content}lt;year>') );
// 30.10.2019, 01.01.2020
非捕获组 ?:
有时我们需要用括号才能正确应用量词,但我们不希望它们的内容出现在结果中。
可以通过在开头添加 ?:
来排除组。
例如,如果我们要查找 (go)+
,但不希望括号内容(go
)作为一个单独的数组项,则可以编写:(?:go)+
。
在下面的示例中,我们仅将名称 John
作为匹配项的单独成员:
let str = "Gogogo John!";
// ?: 从捕获组中排除 'go'
let regexp = /(?:go)+ (\w+)/i;
let result = str.match(regexp);
alert( result[0] ); // Gogogo John(完整的匹配项)
alert( result[1] ); // John
alert( result.length ); // 2(在数组中没有其他数组项)
总结
括号将正则表达式中的一部分组合在一起,以便量词可以整体应用。
括号组从左到右编号,可以选择用 (?<name>...)
命名。
可以在结果中获得按组匹配的内容:
- 方法
str.match
仅当不带修饰符 g
时返回捕获组。 - 方法
str.matchAll
始终返回捕获组。
如果括号没有名称,则匹配数组按编号提供其内容。命名括号还可使用属性 groups
。
我们还可以在 str.replace
的替换字符串中使用括号内容:通过数字 $n
或者名称 $<name>
。
可以通过在组的开头添加 ?:
来排除编号。当我们需要对整个组应用量词,但不希望将其作为结果数组中的单独项时这很有用。我们也不能在替换字符串中引用这样的括号。
任务
检查 MAC 地址
网络接口的 MAC 地址 由 6 个以冒号分隔的两位十六进制数字组成。
例如:'01:32:54:67:89:AB'
。
编写一个检查字符串是否为 MAC 地址的正则表达式。
用例:
let regexp = /你的正则表达式/;
alert( regexp.test('01:32:54:67:89:AB') ); // true
alert( regexp.test('0132546789AB') ); // false (没有冒号分隔)
alert( regexp.test('01:32:54:67:89') ); // false (5 个数字,必须为 6 个)
alert( regexp.test('01:32:54:67:89:ZZ') ) // false (尾部为 ZZ)
解决方案
一个两位的十六进制数可以用 [0-9a-f]{2}
(假设已设定修饰符 i
)进行匹配。
我们需要匹配数字 NN
,然后再重复 5 次 :NN
(匹配更多数字);
所以正则表达式为:[0-9a-f]{2}(:[0-9a-f]{2}){5}
现在让我们验证一下此匹配规则可以捕获整个文本:从开头开始,在结尾结束。这是通过将模式包装在 ^...$
中实现的。
最终:
let regexp = /^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/i;
alert( regexp.test('01:32:54:67:89:AB') ); // true
alert( regexp.test('0132546789AB') ); // false (没有分号分隔)
alert( regexp.test('01:32:54:67:89') ); // false (5 个数字,必须为 6 个)
alert( regexp.test('01:32:54:67:89:ZZ') ) // false (尾部为 ZZ)
找出形如 #abc 或 #abcdef 的颜色值
编写一个匹配 #abc
或 #abcdef
格式的颜色值的正则表达式。即:#
后跟着 3 个或 6 个十六进制的数字。
用例:
let regexp = /你的正则表达式/g;
let str = "color: #3f3; background-color: #AA00ef; and: #abcd";
alert( str.match(regexp) ); // #3f3 #AA00ef
P.S. 必须只匹配 3 位或 6 位十六进制数字的颜色值。不应该匹配 4 位数字的值,例如 #abcd
。
解决方案
查找 #
号后跟着 3 位十六进制数的颜色值 #abc
的正则表达式:/#[a-f0-9]{3}/i
。
我们可以再添加 3 位可选的十六进制数字。这样刚好,不多不少。只匹配 #
号后跟着 3 位或 6 位十六进制数字的颜色值。
我们使用量词 {1,2}
来实现:所以正则表达式为 /#([a-f0-9]{3}){1,2}/i
。
这里将模式 [a-f0-9]{3}
用括号括起来,以在其外面应用量词 {1,2}
。
用例:
let regexp = /#([a-f0-9]{3}){1,2}/gi;
let str = "color: #3f3; background-color: #AA00ef; and: #abcd";
alert( str.match(regexp) ); // #3f3 #AA00ef #abc
这里存在一个小问题:上面的模式会匹配 #abcd
中的 #abc
。为避免这一问题,我们可以在最后添加 \b
。
let regexp = /#([a-f0-9]{3}){1,2}\b/gi;
let str = "color: #3f3; background-color: #AA00ef; and: #abcd";
alert( str.match(regexp) ); // #3f3 #AA00ef
找出所有数字
编写一个正则表达式,找出所有十进制数字,包括整数、浮点数和负数。
用例:
let regexp = /你的正则表达式/g;
let str = "-1.5 0 2 -123.4.";
alert( str.match(regexp) ); // -1.5, 0, 2, -123.4
解决方案
带有可选小数部分的正数:\d+(\.\d+)?
。
让我们在开头加上可选的 -
:
let regexp = /-?\d+(\.\d+)?/g;
let str = "-1.5 0 2 -123.4.";
alert( str.match(regexp) ); // -1.5, 0, 2, -123.4
解析表达式
一个算术表达式由 2 个数字和一个它们之间的运算符组成,例如:
1 + 2
1.2 * 3.4
-3 / -6
-2 - 2
运算符为 "+"
、"-"
、"*"
或 "/"
中之一。
在开头、之间的部分或末尾可能有额外的空格。
创建一个函数 parse(expr)
,它接受一个表达式作为参数,并返回一个包含 3 个元素的数组:
- 第一个数字
- 运算符
- 第二个数字
用例:
let [a, op, b] = parse("1.2 * 3.4");
alert(a); // 1.2
alert(op); // *
alert(b); // 3.4
解决方案
匹配数字的正则表达式:-?\d+(\.\d+)?
。我们在上一题创建了这个表达式。
我们可以使用 [-+*/]
匹配运算符。连字符 -
在方括号中的最前面,因为在中间它表示字符范围,而我们只想让其表示字符 -
。
在 JavaScript 正则表达式 /.../
中,我们应该对 /
进行转义,稍后我们会对其进行处理。
我们需要一个数字、一个运算符以及另一个数字。其间可能会有空格。
完整的正则表达式为:-?\d+(\.\d+)?\s*[-+*/]\s*-?\d+(\.\d+)?
。
它包含 3 个部分,以 \s*
分隔:
-?\d+(\.\d+)?
—— 第一个数字,[-+*/]
—— 运算符,-?\d+(\.\d+)?
—— 第二个数字。
为了使这里的每一部分成为结果数组中的单独元素,所以我们把它们括在括号里:(-?\d+(\.\d+)?)\s*([-+*/])\s*(-?\d+(\.\d+)?)
。
使用示例:
let regexp = /(-?\d+(\.\d+)?)\s*([-+*\/])\s*(-?\d+(\.\d+)?)/;
alert( "1.2 + 12".match(regexp) );
结果包括:
result[0] == "1.2 + 12"
(完整的匹配项)result[1] == "1.2"
(第一组(-?\d+(\.\d+)?)
—— 第一个数字,包括小数部分)result[2] == ".2"
(第二组(\.\d+)?
—— 第一个数字的小数部分)result[3] == "+"
(第三组([-+*\/])
—— 运算符)result[4] == "12"
(第四组(-?\d+(\.\d+)?)
—— 第二个数字)result[5] == undefined
(第五组(\.\d+)?
—— 第二个数字的小数部分不存在,所以这里是 undefined)
我们只想要数字和运算符,不需要完全匹配的以及小数部分结果,所以让我们稍微“清理”一下结果。
我们可以使用数组的 shift
方法 result.shift()
来删去完全匹配的结果(数组的第一项)。
可以通过在开头添加 ?:
来排除包含小数部分(数字 2 和 4)(.\d+)
的组:(?:\.\d+)?
。
最终的解决方案:
function parse(expr) {
let regexp = /(-?\d+(?:\.\d+)?)\s*([-+*\/])\s*(-?\d+(?:\.\d+)?)/;
let result = expr.match(regexp);
if (!result) return [];
result.shift();
return result;
}
alert( parse("-1.23 * 3.45") ); // -1.23, *, 3.45
更多建议: