MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

JavaScript正则表达式高级匹配与优化

2023-12-037.3k 阅读

1. JavaScript 正则表达式基础回顾

在深入探讨高级匹配与优化之前,先简单回顾一下正则表达式的基础概念。正则表达式是用于匹配文本模式的工具,在 JavaScript 中,它通过 RegExp 对象或者字面量形式创建。

1.1 创建正则表达式

  • 字面量形式
const pattern1 = /hello/;

这里的 /hello/ 就是一个简单的正则表达式,它用于匹配字符串中的 hello 文本。

  • RegExp 构造函数形式
const pattern2 = new RegExp('hello');

这两种方式创建的正则表达式功能基本相同,但字面量形式在性能上略胜一筹,因为它在解析时就被编译,而构造函数形式是在运行时编译。

1.2 基本元字符

  • 字符类
    • [abc]:匹配方括号内的任意一个字符,例如 /[abc]/ 可以匹配 abc
    • [^abc]:匹配除了方括号内字符以外的任意字符,如 /[^abc]/ 可以匹配 de 等,但不能匹配 abc
  • 量词
    • *:匹配前面的字符零次或多次。例如 /a*/ 可以匹配空字符串、aaaaaa 等。
    • +:匹配前面的字符一次或多次。如 /a+/ 可以匹配 aaaaaa 等,但不能匹配空字符串。
    • ?:匹配前面的字符零次或一次。比如 /a?/ 可以匹配空字符串或 a
    • {n}:匹配前面的字符恰好 n 次。例如 /a{3}/ 只匹配 aaa
    • {n,}:匹配前面的字符至少 n 次。如 /a{3,}/ 匹配 aaaaaaa 等。
    • {n,m}:匹配前面的字符至少 n 次,最多 m 次。例如 /a{3,5}/ 匹配 aaaaaaaaaaaa

1.3 边界匹配

  • ^:匹配字符串的开始位置。例如 /^hello/ 只有在字符串以 hello 开头时才匹配。
  • $:匹配字符串的结束位置。如 /world$/ 只有在字符串以 world 结尾时才匹配。

2. 高级匹配模式

2.1 分组与捕获

  • 分组:通过圆括号 () 可以将正则表达式的一部分分组。例如 /ab(cd)/,这里的 (cd) 就是一个分组。分组可以作为一个整体应用量词,比如 /ab(cd)+/ 表示 ab 后面跟着一个或多个 cd
  • 捕获:默认情况下,分组会捕获匹配到的文本。可以通过 RegExp 对象的 exec 方法或字符串的 match 方法获取捕获的内容。
const str = 'abcde';
const pattern = /ab(cd)/;
const result = pattern.exec(str);
console.log(result); 
// 输出: ["abcd", "cd", index: 0, input: "abcde", groups: undefined]
// 第一个元素是整个匹配的字符串,第二个元素是第一个分组捕获的内容

2.2 非捕获分组

有时候我们只是想分组,但不想捕获其中的内容,这时可以使用非捕获分组 (?:pattern)。例如 /ab(?:cd)+/,这里的 (?:cd) 分组不会捕获匹配的 cd 内容。

const str2 = 'abcde';
const pattern2 = /ab(?:cd)+/;
const result2 = pattern2.exec(str2);
console.log(result2); 
// 输出: ["abcd", index: 0, input: "abcde", groups: undefined]
// 没有捕获到分组内容

2.3 反向引用

反向引用允许我们在正则表达式中引用之前捕获的分组内容。语法是 \n,其中 n 是分组的编号(从 1 开始)。例如,要匹配两个连续相同的单词,可以这样写:

const str3 = 'hello hello world';
const pattern3 = /(\w+) \1/;
const result3 = pattern3.exec(str3);
console.log(result3); 
// 输出: ["hello hello", "hello", index: 0, input: "hello hello world", groups: undefined]
// 匹配到了连续相同的单词 "hello hello"

2.4 零宽断言

零宽断言是一种特殊的匹配模式,它匹配的位置是零宽度的,即不消耗字符。

  • 正向先行断言(?=pattern),断言所在位置的后面能匹配 pattern。例如,要匹配以 ing 结尾的单词,但不包括 ing 部分,可以用 /\w+(?=ing)/
const str4 = 'running jumping';
const pattern4 = /\w+(?=ing)/g;
const result4 = str4.match(pattern4);
console.log(result4); 
// 输出: ["run", "jump"]
  • 负向先行断言(?!pattern),断言所在位置的后面不能匹配 pattern。比如,要匹配不是以 ing 结尾的单词,可以用 /\w+(?!ing)/
const str5 = 'running jumping play';
const pattern5 = /\w+(?!ing)/g;
const result5 = str5.match(pattern5);
console.log(result5); 
// 输出: ["play"]
  • 正向回顾后发断言(?<=pattern),断言所在位置的前面能匹配 pattern。例如,在 JavaScript 正则表达式中,要匹配 $ 符号后面的数字,可以用 /(?<=\$)\d+/。不过需要注意的是,JavaScript 正则表达式在 ES2018 之前不支持正向回顾后发断言,在支持的环境下:
