阐述 JavaScript RegExp (正则表达式) 引擎的贪婪 (Greedy) 与非贪婪 (Non-Greedy) 匹配,以及 lookahead 和 lookbehind 断言的高级用法。

各位靓仔靓女们,今天咱们来聊聊 JavaScript 正则表达式这玩意儿,特别是它那“贪得无厌”和“谦让有礼”的匹配方式,还有那神秘莫测的“前后瞻顾”断言。准备好了吗?系好安全带,开始咱们的正则表达式之旅!

开场白:正则表达式,你这磨人的小妖精!

正则表达式,简称 RegExp,是用来匹配文本模式的强大工具。在 JavaScript 的世界里,它就像一把瑞士军刀,可以用来搜索、替换、验证各种各样的字符串。但是,这把刀用得不好,也会割到手的。今天,咱们就来学习如何更好地驾驭这把刀。

第一章:贪婪模式 (Greedy Matching) – 贪吃蛇的本性

默认情况下,JavaScript 的 RegExp 引擎是“贪婪”的。这意味着它会尽可能多地匹配字符,直到整个字符串的末尾,然后才开始回溯,寻找可能的匹配项。

举个栗子:

const str = "<h1>Hello</h1><h2>World</h2>";
const greedyRegex = /<.*>/; // 贪婪模式
const match = str.match(greedyRegex);

console.log(match); // 输出: ["<h1>Hello</h1><h2>World</h2>", index: 0, input: "<h1>Hello</h1><h2>World</h2>", groups: undefined]

在这个例子中,/<.*>/ 表达式试图匹配所有以 < 开头,以 > 结尾的内容。由于贪婪模式的本性,它一口气吞掉了整个字符串,从第一个 < 到最后一个 >,形成了一个超大的匹配项。

贪婪模式的特点:

  • 尽可能多地匹配。
  • 回溯以寻找可能的匹配项。
  • 是 RegExp 引擎的默认行为。

第二章:非贪婪模式 (Non-Greedy Matching) – 谦让的绅士

有时候,我们并不希望 RegExp 引擎这么贪婪,而是希望它尽可能少地匹配。这时候,就要请出我们的“非贪婪模式”了。要开启非贪婪模式,只需在量词(如 *, +, ?, {})后面加上一个问号 ?

还是上面的例子,咱们换成非贪婪模式:

const str = "<h1>Hello</h1><h2>World</h2>";
const nonGreedyRegex = /<.*?>/; // 非贪婪模式
const match = str.match(nonGreedyRegex);

console.log(match); // 输出: ["<h1>", index: 0, input: "<h1>Hello</h1><h2>World</h2>", groups: undefined]

这次,/<.*?>/ 表达式只匹配了第一个 <h1>,因为它在遇到第一个 > 的时候就停止了匹配,不再继续吞噬后面的字符。

非贪婪模式的特点:

  • 尽可能少地匹配。
  • 在量词后添加 ? 开启。
  • 也叫做“懒惰模式” (Lazy Matching)。

贪婪与非贪婪的对比:

特性 贪婪模式 (Greedy) 非贪婪模式 (Non-Greedy)
匹配方式 尽可能多 尽可能少
符号 ?
适用场景 需要匹配大块内容时 需要精确匹配时

第三章:前后瞻顾 (Lookaround) – 侦察兵的视角

前后瞻顾断言允许我们在匹配一个模式的同时,检查该模式的前面或后面的字符是否满足某些条件,但又不将这些字符包含在最终的匹配结果中。这就像一个侦察兵,先观察敌情,再决定是否行动。

