文章

正则表达式

致读者:如果你是初学者,完全不懂正则,那么我推荐你先阅读这篇文章

案例解决方案

vscode 中正则匹配英文单词左右没有空格的单词

1
[^ \n\w\[\(\/\.\-\?\* %"<=`+:;#{'。,、(:“][a-zA-Z]+

解释:要求 [a-zA-Z]+ 前面不能是以下字符:

  • \n 行首
  • \w, \(, \/, \[, \., \-, \?, \*
  • ` ` 空格
  • %, ", <, =, +, :, ;, #, {, ', `
  • , , , , ,
1
2
[^ \*]\*{1,3}\w+
考虑到某些单词会被加粗或设置为斜体,所以还需要搜索一下这类单词前面没有空格的情况。

字符串转换为数组:每两个字符作为一个元素

1
2
console.log('1234567'.match(/.{1,2}/g))
// [ '12', '34', '56', '7' ]

^(?<=) 一起使用

1
2
3
4
const str = 'hello, world'
console.log(str.match(/^(?<=hello, )(?<n>.*)$/)) // null
console.log(str.match(/(?<=^hello, )(?<n>.*)$/)) // 有输出
// 可见,^ 应该放在 (?<=) 里面

获取 // 注释后的内容

1
2
3
4
5
6
const str = `
    // Controls whether the diff editor shows empty decorations to see where characters got inserted or deleted.
    // Controls whether the diff editor should show detected code moves.
`
const re = new RegExp('(?<=//)(?<comment>.*)', 'g')
console.log(str.match(re))

基础知识

标志(flags)

flags对应的 RegExp 属性描述
gglobal全局匹配
iignoreCase忽略大小写
sdotAll允许 . 匹配行终结符(回车换行符)
mmultiline允许多行搜索,即 ^$ 将跨行
dhasIndices允许匹配子串时生成索引
uunicode允许使用 unicode 码的模式进行匹配
vunicodeSets允许使用 unicode 属性转义集合
ysticky允许粘性搜索,从目标字符串的当前位置开始匹配

RegExp 对象

一种有两种方式可以创建正则对象:

1
2
3
4
5
6
7
8
// 字面量方式创建。好处:快捷方便、无需对 \ 转义
const re = /ab+c/i
const re2 = /\*/


// new 对象方式。好处:可以使用变量,但需要对 \ 转义
const reg = new RegExp('ab+c', 'i')
const reg = new RegExp('\\*')

被废弃的静态属性/构造函数属性:

  • input($_) 最后搜索的字符串
  • lastMatch($&) 最后匹配的字符串
  • lastParen($+) 最后匹配的捕获组
  • leftContext($`) input 字符串中出现在 lastMatch 前面的文本
  • rightContext($') input 字符串中出现在 lastMatch 后面的文本

实例属性

实例属性说明
ignoreCase判断是否使用了 i 标志
global判断是否使用了 g 标志
dotAll判断是否使用了 s 标志
multiline判断是否使用了 m 标志
unicode判断是否使用了 u 标志
sticky判断是否使用了 y 标志
hasIndices判断是否使用了 d 标志
unicodeSets判断是否使用了 v 标志
flags返回一个由该对象使用的所有标志组成的字符串
source获取正则匹配模版
lastIndex需开启 gy 标志才有效。用于指定下一次匹配的起始索引

简单案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
console.log((/fooBar/gi).source)
// 输出 fooBar

console.log(new RegExp().source);
// 输出 (?:)

console.log((/\n/).source);
// 输出 \n
console.log((/\n/).source === '\\n');
// 输出 true (starting with ES5)
// Due to escaping

console.log((/\\n/).source);
// 输出 \\n
console.log((/\\n/).source === '\\\\n')
// 输出 true

实例方法

实例方法说明
exec执行一次搜索匹配,返回数组或 null
test判断一个字符是否匹配

简单案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 注意要开启 g,不然每次 exec 时不会更新 lastIndex,这样会导致后面的 while 变成死循环
const reg = RegExp('foo[^b]', 'g')
const str = 'table football, foosball'
let result

while ((result = reg.exec(str)) !== null) {
    console.log(`匹配到 ${result[0]} 下标位置是 ${result.index} 下一次将从下标 ${reg.lastIndex} 继续匹配`)
    // 匹配到 foot 下标位置是 6 下一次将从下标 10 继续匹配
    // 匹配到 foos 下标位置是 16 下一次将从下标 20 继续匹配
}

const str2 = 'foobar'
console.log(reg.test(str2))
// false

内部方法

内部方法说明
@@split 
@@match 
@@matchAll需开启 g 标志
@@search 
@@replace 

String 对象实现了上面五种内部方式。

简单案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const str = 'date: 2024-01_02'

console.log(str.split(/-|_/))
// [ 'date: 2024', '01', '02' ]

console.log(str.match(/\d+/))
// [ '2024', index: 6, input: 'date: 2024-01_02', groups: undefined ]

for (const result of str.matchAll(/\d+/g)) {
    console.log(result)
    // [ '2024', index: 6,  input: 'date: 2024-01_02', groups: undefined ]
    // [ '01',   index: 11, input: 'date: 2024-01_02', groups: undefined ]
    // [ '02',   index: 14, input: 'date: 2024-01_02', groups: undefined ]
}

console.log(str.search(/\d/))
// 6

console.log(str.replace(/-|_/, ','))
// date: 2024,01_02
console.log(str.replace(/-|_/g, ','))
// date: 2024,01,02

断言

输入内容边界断言 ^ $

^ 表示输入内容的开头;$ 表示输入内容的末尾

简单案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const str1 = 'hello, world'

console.log(/lo, wor/.test(str1))
// true
console.log(/^lo, wor$/.test(str1))
// false


const str2 = `hello
world`
console.log(/^hello\r?\nworld$/.test(str2))
// true 注意,如果是 win 系统的换行符是 \r\n
console.log(/^hello/.test(str2))
// true
console.log(/world$/.test(str2))
// true

单词边界断言 \b \B

什么是单词边界?当我们通过快捷键 ctrl+leftctrl+right 进行跳转时,其实就是跳转到下一个单词边界。或者可以在 vscode 中进行正则搜索 \b,这样可以很直观地看到效果。

默认情况下,中文(包括标点)不会被认为是一个一个单词(word)。不过,如果想要对中文词进行分隔,可以借助查阅 Segmenter

简单案例:

1
2
3
4
5
6
7
8
9
const str = "To be, or not to be, that is the question."

console.log(/no/.test(str))
// true

console.log(/\bno\b/.test(str))
// false

先行断言 foo(?=bar) foo(?!bar)

foo(?=bar) 要求 foo 有 bar 时才匹配 foo

foo(?!bar) 要求 foo 不存在有 bar 时才匹配 foo

简单案例:

1
2
3
4
5
6
7
8
9
10
const str = 'hello, world. hello.'

console.log(/hello/.exec(str))
// [ 'hello', index: 0, ]

console.log(/hello(?=\.)/.exec(str))
// [ 'hello', index: 14, ]

console.log(/hello(?!,)/.exec(str))
// [ 'hello', index: 14, ]

后行断言 (?<=bar)foo (?<!bar)foo

(?<=bar)foo 要求 foo 有 bar 时才匹配 foo

(?<!bar)foo 要求 foo 不存在有 bar 时才匹配 foo

简单案例:

1
2
3
4
5
6
7
8
9
10
const str = "All the world's a stage, and all the men and women merely players."

console.log(/men/.exec(str))
// [ 'men', index: 37, ]

console.log(/(?<=wo)men/.exec(str))
// [ 'men', index: 47, ]

console.log(/(?<!wo)men/.exec(str))
// [ 'men', index: 37, ]

量词

语法说明
?0 次或 1 次 (非贪婪匹配)
*0 次或 0 次以上
+1 次或 1 次以上
{n}刚好 n 次
{n,}n 次或 n 次以上
{n,m}n 到 m 次(包含 n 和 m)

注意,使用花括号时中间不能有空格!

简单案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
console.log("foooooo bar".match(/o+/)[0])
// 贪婪匹配: oooooo

console.log("foooooo bar".match(/o+?/)[0])
// 非贪婪匹配: o

console.log("bar abb abbb foo".match(/b{2}/g))
// [ 'bb', 'bb' ]

console.log("bar abb abbb foo".match(/b{2,}/g))
// [ 'bb', 'bbb' ]


console.log(/a{1, 3}/.test('aaaa'))
// false 当花括号中出现空格时,会花括号被解析成字面量,而不是量词。
console.log(/a{1, 3}/.test('a{1, 3}'))
// true 虽然这样可以成功运行,但并不推荐!这种特性是为了兼容历史遗留问题
console.log(/a\{1, 3\}/.test('a{1, 3}'))
// true 使用转义时一种更规范的用法
console.log('😀😀😀'.match(
    /😀{2,3}/u
))
// 在 u 模式下如果在花括号中添加空格,会直接报错!

字符类

元字符

正则表达式中有以下元字符(metacharacter):

1
.  *  ?  +  ^  $  \  |  ( ) [ ] { }

想要匹配元字符时需要通过 \ 进行转义。某些情况下也可以直接包裹在 [] 中。除了 [\^], [\\], [\]]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/a[.]b/.test('a.b') // true
/a[*]b/.test('a*b') // true
/a[?]b/.test('a?b') // true
/a[+]b/.test('a+b') // true
/a[^]b/.test('a^b') // true
/a[$]b/.test('a$b') // true
/a[|]b/.test('a|b') // true
/a[(]b/.test('a(b') // true
/a[)]b/.test('a)b') // true
/a[{]b/.test('a{b') // true
/a[}]b/.test('a}b') // true
/a[[]b/.test('a[b') // true

/a[\^]b/.test('a^b') // true
/a[\\]b/.test('a\\b') // true
/a[\]]b/.test('a]b') // true

通配符 .

. 默认不匹配行终结符符(换行),除非开启 s 标志。

一个 Unicode 字符只会消耗一个 .

win 系统中换行符是 \r\n,而 . 只能匹配一个 \r 或者一个 \n

简单案例:

1
2
3
4
5
6
7
8
9
10
11
12
console.log(/hello.world/.test('hello\tworld'))
// true

console.log(/hello./.test('hello\nworld'))
// false 不开启 s 标志时,通配符不支持换行符

console.log(/hello.world/s.test('hello\r\nworld'))
// false
// 一个 . 只能匹配一个 \r 或一个 \n

console.log(/hello..world/s.test('hello\r\nworld'))
// true

字符转义

 
\f 换页 \n 换行 \r 回车
\t 水平制表符 \v 垂直制表符
\0 字符 NUL
\(\)
\[\]
{}
\^\$
\\\.\*\+\?
\|\/
\cA, \cB, … 脱字符表示法,表示 ASCII 控制字符
\xHH 必须 2 个十六进制位,比如 \x20 匹配空格
\uHHHH 必须 4 个十六进制位,比如 \uFFE5 匹配
\u{HHHHHH} 需先开启 u 标志,允许 1 到 6 个十六进制位,比如 \u{1F600} 匹配 😀

简单案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
console.log(/\x20/.test('  '))
// true

console.log(/\u20/.test('  '))
// false 必需四位 16 进制
console.log(/\u0020/.test('  '))
// true 必需四位 16 进制

console.log(/\uFFE5/.test(''))
// true

console.log(/\u{1F600}/.test('😀'))
// false 未开启 u 标志

console.log(/\u{1F600}/u.test('😀'))
// true


console.log('\x01Hello World!'.match(/^\cA/))
// 匹配以控制字符 SOH 开头的字符串

console.log('Hello World!\x06'.match(/\cF$/))
// 匹配以控制字符 ACK 结尾的字符串

字符类转义 \d \w \s

转义字符说明
\d(digit) 数字
\D数字
\w(word) 数字 字母 下划线
\W数字 字母 下划线
\s(space) 空格、制表符(\t)、换行符(\r \n)
\S空格、制表符(\t)、换行符(\r \n)

\s 实际上表示的是空白字符和行终结符。 完整的空白字符包含 \t \v \f U+0020 U+00A0 U+FEFF 和其他 unicode 空白符; 完整的行终结符包含 \n \r U+2028 U+2029 四个。 详见 词法文法

简单案例:

1
2
3
4
5
const str = `Look  at   the    stars
Look    how they \t\t\tshine for you`

console.log(str.split(/\s+/))
// [ 'Look', 'at', 'the',  'stars', 'Look', 'how', 'they', 'shine', 'for',  'you' ]

Unicode 字符类转义 \p{...} \P{...}

使用时需要开启 u 标志。

常见的 unicode 属性名 有:L(Letter)、N(Number)、Emoji 等等。比如 \p{Emoji} 能实现只匹配 Emoji 符号。/[\u4E00-\u9FFF]/ug 能匹配大多数中文。

简单案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const str1 = '✨你好❗'
console.log(str1.match(
    /\p{Emoji}/u
)[0])
// [ '✨' ]
console.log(str1.match(
    /\p{Emoji}/ug
))
// [ '✨', '❗' ]


const str2 = '魑魅魍魉 魃鬾魑魊 魖魈鬽魁 魓魌鬿魕 魆魒魐魖魀'
console.log(str2.match(
    /\p{L}+/ug
))
// [ '魑魅魍魉', '魃鬾魑魊', '魖魈鬽魁', '魓魌鬿魕', '魆魒魐魖魀' ]


const str3 = '你好,鿾 鿿 ꀀ ꀁ'
console.log(str3.match(
    /[\u4E00-\u9FFF]/ug
))
// [ '你', '好', '鿾', '鿿' ]


const str = '你到底知道不知道?愛是什麽'
const granularities = ['grapheme', 'word', 'sentence']
granularities.forEach(granularity => {
    console.table(Array.from((new Intl.Segmenter('zh', { granularity })).segment(str)))
    // grapheme
    //          '你', '到', '底', '知', '道', '不', '知', '道', '?', '愛', '是', '什', '麽'
    // word
    //          '你', '到底', '知道', '不知道', '?' , '愛', '是', '什', '麽'
    // sentence
    //          '你到底知道不知道?', '愛是什麽'
})

析取字符 |

相当于逻辑或。它在正则表达式中的优先级是最低的。

简单案例:

1
2
3
4
5
6
7
8
9
10
console.log('abc'.match(
    /a|ab/
)[0])
// a


console.log('abbacc'.match(
    /a(c|b)/
)[0])
// ab

捕获组 () 和后向引用 \1

后向引用 \1

一个很常见的需求就是匹配连续多个字符相同的字符,比如 abb 模式, aabb 模式, abba 模式等等

简单案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
console.log('aab bba'.match(
    /(.)\1/g
))
// [ 'aa', 'bb' ]


console.log('aabb ccdd'.match(
    /(.)\1(.)\2/g
))
// [ 'aabb', 'ccdd' ]


console.log('abba cddc'.match(
    /(.)(.)\2\1/g
))
// [ 'abba', 'cddc' ]

在 vscode 中的替换时,在替换栏中的 $1, $2 和这里的后向引用时一个的道理。比如: 搜索栏使用正则:\[(\s)\] ; 替换栏使用 $1 ,这样就可以批量删除配对的中括号。

指定字符的范围 [..] [^...]

注意, [^...] 是一体的,否定字符 ^ 不能单独使用。比如 [a^b] 中的 ^ 仅仅代表一个字符,没有否定的含义

简单案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
console.log('log, lig, lag, lug'.match(
    /l[aio]g/g
))
// [ 'log', 'lig', 'lag' ]

console.log('log, lig, lag, lug'.match(
    /l[^aio]g/g
))
// [ 'lug' ]

console.log('log, lIg, lAg, lug, loon'.match(
    /l[a-zA-Z]+[gn]/g
))
// [ 'log', 'lIg', 'lAg', 'lug', 'loon' ]

console.log('2^3=8; 3*2=6; 8+6=9'.match(
    /\d[*^]\d/g
))
// [ '2^3', '3*2' ]

console.log('2[3=8; 3]2=6; 8+6=9'.match(
    /\d[\[\]]\d/g
))
// [ '2[3', '3]2' ]

捕获组 ()

捕获组,其实就是嵌套的概念。后向引用就是引用捕获组中的内容!

捕获组的匹配信息可以通过以下方式访问:

举例说明 (1):

1
/(ab)|(cd)/.exec("cd") // ['cd', undefined, 'cd']

上面代码中定义了来个捕获组(两个括号),返回了一个包含三个元素的数组(忽略其属性值)。其中下标为 0 的元素并不是捕获组的内容,它表示的是整个正则的匹配结果。而下标为 1 和 2 的元素则分别对应第 1 和第 2 个捕获组。由于第一个捕获组((ab))并没有匹配的内容,所以其值为 undefined

举例说明 (2):

1
2
/([ab])+/.exec("abc") // ['ab', 'b']
/([ab])+/.exec("bac") // ['ab', 'a']

捕获组可以使用量词,上面案例中使用了 +,在这种情况下,捕获组最终只会留下最后一次匹配的信息。上面例子中,使用正则 /([ab])+/ 匹配 abc 时,捕获组依次捕获到 ab,所以只留下了 b

举例说明(3):

1
2
3
/((a+)?(b+)?(c))*/.exec("aac")      // ['aac',      'aac',  'aa',      undefined, 'c']
/((a+)?(b+)?(c))*/.exec("aacbbbc")  // ['aacbbbc',  'bbbc', undefined, 'bbb',     'c']
/((a+)?(b+)?(c))*/.exec("aacbbbcac")// ['aacbbbcac','ac',   'a',       undefined, 'c']

捕获组内可以继续嵌套捕获组。不管嵌套多少层,捕获组的编号顺序都是根据其左括号的出现顺序进行编号的。所以,上面代码中最外面的括号是第一个捕获组,然后里面的括号依次是 2,3,4 个捕获组。此外,如果捕获组使用了量词(比如上面代码中外部捕获组使用了 * ),则每当外部捕获组匹配到新的内容时,内部的捕获组的结果都会被覆盖。上面代码中专门写成了三行,就是因为外部捕获组成功匹配了三次:

  1. 第一次,识别到子串 aac,此时符合外部捕获组的定义。其内部捕获组的结果分别是: aa, undefined, c。如果此时外部捕获组没有使用量词,则匹配到此结束。但这里使用了量词 *,所以指针会继续匹配下一个子串。
  2. 第二次,成功匹配到子串 bbbc,此时内部捕获组的结果分别是:undefine, bbb, c。同理,由于使用了量词 *,所以指针会继续匹配
  3. 第三次,成功匹配到子串 ac,此时内部捕获组的结果分别是:a, undefined, c

现在,看看下面的代码,你应该能够理解为什么了:

1
2
3
   /((a+)?(b+)?(c))?/.exec("aacbbbcac") // ['aac',       'aac',  'aa',      undefined,  'c']
 /((a+)?(b+)?(c)){2}/.exec("aacbbbcac") // ['aacbbbc',   'bbbc', undefined, 'bbb',      'c']
 /((a+)?(b+)?(c)){3}/.exec("aacbbbcac") // ['aacbbbcac', 'ac',   'a',       undefined,  'c']

具名捕获组 (?<name>...)

见名知意,具名捕获组就是有名字的捕获组。具名捕获组的好处在于,我们可以直接通过名字来获取捕获组的内容,而不是通过下标。

简单案例:

1
2
3
4
5
6
7
const result = "2024-12-31".match(/(\d{4})-(\d{2})-(\d{2})/)
console.log(result[1], result[2], result[3])
// 2024 12 31

const groups = "2024-12-31".match(/(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/)?.groups
console.log( groups.year, groups.month, groups.day )
// 2024 12 31

非捕获组 (?:...)

捕获组在运行时,会记住(存储)匹配到的内容,这会带来额外的性能消耗。而非捕获组则相反,它不会记住匹配到的内容。

什么时候可以考虑使用非捕获组呢?在正则表达式中,我们经常会用到括号 (),但大多数人使用括号时仅仅只是为了分组,而不是为了后面能够后向引用。也就是说,当我们只想分组,而不需要记住匹配到的内容时,就可以使用非捕获组!

简单案例:

1
2
3
4
5
function isStylesheet(path) {
  return /styles(?:\.[\da-f]+)?\.css$/.test(path);
}
isStylesheet('styles.13ABF3.css') // true

案例 2:

1
2
3
4
5
6
7
8
9
function parseTitle(metaString) {
    // 这里我们使用后向引用来更方便的匹配 value 值,而不需要判断字符串是使用什么括号进行包裹
    return metaString.match(
        /title=(["'`])(.*?)\1/
    )[2]
}
parseTitle('title="foo"') // 'foo'
parseTitle("title=`foo`") // 'foo'
parseTitle("title='foo'") // 'foo'

上面代码可以完成任务,但后续我们可能想识别 key 等于 name 的情况,于是我们需要修代码:

1
2
3
4
5
6
//  -   添加一个析取字符 |
//  -   修改后向引用的数字为 \2
//  -   修改数组的下标为 3
    return metaString.match(
        /(title|name)=(["'`])(.*?)\2/
    )[3]

可以看到,其中的第一个括号,我们需要的功能仅仅只是分组,并不需要记住它的值,这个时候就可以使用非捕获组:

1
2
3
4
5
6
7
8
function parseTitleOrName(metaString) {
    return metaString.match(
        /(?:title|name)=(["'`])(.*?)\1/
    )[2]
}
parseTitle( 'name="foo"') // foo
parseTitle("title=`bar`") // bar
parseTitle( "name='baz'") // baz

附录

脱字符标识符

脱字符表示法(Caret notation),是 ASCII 码中不可打印的控制字符的一种表示方式:用一个脱字符(^)后跟一个大写字符来表示一个控制字符的 ASCII 码值。

键盘上的 Ctrl 按键其实是 control characters 中的 control,许多系统的终端都使用 Ctrl 按键加键盘上一个另一个按键来输入控制字符。

ASCII 码中不可打印的控制字符共有 33 个:0x00 - 0x1F 加上 0x7F

binarydecimalhexabbr.UnicodeCaret notation名称/意义
0000 000000x00NUL^@空字符(Null)
0000 000110x01SOH^A标题开始
0000 001020x02STX^B本文开始
0000 001130x03ETX^C本文结束
0000 010040x04EOT^D传输结束
0000 010150x05ENQ^E请求
0000 011060x06ACK^F确认回应
0000 011170x07BEL^G响铃
0000 100080x08BS^H退格
0000 100190x09HT^I水平定位符号
0000 1010100x0ALF^J换行键
0000 1011110x0BVT^K垂直定位符号
0000 1100120x0CFF^L换页键
0000 1101130x0DCR^MEnter键
0000 1110140x0ESO^N取消变换(Shift out)
0000 1111150x0FSI^O启用变换(Shift in)
0001 0000160x10DLE^P跳出数据通讯
0001 0001170x11DC1^Q设备控制一(XON 启用软件速度控制)
0001 0010180x12DC2^R设备控制二
0001 0011190x13DC3^S设备控制三(XOFF 停用软件速度控制)
0001 0100200x14DC4^T设备控制四
0001 0101210x15NAK^U确认失败回应
0001 0110220x16SYN^V同步用暂停
0001 0111230x17ETB^W区块传输结束
0001 1000240x18CAN^X取消
0001 1001250x19EM^Y连线介质中断
0001 1010260x1ASUB^Z替换
0001 1011270x1BESC^[退出键
0001 1100280x1CFS^\文件分割符
0001 1101290x1DGS^]群组分隔符
0001 1110300x1ERS^^记录分隔符
0001 1111310x1FUS^_单元分隔符
0111 11111270x7FDEL^?删除

参考


本文由作者按照 CC BY 4.0 进行授权

© Linhieng. 保留部分权利。

本站由 Jekyll 生成,基于 Chirpy 主题进行修改。