const str6 = 'price: $100';
const pattern6 = /(?<=\$)\d+/;
const result6 = pattern6.exec(str6);
console.log(result6); 
// 输出: ["100", index: 7, input: "price: $100", groups: undefined]
  • 负向回顾后发断言(?<!pattern),断言所在位置的前面不能匹配 pattern。同样在支持的环境下,例如要匹配前面不是 $ 符号的数字,可以用 /(?<!\$)\d+/

3. 正则表达式的优化

3.1 减少回溯

回溯是正则表达式匹配过程中的一种机制,当某个分支匹配失败时,正则表达式引擎会尝试其他可能的匹配路径。过多的回溯会导致性能问题。

  • 贪婪与非贪婪量词
    • 贪婪量词:像 *+{n,} 等默认是贪婪的,它们会尽可能多地匹配字符。例如 /a.*b/ 匹配 a123b456b 时,会匹配 a123b456b 整个字符串。
    • 非贪婪量词:在贪婪量词后面加上 ? 就变成了非贪婪量词。如 /a.*?b/ 匹配 a123b456b 时,只会匹配 a123b,因为它尽可能少地匹配字符。
const str7 = 'a123b456b';
const greedyPattern = /a.*b/;
const nonGreedyPattern = /a.*?b/;
console.log(str7.match(greedyPattern)); 
// 输出: ["a123b456b"]
console.log(str7.match(nonGreedyPattern)); 
// 输出: ["a123b"]

通过合理使用非贪婪量词,可以减少不必要的回溯,提高匹配效率。

  • 消除分支结构中的冗余:当正则表达式中有分支结构 (pattern1|pattern2) 时,如果 pattern1pattern2 有重叠部分,要注意优化。例如,/(abc|ab)/ 这种写法中,ababc 的前缀,会导致不必要的回溯。可以改为 /(abc|^ab(?!c))/,这样先匹配 abc,如果不匹配再匹配 ab 且后面不是 c 的情况,减少了回溯。

3.2 预编译正则表达式

正如前面提到的,使用字面量形式创建正则表达式在解析时就被编译,而构造函数形式是在运行时编译。如果在循环等频繁使用正则表达式的场景下,应该优先使用预编译的字面量形式。如果必须使用构造函数形式,可以将其提取到循环外部进行预编译。

// 不好的做法,每次循环都编译
for (let i = 0; i < 1000; i++) {
    const pattern = new RegExp('hello');
    const str = 'hello world';
    pattern.test(str);
}
// 好的做法,预编译
const pattern = new RegExp('hello');
for (let i = 0; i < 1000; i++) {
    const str = 'hello world';
    pattern.test(str);
}

3.3 使用正确的标志

正则表达式有几个标志,如 g(全局匹配)、i(不区分大小写)、m(多行匹配)等。使用不当可能会影响性能。

  • g 标志:如果不需要全局匹配,就不要使用 g 标志。因为全局匹配会在每次匹配成功后继续查找下一个匹配项,增加了处理时间。例如,只是判断字符串中是否包含某个模式,使用非全局匹配即可。
const str8 = 'hello world';
// 不需要全局匹配时
const pattern7 = /hello/;
console.log(pattern7.test(str8)); 
// 好的方式
const pattern8 = /hello/g;
const result7 = pattern8.test(str8); 
// 虽然结果一样,但使用 g 标志会多一些不必要的处理
  • i 标志:如果确定匹配的文本是区分大小写的,就不要使用 i 标志。因为不区分大小写匹配需要对更多的字符组合进行检查,增加了匹配的复杂度。

3.4 避免过度复杂的正则表达式

有时候,用多个简单的正则表达式分步处理可能比一个复杂的正则表达式更高效。例如,要从一段 HTML 文本中提取所有链接。如果使用一个非常复杂的正则表达式来匹配整个 <a href="...">...</a> 结构,可能会因为回溯等问题导致性能下降。可以先使用简单的正则表达式匹配 <a 标签开始,再分别处理 href 属性和链接文本等部分。

const html = '<a href="https://example.com">link</a>';
// 先匹配 <a 标签开始
const aTagPattern = /<a\s+/;
const aTagMatch = aTagPattern.exec(html);
if (aTagMatch) {
    // 再匹配 href 属性
    const hrefPattern = /href="([^"]+)"/;
    const hrefMatch = hrefPattern.exec(html.slice(aTagMatch.index));
    if (hrefMatch) {
        console.log(hrefMatch[1]); 
    }
}

这样分步处理,虽然代码看起来多了一些,但在性能上可能更优,尤其是处理复杂文本时。

4. 实战案例

4.1 验证邮箱地址

邮箱地址的格式有多种规则,一个常见的正则表达式可以写成:

