JavaScript 的正则表达式

作者:刘专,日期:2018 年 01 月 03 日

正则表达式(Regular Expression),在实际的软件开发项目中经常会被使用到。它使用单个字符串来描述、匹配并获取一系列符合某个句法规则的结果。

起源

1956 年,数学家 Stephen KleeneWarren McCullochWalter Pitts 早期神经系统工作的基础上,设计出了一个数学符号体系 —— regular sets(规则的集合),这个东西很快被计算机科学家用于编译器的扫描或词法分析。由于正则表达式强大的文本处理能力,很快被应用到 Unix 的工具软件 grep 中;此后,正则表达式被广泛应用于 Unix 系操作系统、Perl、PHP,JavaScript 等语言和开发环境中。

基本语法

JavaScript 的 RegExp 类表示正则表达式。StringRegExp 皆有许多函数,用于模式匹配和查找替换。

我们通常使用正则表达式的字面量语法,比如 /s$/。注意,和字符串字面量不同,每个正则字面量都是重新生成的,即拥有相同值的正则表达式字面量并非严格相等

/a/ === /a/
// ==> false

'a' === 'a'
// ==> true

字面量字符

字面量字符(Literal Characters)

字符 匹配的值
\0 NUL 字符(\u0000
\t Tab(\u0009
\n 换行(\u000A
\v 垂直 Tab(\u000B
\f Form Feed(\u000C
\r 回车(\u000D
\xnn 十六进制数字 nn 表示的拉丁字符;比如 \x0A 相当于 \n
\uxxxx 十六进制数字 xxxx 表示的 Unicode 字符;比如 \u0009 相当于 \t
\cX 控制字符 ^X;比如,\cJ 相当于换行符 \n

字符类

将多个单字符用方括号包围,就可以组合成字符类character classes)。比如 /[abc]/。还可以取字符类的“补集”,比如 /[^abc]/。用短横线连接可以定义字符类的起止字符,比如 /[a-zA-Z0-9]/

除了方括号语法,JavaScript 还为常用的字符类定义了一些转义字符,相当于某些字符类的快捷方式。所有的字符类如下:

字符 匹配的值
[...] 方括号之间字符集的任意一个字符
[^...] 方括号字符集之外的任意一个字符
. 除换行符或 Unicode 行结束符之外的任意字符
\w 任意 ASCII 字符,相当于 [a-zA-Z0-9_]
\W 相当于 [^a-zA-Z0-9_]
\s 任意 Unicode 空白符
\S 任意非 Unicode 空白符。
\d 任意 ASCII 数字。相当于 [0-9]
\D 任意非 ASCII 数字。相当于 [^0-9]
[\b] 退格字面量(特殊情况)

重复量词

字符 含义
{n,m} 匹配前面元素至少 n 次,至多 m
{n,} 匹配前面元素至少 n
{n} 匹配前面元素恰好 n
? 匹配前面元素 0 或 1 次。相当于 {0,1}
+ 匹配前面元素 1 或多次。相当于 {1,}
* 匹配前面元素 0 或多次。相当于 {0,}

重复量词默认是贪婪的greedy),也就是说,它们会尽可能多的匹配字符。

可以给数量词增加 ? 后缀,将其切换为“非贪婪模式”。比如:??+?*?,甚至 {1,5}?。这样它会尽可能少的匹配元素。

比如,对于字符串 "aaa",正则表达式 /a+/ 是贪婪的,会匹配全部字符串 "aaa"/a+?/ 不贪婪,很腼腆,只匹配第一个 "a"

可选值、群组和引用

正则表达式语法包括一些特殊字符,用来表示可选值、群组子表达式和引用子表达式。| 用来隔离可选值。比如,/ab|cd|ef/ 可以匹配 "ab""cd""ef"/\d{3}|[a-z]{4}/ 匹配三个数字或四个小写字母。

圆括号用来分组,将多个字符组合为一个单元。比如,/java(script)?/ 匹配 "java" 后面跟着一个可选的 "script"

与圆括号相关的另一个语法是引用前面出现的子单元,使用 \nn 即可,其中 nn 是数字,表示第几个子单元。比如,\1 表示第一个子单元。

引用可以增加一些限制,比如,如果要匹配成对单引号或双引号及其包围的内容,可以使用 /(['"])[^'"]*\1/

不可以在字符类内使用引用,因此如下写法是不对的:

/(['"])[^\1]*\1/

有时候需要将元素作为群组处理,但是不可以创建数字引用,此时可以使用 (?:...) 语法创建。比如:

/([Jj]ava(?:[Ss]cript)?)\sis\s(fun\w*)/

此时,(?:[Ss]cript) 仅使用群组功能,并不会生成数字引用。所以 \2 会指代 (fun\w*) 匹配的文本。

下表对可选值、群组和引用略作总结

字符 含义
| 可选值
(...) 群组。匹配的字符可在后面引用
(?:...) 仅做群组。不记忆该群组匹配的字符
\n 引用前面已匹配字符

⚠️ 注意,上面的 \nn 表示一个数字,比如 \1。不要与换行符 \n 混淆。

匹配位置

有些正则表达式元素可以匹配特定位置,比如 \b 匹配单词边界,即 \w\W 之间等位置。这些元素有时也被称作正则表达式锚点regular-expression anchors)。

字符 含义
^ 匹配字符首部,在多行搜索中,匹配行首
$ 匹配字符尾部,在多行搜索中,匹配行尾
\b 匹配单词边界。包括 \w\W 之间的位置,以及 \w 和行首行尾之间的位置
\B 匹配非单词边界
(?=p) 前向断言。需要匹配模式 p,但 p 的对应字符不会出现在匹配结果中
(?!p) 反向断言。要求后面的字符不可以匹配 p

标志位

正则表达式标示位表示高层的模式匹配规则。

字符 含义
i 不区分大小写
g 全局搜索模式
m 多行搜索模式

ES6+ 新增语法

TODO

String 的函数

String 支持正则的函数有四种,最简单的是 search(),它返回字符串中匹配正则的第一个字符的索引值,或者没有匹配时,返回 -1。

"JavaScript".search(/script/i)
// => 4

如果 search 的参数不是正则表达式类型,会被 RegExp 构造函数转换为正则表达式。search 不支持全局搜索,会忽略 g 标识符。

replace()

replace() 用来查找替换。它的函数签名如下:

string.replace(regexp, replacement)

regexp 表示待匹配的正则表达式,replacement 是替换后的字符串或者函数。

对于未指定 g 标志位的正则表达式,它只替换第一个发现的匹配字符串。

'hello world hello regexp'.replace(/hello/, '你好')
// => "你好 world hello regexp"

如果正则表达式有 g 标志位,replace() 会替换所有的匹配字符:

'hello world hello regexp'.replace(/hello/g, '你好')
// => "你好 world 你好 regexp"

replace() 的功能不止于此。对于捕获到的匹配群组,可以使用 $1$2 等分别指代第一个群组文本,第二个群组文本等。因此可以在 replacement 使用 $1 等实现部分字串替换。

比如,我们想将 "hello" is "great"! 中的双引号替换为方括号,可以这么做:

'"hello" is "great"!'.replace(/"([^"]+)"/g, '[$1]')
// => '[hello] is [great]!'

replacement 可以出现的特殊字符小结如下:

字符 在 replacement 的含义
$1, $2, …, $99 匹配正则的群组子串,从第1个到第99个
$& 匹配正则的字符串
$` 匹配字符串左侧的字符串
$' 匹配字符串右侧的字符串
$$ 美元符号字面量

ECMAScript v3 规定,replacement 参数还可以是函数,每次匹配后都会执行,函数返回的结果会当作替换文本。

函数的第一个参数表示匹配的字符串,后面的参数是匹配的群组子串,数量从零到多个不等,下一个参数是匹配子串在原字符串的位置索引,最后一个参数是字符串本身。

如果要将所有单词变为首字母大写:

text.replace(/\b\w+\b/g, function(word) {
    return word.substring(0, 1).toUpperCase() + 
        word.substring(1)
})

match()

match() 是最通用的字符串正则表达式函数。它只有一个参数(正则表达式类型),返回值是一个数组,包含了匹配结果。

如果正则表达式含有 g 标志位,数组中会返回所有匹配的结果。如果没有匹配项,则返回 null。比如:

'1 plus 2 equals 3'.match(/\d+/g)
// => ['1', '2', '3']

'hello world'.match(/\d+/g)
// => null

如果正则表达式没有 g 标志位,match() 将不进行全局搜索;它仅搜索第一个匹配项。尽管没有全局搜索,它依然返回一个数组。数组第一个元素是匹配的字符串部分,后面依次是群组匹配的子串。

因此,如果 match() 返回一个数组 a,那么 a[0] 包含完整的匹配,a[1] 包含第一个群组匹配的子串。依次类推。

replace() 做个类比,a[n] 的内容和 $n 一样。

比如,以下的例子用于解析 URL:

var url = /(\w+):\/\/([\w.]+)\/(\S*)/
var text = 'Visit my blog at http://www.example.com/~david'
var result = text.match(url)
if (result) {
    var fullurl = result[0]
    var protocol = result[1]
    var host = result[2]
    var path = result[3]

    console.log(fullurl)    // => http://www.example.com/~david
    console.log(protocol)   // => http
    console.log(host)       // => www.example.com
    console.log(path)       // => ~david
}

⚠️ 注意,将非全局搜索的正则表达式(即不设置 g 标志位)传递给 match() 函数,相当于把该字符串传递给 RegExpexec() 方法。返回的数组均有 indexinput 两个属性。其中的 index 表示匹配子串在原串的位置索引,input 就是原始字符串。

再比如,Mock.js 中的 src/mock/util.js 用于判断参数类型的代码:

Util.type = function type(obj) {
  return (obj === null || obj === undefined) ? String(obj) : Object.prototype.toString().call(obj).match(/\[object (\w+)\]/)[1].toLowerCase()
}

split()

split 只有一个参数,用作分隔符。它的作用是将字符串按照给定的模式拆分为多个字符串。比如:

'1, 2, 3, 4, 5'.split(/\s*,\s*/)
// => ['1', '2', '3', '4', '5']

默认情况下,数组中不包含分隔符。如果分隔符正则表达式中包含群组,则该群组匹配的字符串会出现在结果中。比如:

var text = 'hello <b>world</b>'
text.split(/(<[^>]*>)/)
// => ["hello ", "<b>", "world", "</b>", ""]

text.split(/<[^>]*>/)
// => ["hello ", "world", ""]

RegExp 对象

RegExp 对象除了字面量,还可以由构造函数 RegExp() 产生。

构造函数可以接收两个参数,第一个参数是字符串,用来表示模式。第二个是标志位,可选的值有 gim,或三者的任意组合。

因为字符串和正则表达式都用 \ 表示转译。因此如果要在正则表达式中出现反斜线,需要在第一个参数中写两个,比如:

var zipcode = new RegExp('\\d{5}', 'g')

如果正则表达式需要动态生成,就必须使用构造函数动态生成。比如,搜索用户输入的内容,就只能使用构造函数的形式。

RegExp 属性

每个 RegExp 有五个属性。

属性名 含义
source 包含正则表达式的字符串。只读
global g 标志位是否设定为真。只读
ignoreCase i 标志位是否设定为真。只读
multiline m 标志位是否设定为真。只读
lastIndex 表示下一次正则匹配开始的位置。可读写。仅对设定 g 标志位正则生效

lastIndexexec()test() 方法中使用,下面有详细介绍。

RegExp 方法

exec()

exec() 是 RegExp 对象一个重要的方法,同 String.prototype.match() 类似,用于在字符串中查找匹配。

如果没有匹配项,返回 null。如果有一个匹配项,返回的数组内容和字符串的 match() 方法(未设定 g 标志位)返回值相同。第一个元素表示匹配的字符串,第二个元素表示匹配的第一个群组子串,依次类推。另外,index 属性表示匹配的位置索引,input 表示输入的待匹配字符串。

⚠️ 注意,与 match() 不同,exec() 无论 g 设定与否,返回值的类型均相同,每次只返回一个匹配值,并返回该值的详细信息。

若调用 exec() 的正则表达式的 g 标志位为真,会将该正则表达式对象的 lastIndex 设定为匹配字符串的下一个索引位置。当同一个正则表达式第二次执行 exec() 方法时,它会从 lastIndex 表示的位置开始查找匹配。如果 exec() 没有找到匹配值,会将 lastIndex 重置为 0 。

这个特性可以让我们循环调用 exec(),查找所有的匹配值。比如:

var pattern = /Java/g
var text = 'JavaScript is more fun than Java!'
var result
while (result = pattern.exec(text)) {
    alert('Matched ' + result[0] + 
        ' at position ' + result.index +
        '; next search begins at ' + pattern.lastIndex)
}
// => Matched Java at position 0; next search begins at 4
// => Matched Java at position 28; next search begins at 32

download-git-repo 中有如下用法:

function normalize(repo) {
    var regex = /^((github|gitlab|bitbucket):)?((.+):)?([^/]+)\/([^#]+)(#(.+))?$/
    var match = regex.exec(repo)
    var type = match[2] || 'github'
    // ...
}

用于从字符串中提取各部分信息。

test()

test() 方法就相对简单很多,它接收一个字符串参数,如果该字符串包含匹配项,就返回 true,否则返回 false。比如:

var pattern = /java/i
pattern.test('JavaScript')
// => true

执行 test(),相当于执行 exec(),然后检测结果是否不为 null。由于这种对等关系,在全局正则表达式中,test() 的行为和 exec() 一样。

exec()test() 不同,字符串方法 search()replace()match() 不使用 lastIndex 属性。字符串方法会将 lastIndex 重置为 0。

工具

工欲善其事,必先利其器。正则表达式晦涩难懂,好的工具可以让你事半功倍。

REF