关于正则位置匹配(断言)的技巧

9,367

正则位置匹配

先了解下以下几个概念

  • 零宽:只匹配位置,在匹配过程中,不占用字符,所以被称为零宽

  • 先行:正则引擎在扫描字符的时候,从左往右扫描,匹配扫描指针未扫描过的字符,先于指针,故称先行

  • 后行:匹配指针已扫描过的字符,后于指针到达该字符,故称后行,即产生回溯

  • 正向:即匹配括号中的表达式

  • 负向:不匹配括号中的表达式

es5 就支持了先行断言

es2018 才支持后行断言

零宽正向先行断言,又称正向向前查找(positive lookhead)

注意: .在正则里面代表匹配除换行符,回车符等少数空白字符之外的任何字符,匹配其时需要转义

regexp(?=pattern):regexp匹配的字符串后面紧接着的字符序列要匹配 pattern

例:

`sinM.`.match(/sin(?=M\.)/g); // ["sin"]
`M.sin`.match(/sin(?=M\.)/g); // null

第一个 sin 会匹配,因为他后面有 pattern

零宽负向先行断言,又称负向向前查找(negative lookhead)

regexp(?!pattern):regexp匹配的字符串后面紧接着的字符序列不能匹配 pattern

例:

`M.sin`.match(/sin(?!M\.)/g); // ["sin"]
`sinM.`.match(/sin(?!M\.)/g); // null

第一个 sin 会匹配,因为他后面没有 pattern

零宽正向后行断言,又称正向向后查找(positive lookbehind)

(?<=pattern)regexp:regexp匹配的字符串前面紧接着的字符序列要匹配 pattern

例:

'sinM.'.match(/(?<=M\.)sin/g); // null
'M.sin'.match(/(?<=M\.)sin/g); // ["sin"]

第二个 sin 会匹配,因为它前面有 pattern

零宽负向后行断言,又称负向向后查找(negative lookbehind)

(?<!pattern)regexp:regexp匹配的字符串前面紧接着的字符序列不能匹配 pattern

例:

'sinM.'.match(/(?<!M\.)sin/g); // ["sin"]
'M.sin'.match(/(?<!M\.)sin/g); // null

第一个 sin 会匹配,因为它前面没有 pattern


来看个实际的例子,把4+6*sqrt(5)*Math.sqrt(5)转换成可以通过eval或者new Function()获得实际结果的字符串

这个可以使用负向后行断言,即替换前面不紧接 Math.的 sqrt 字符串序列

let s = `4+6*sqrt(5)*Math.sqrt(5)`.replace(/(?<!Math\.)sqrt/g, func => `Math.${func}`);
eval(s); // 34

第二个例子: 匹配 url 后面的路径

'https://www.google.com/v3/api/getUser?user=panghu'.match(/(?<=\.\w*(?=\/)).*/);

第三个例子:替换字符串中 img 标签的 width 为 100%

'<img id = "23" style="width:999x;"/><img id = "23" style="width:999x;"/>'.replace(
  /(?<=(<img[\s\S]*width:\s*))[^("\/);]*/gm,
  '100%'
);

匹配 sin

'M.sin'.match(/(?<=M\.)sin/g); // ["sin"]
`M.sin`.match(/sin(?!M\.)/g); // ["sin"]

这两种方法都可以实现同样的效果,但我个人更喜欢使用第一种方法,它的写法更符合人的直接思维习惯

在全局匹配修饰符 g 作用下正则 test 方法出现的“怪异”结果

先看下面两行代码的运行结果

let reg = /js/g;
reg.test('js'); //before: lastIndex:0, after: lastIndex:2
reg.test('js'); //before: lastIndex:2, after: lastIndex:0
reg.test('js'); //before: lastIndex:0, after: lastIndex:2

如果你的答案是三个 true 的话,那就错了 答案其实是 true、false、true,这就是所谓的怪异现象

为什么?答: RegExp 对象有个 lastIndex 属性,它的初始值是 0, 当不使用 g 修饰符修饰时,每次执行 test 方法之后它都会自动置 0 而使用 g 修饰符时,每次执行 test 方法的时候,它都是从索引值为 lastIndex 的位置开始匹配,lastIndex 为匹配到的字符序列下一个索引值。只有当匹配失败以后才会将 lastIndex 置为 0

例:上述例子中的第一个 test 方法执行之前,lastIndex 值为 0,执行之后 lastIndex 值为 2,于是当第二次执行 test 方法时,从字符串索引值为 2 处开始匹配,显然会匹配失败,所以第三次匹配时又会匹配成功

匹配含 class 为 root 的标签(不考虑特殊情况), 如<div class="root">

这里可以涉及到的知识点有:贪婪/非贪婪匹配模式匹配回溯及其消除分组反向引用

基础版:只匹配双引号包裹的 class

`<div class="root"><span class="root"></span><i class='root'></i></div>`.match(/<[^>]*class="root".*?>/g);
// ["<div class="root">", "<span class="root">"]

模式匹配[^>]表示匹配除[^]里面的所有字符,这里就是匹配除>外的所有字符 注意前后都需要非贪婪匹配符号?否则只有前面的,它会贪婪的吃掉 div;只有后面的,它会贪婪的吃掉 span

完整版:单双引号包裹的 class 都可以匹配

`<div class="root"><span class="root"></span><i class='root'></i></div>`.match(/<[^>]*class=("root"|'root').*?>/g);
// ["<div class="root">", "<span class="root">", "<i class='root'>"]

这里如果不使用[^>]而使用.*就会出现下面这种匹配结果,不是我们想要的

["<div class="root">", "<span class="root">", "</span><i class='root'>"]

进阶版:使用分组引用消除难看的("root"|'root'),再消除.*?回溯

`<div class="root"><span class="root"></span><i class='root'></i></div>`.match(/<[^>]*class=("|')root\1[^>]*>/g);
// ["<div class="root">", "<span class="root">", "<i class='root'>"]

\1表示引用前面的第一个分组结果,即("|')的匹配结果,这样就能保证单引号配对单引号,双引号匹配双引号

[^>]*代替.*?可以消除使用*?引发的回溯,因为*是尽可能多的匹配,而?是尽可能少的匹配

回顾开头,我所说的特殊情况就是标签的属性值不能含有>,因为为了消除回溯使用的[^>]含有字符>,这部分其实可以使用其他正则代替,让它在消除回溯的情况下可以匹配特殊情况

如果大家对匹配含 class 为 root 的标签这部分涉及的知识点感兴趣,可以在底下评论,我到时候再仔细讲

如果你喜欢这篇文章的话,麻烦点个⭐原文地址资瓷下

参考:

JavaScript 权威指南(第 6 版)

Javascript 正则表达式迷你书

以上如有错误,欢迎指正