const emailPattern = /^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$/;
function validateEmail(email) {
    return emailPattern.test(email);
}
console.log(validateEmail('test@example.com')); 
// 输出: true
console.log(validateEmail('test.example.com')); 
// 输出: false

这里使用了字符类、量词、边界匹配等知识。^[a-zA-Z0-9_.+-]+ 匹配邮箱用户名部分,@[a-zA-Z0-9-]+ 匹配 @ 符号和域名主体部分,\.[a-zA-Z0-9-.]+$ 匹配域名后缀部分。

4.2 提取 URL 中的参数

假设 URL 格式为 http://example.com?param1=value1&param2=value2,要提取其中的参数,可以这样写:

const url = 'http://example.com?param1=value1&param2=value2';
const paramPattern = /([^?=&]+)=([^&]*)/g;
const params = {};
let match;
while (match = paramPattern.exec(url)) {
    params[match[1]] = match[2];
}
console.log(params); 
// 输出: {param1: "value1", param2: "value2"}

这里使用了全局匹配标志 g,通过分组捕获每个参数名和参数值,并存储到一个对象中。

4.3 替换 HTML 标签内的文本

例如,将 <p>old text</p> 中的 old text 替换为 new text,可以这样实现:

const html2 = '<p>old text</p>';
const replacePattern = /<p>(.*?)<\/p>/;
const newHtml = html2.replace(replacePattern, `<p>new text</p>`);
console.log(newHtml); 
// 输出: <p>new text</p>

这里使用了非贪婪量词 .*? 来匹配 <p></p> 标签之间的内容,然后通过 replace 方法进行替换。

5. 正则表达式与性能测试

为了更好地优化正则表达式,我们可以使用性能测试工具。在 JavaScript 中,可以使用 console.time()console.timeEnd() 来简单地测试一段代码的执行时间。

const str9 = 'a'.repeat(10000);
const pattern9 = /a{5000}/;
console.time('match');
pattern9.test(str9);
console.timeEnd('match'); 

这里通过 console.time('match') 开始计时,console.timeEnd('match') 结束计时,并输出执行 pattern9.test(str9) 所用的时间。对于更复杂的性能测试,可以使用 benchmark 库。

const Benchmark = require('benchmark');
const suite = new Benchmark.Suite;
const str10 = 'a'.repeat(10000);
const pattern10 = /a{5000}/;
const pattern11 = /a{4999}/;
suite
.add('match pattern10', function() {
    pattern10.test(str10);
})
.add('match pattern11', function() {
    pattern11.test(str10);
})
.on('cycle', function(event) {
    console.log(String(event.target));
})
.on('complete', function() {
    console.log('Fastest is'+ this.filter('fastest').map('name'));
})
.run({ 'async': true });

这个例子中,使用 benchmark 库对两个类似的正则表达式 pattern10pattern11 进行性能测试,on('cycle') 事件输出每个测试用例的结果,on('complete') 事件输出最快的测试用例。通过这样的性能测试,可以直观地了解不同正则表达式的性能差异,从而进行针对性的优化。

6. 正则表达式的兼容性

虽然 JavaScript 的正则表达式遵循 ECMAScript 标准,但不同的 JavaScript 引擎在实现上可能存在一些细微的差异,尤其是在一些较新的特性支持上。

6.1 零宽断言的兼容性

如前面提到的正向回顾后发断言 (?<=pattern) 和负向回顾后发断言 (?<!pattern),在 ES2018 之前的 JavaScript 引擎中不支持。如果需要在不支持的环境中使用类似功能,可以通过一些替代方法。例如,对于正向回顾后发断言的需求,可以通过字符串截取和普通匹配来模拟。

// 模拟正向回顾后发断言匹配 $ 符号后面的数字
const str11 = 'price: $100';
const dollarIndex = str11.indexOf('$');
if (dollarIndex!== -1) {
    const subStr = str11.slice(dollarIndex + 1);
    const numberPattern = /\d+/;
    const numberMatch = numberPattern.exec(subStr);
    if (numberMatch) {
        console.log(numberMatch[0]); 
    }
}

6.2 Unicode 支持

JavaScript 正则表达式对 Unicode 的支持在不断改进。在较新的引擎中,可以使用 u 标志来启用完整的 Unicode 支持。例如,要匹配一个 Unicode 字符 😀,可以这样写:

const emojiPattern = /😀/u;
console.log(emojiPattern.test('😀')); 
// 输出: true

如果在不支持 u 标志的引擎中,可能无法正确匹配 Unicode 字符中的一些复杂情况,比如代理对表示的字符。因此,在处理涉及 Unicode 的正则表达式时,要注意目标环境的支持情况。

通过深入理解 JavaScript 正则表达式的高级匹配模式和优化技巧,结合性能测试和兼容性考虑,我们可以在实际开发中更高效地使用正则表达式来处理各种文本匹配和处理任务。无论是验证用户输入、提取数据还是进行文本替换,正则表达式都是一个强大而灵活的工具。但同时也要注意合理使用,避免过度复杂的表达式导致性能问题和维护困难。