前后瞻顾断言分为四种:

  1. 正向肯定预查 (Positive Lookahead): (?=pattern)

    • 匹配后面跟着 pattern 的内容,但不包含 pattern
  2. 正向否定预查 (Negative Lookahead): (?!pattern)

    • 匹配后面不跟着 pattern 的内容。
  3. 反向肯定预查 (Positive Lookbehind): (?<=pattern)

    • 匹配前面是 pattern 的内容,但不包含 pattern。(注意:并非所有 JavaScript 引擎都支持反向预查,特别是老版本的浏览器。)
  4. 反向否定预查 (Negative Lookbehind): (?<!pattern)

    • 匹配前面不是 pattern 的内容。(同样,并非所有 JavaScript 引擎都支持反向预查。)

3.1 正向肯定预查 (Positive Lookahead) – 瞄准目标

假设我们想匹配所有后面跟着 USD 的数字,但不包含 USD 本身:

const str = "Price: 100USD, Discount: 20USD";
const regex = /d+(?=USD)/g; // 正向肯定预查
const match = str.match(regex);

console.log(match); // 输出: ["100", "20"]

这里,/d+(?=USD)/g 表达式的意思是:匹配一个或多个数字 (d+),但只有当这些数字后面紧跟着 USD 时才匹配。(?=USD) 就是正向肯定预查,它只检查后面的字符,但不包含在最终的匹配结果中。

3.2 正向否定预查 (Negative Lookahead) – 排除异己

现在,我们想匹配所有后面不跟着 USD 的数字:

const str = "Price: 100USD, Quantity: 50, Tax: 10EUR";
const regex = /d+(?!USD)/g; // 正向否定预查
const match = str.match(regex);

console.log(match); // 输出: ["50", "10"]

/d+(?!USD)/g 表达式的意思是:匹配一个或多个数字 (d+),但只有当这些数字后面没有紧跟着 USD 时才匹配。(?!USD) 就是正向否定预查,它确保匹配的数字后面不是 USD

3.3 反向肯定预查 (Positive Lookbehind) – 确认身份

假设我们想匹配所有前面是 $ 符号的数字,但不包含 $ 符号(注意:部分浏览器可能不支持):

const str = "Price: $100, Discount: $20";
const regex = /(?<=$)(d+)/g; // 反向肯定预查
const match = str.match(regex);

console.log(match); // 输出: ["100", "20"]

/(?<=$)(d+)/g 表达式的意思是:匹配一个或多个数字 (d+),但只有当这些数字前面紧跟着 $ 符号时才匹配。(?<=$) 就是反向肯定预查,它只检查前面的字符,但不包含在最终的匹配结果中。

3.4 反向否定预查 (Negative Lookbehind) – 排除嫌疑

现在,我们想匹配所有前面不是 $ 符号的数字(同样,部分浏览器可能不支持):

const str = "Price: $100, Quantity: 50, Tax: €10";
const regex = /(?<!$)d+/g; // 反向否定预查
const match = str.match(regex);

console.log(match); // 输出: ["50", "10"] (取决于浏览器支持情况)

/(?<!$)d+/g 表达式的意思是:匹配一个或多个数字 (d+),但只有当这些数字前面没有紧跟着 $ 符号时才匹配。(?<!$) 就是反向否定预查,它确保匹配的数字前面不是 $ 符号。

前后瞻顾断言的对比:

断言类型 符号 作用
正向肯定预查 (Lookahead) (?=pattern) 匹配后面跟着 pattern 的内容,但不包含 pattern
正向否定预查 (Lookahead) (?!pattern) 匹配后面不跟着 pattern 的内容。
反向肯定预查 (Lookbehind) (?<=pattern) 匹配前面是 pattern 的内容,但不包含 pattern。(部分引擎不支持)
反向否定预查 (Lookbehind) (?<!pattern) 匹配前面不是 pattern 的内容。(部分引擎不支持)

第四章:实战演练 – 正则表达式的妙用

说了这么多理论,咱们来几个实际的例子,看看如何运用这些知识解决实际问题。

4.1 验证密码强度:

假设我们需要验证一个密码是否符合以下条件:

  • 至少包含一个大写字母
  • 至少包含一个小写字母
  • 至少包含一个数字
  • 长度至少为 8 位

我们可以使用前后瞻顾断言来实现:

function validatePassword(password) {
  const regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*d).{8,}$/;
  return regex.test(password);
}

console.log(validatePassword("P@sswOrd123")); // 输出: true
console.log(validatePassword("password")); // 输出: false
console.log(validatePassword("Password1")); // 输出: false

解释:

  • ^: 匹配字符串的开头。
  • (?=.*[a-z]): 正向肯定预查,确保字符串中至少包含一个小写字母。
  • (?=.*[A-Z]): 正向肯定预查,确保字符串中至少包含一个大写字母。
  • (?=.*d): 正向肯定预查,确保字符串中至少包含一个数字。
  • .{8,}: 匹配任意字符,至少 8 位。
  • $: 匹配字符串的结尾。

4.2 提取 HTML 标签的内容:

假设我们想从一段 HTML 代码中提取所有 <a> 标签的 href 属性值:

const html = '<a href="https://www.google.com">Google</a><a href="https://www.baidu.com">Baidu</a>';
const regex = /<a href="(?<url>.*?)">/g;
let match;
let urls = [];

while ((match = regex.exec(html)) !== null) {
  urls.push(match.groups.url);
}

console.log(urls); // 输出: ["https://www.google.com", "https://www.baidu.com"]

解释:

  • <a href=": 匹配 <a> 标签的开头。
  • (?<url>.*?): 使用命名捕获组 url 匹配 href 属性值,使用非贪婪模式,尽可能少地匹配。
  • ">: 匹配 <a> 标签的结尾。
  • regex.exec(html): 循环匹配 HTML 代码,并将匹配结果存储在 match 变量中。
  • match.groups.url: 从 match 对象中提取命名捕获组 url 的值。

4.3 替换字符串中的特定单词:

假设我们想将字符串中所有出现的 "apple" 替换为 "orange",但只替换独立的单词 "apple",而不是 "pineapple" 中的 "apple":

const str = "I like apple and pineapple.";
const regex = /bappleb/g; // b 表示单词边界
const newStr = str.replace(regex, "orange");

console.log(newStr); // 输出: "I like orange and pineapple."

解释:

  • b: 匹配单词边界,确保匹配的是独立的单词 "apple"。
  • apple: 匹配单词 "apple"。
  • b: 匹配单词边界。
  • str.replace(regex, "orange"): 将匹配到的 "apple" 替换为 "orange"。

第五章:注意事项 – 正则表达式的坑

正则表达式很强大,但也容易出错。以下是一些需要注意的地方:

  • 性能问题: 复杂的正则表达式可能会导致性能问题,特别是当处理大量文本时。尽量简化正则表达式,避免过度使用回溯。
  • 可读性问题: 复杂的正则表达式难以阅读和维护。添加注释,将正则表达式分解成更小的部分,可以提高可读性。
  • 兼容性问题: 不同的 JavaScript 引擎对正则表达式的支持程度可能有所不同。特别是反向预查,在老版本的浏览器中可能无法使用。
  • 转义问题: 正则表达式中的一些字符具有特殊含义,需要进行转义。例如,要匹配 . 字符,需要使用 .

总结:正则表达式的修炼之路

正则表达式是一门需要不断学习和实践的技能。掌握贪婪与非贪婪匹配、前后瞻顾断言,以及各种正则表达式的语法和技巧,可以让你在文本处理方面事半功倍。

记住,正则表达式就像一把锋利的宝剑,用得好可以披荆斩棘,用得不好则会伤到自己。希望今天的讲座能帮助你更好地驾驭这把宝剑,在编程的道路上越走越远!

最后,留几个思考题给大家:

  1. 如何使用正则表达式提取一个字符串中的所有邮箱地址?
  2. 如何使用正则表达式验证一个字符串是否是有效的 IP 地址?
  3. 如何使用正则表达式将一个字符串中的所有 HTML 标签去除?

欢迎大家在评论区分享你的答案和心得体会!咱们下期再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注