第 19 章. 正则表达式
目录
购买本书
(广告,请不要屏蔽。)

第 19 章. 正则表达式

本章概述了JavaScript 正则表达式 API。它假设您大致熟悉它们的工作原理。如果您不熟悉,网络上有许多很好的教程。以下是两个例子:

正则表达式语法

此处使用的术语密切反映了 ECMAScript 规范中的语法。我有时会偏离规范,以便于理解。

原子:概述

一般原子的语法如下:

特殊字符

以下所有字符都有特殊含义:

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

您可以通过在前面加上反斜杠来转义它们。例如

> /^(ab)$/.test('(ab)')
false
> /^\(ab\)$/.test('(ab)')
true

其他特殊字符是

  • 在字符类 [...]

    -
  • 在以问号开头的组中 (?...)

    : = ! < >

    尖括号仅由 XRegExp 库使用(请参阅第 30 章)来命名组。

模式字符
除上述特殊字符外,所有字符都匹配自身。
.(点)

匹配任何 JavaScript 字符(UTF-16 代码单元),但行终止符(换行符、回车符等)除外。要真正匹配任何字符,请使用 [\s\S]。例如

> /./.test('\n')
false
> /[\s\S]/.test('\n')
true
字符转义(匹配单个字符)
  • 特定的控制字符包括 \f(换页符)、\n(换行符)、\r(回车符)、\t(水平制表符)和 \v(垂直制表符)。
  • \0 匹配 NUL 字符 (\u0000)。
  • 任何控制字符:\cA\cZ
  • Unicode 字符转义:\u0000\xFFFF(Unicode 代码单元;请参阅第 24 章)。
  • 十六进制字符转义:\x00\xFF
字符类转义(匹配一组字符中的一个)
  • 数字:\d 匹配任何数字(与 [0-9] 相同);\D 匹配任何非数字(与 [^0-9] 相同)。
  • 字母数字字符:\w 匹配任何拉丁字母数字字符加下划线(与 [A-Za-z0-9_] 相同);\W 匹配 \w 不匹配的所有字符。
  • 空格:\s 匹配空格字符(空格、制表符、换行符、回车符、换页符、所有 Unicode 空格等);\S 匹配所有非空格字符。

原子:字符类

字符类的语法如下:

  • [«charSpecs»] 匹配与 charSpecs 中至少一个字符匹配的任何单个字符。
  • [^«charSpecs»] 匹配与任何 charSpecs 都不匹配的任何单个字符。

以下结构都是字符规范

  • 源字符匹配自身。大多数字符都是源字符(即使许多字符在其他地方是特殊的)。只有三个字符不是

        \ ] -

    像往常一样,您可以通过反斜杠进行转义。如果要匹配破折号而不转义它,它必须是左方括号后的第一个字符或范围的右侧,如下所述。

  • 类转义:允许使用前面列出的任何字符转义和字符类转义。还有一个额外的转义

    • 退格键 (\b):在字符类之外,\b 匹配单词边界。不要与 [\b] 混淆,后者匹配退格键。
  • 范围包括一个源字符或一个类转义,后跟一个破折号 (-),再后跟一个源字符或一个类转义。

为了演示如何使用字符类,本例解析了以 ISO 8601 标准格式化的日期

function parseIsoDate(str) {
    var match = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.exec(str);

    // Other ways of writing the regular expression:
    // /^([0-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9])$/
    // /^(\d\d\d\d)-(\d\d)-(\d\d)$/

    if (!match) {
        throw new Error('Not an ISO date: '+str);
    }
    console.log('Year: '  + match[1]);
    console.log('Month: ' + match[2]);
    console.log('Day: '   + match[3]);
}

以下是交互

> parseIsoDate('2001-12-24')
Year: 2001
Month: 12
Day: 24

原子:组

组的语法如下:

  • («pattern») 是一个捕获组。与 pattern 匹配的任何内容都可以通过反向引用或作为匹配操作的结果进行访问。
  • (?:«pattern») 是一个非捕获组。仍然将 pattern 与输入进行匹配,但不保存为捕获。因此,该组没有您可以引用的编号(例如,通过反向引用)。

\1\2 等被称为反向引用;它们指的是先前匹配的组。反斜杠后的数字可以是任何大于或等于 1 的整数,但第一个数字不能是 0。

在本例中,反向引用保证了破折号前后相同数量的 a

> /^(a+)-\1$/.test('a-a')
true
> /^(a+)-\1$/.test('aaa-aaa')
true
> /^(a+)-\1$/.test('aa-a')
false

本例使用反向引用来匹配 HTML 标记(显然,您通常应该使用适当的解析器来处理 HTML)

> var tagName = /<([^>]+)>[^<]*<\/\1>/;
> tagName.exec('<b>bold</b>')[1]
'b'
> tagName.exec('<strong>text</strong>')[1]
'strong'
> tagName.exec('<strong>text</stron>')
null

量词

任何原子(包括字符类和组)后面都可以跟一个量词:

  • ? 表示匹配零次或一次。
  • * 表示匹配零次或多次。
  • + 表示匹配一次或多次。
  • {n} 表示恰好匹配 n 次。
  • {n,} 表示匹配 n 次或更多次。
  • {n,m} 表示至少匹配 n 次,最多匹配 m 次。

默认情况下,量词是贪婪的;也就是说,它们会尽可能多地匹配。您可以通过在任何前面的量词(包括花括号中的范围)后面加上一个问号 (?) 来获得惰性匹配(尽可能少地匹配)。例如:

> '<a> <strong>'.match(/^<(.*)>/)[1]  // greedy
'a> <strong'
> '<a> <strong>'.match(/^<(.*?)>/)[1]  // reluctant
'a'

因此,.*? 是一个有用的模式,用于匹配所有内容,直到下一个原子出现为止。例如,以下是刚才显示的 HTML 标记正则表达式的更紧凑版本(它使用 [^<]* 而不是 .*?

/<(.+?)>.*?<\/\1>/

断言

断言(如下表所示)是对输入中当前位置的检查:

^

仅在输入的开头匹配。

$

仅在输入的结尾匹配。

\b

仅在单词边界匹配。不要与 [\b] 混淆,后者匹配退格键。

\B

仅在不在单词边界时匹配。

(?=«pattern»)

正向先行断言:仅当 pattern 与后面的内容匹配时才匹配。pattern 仅用于向前查找,但在其他情况下会被忽略。

(?!«pattern»)

负向先行断言:仅当 pattern 与后面的内容不匹配时才匹配。pattern 仅用于向前查找,但在其他情况下会被忽略。

本例通过 \b 匹配单词边界

> /\bell\b/.test('hello')
false
> /\bell\b/.test('ello')
false
> /\bell\b/.test('ell')
true

本例通过 \B 匹配单词内部

> /\Bell\B/.test('ell')
false
> /\Bell\B/.test('hell')
false
> /\Bell\B/.test('hello')
true

注意

不支持后向断言。手动实现后向断言解释了如何手动实现它。

析取

析取运算符 (|) 分隔两个备选项;两个备选项中的任何一个都必须匹配,析取才能匹配。备选项是原子(可以选择包含量词)。

该运算符的绑定非常弱,因此您必须小心,不要让备选项扩展得太远。例如,以下正则表达式匹配以 aa 开头或以 bb 结尾的所有字符串

> /^aa|bb$/.test('aaxx')
true
> /^aa|bb$/.test('xxbb')
true

换句话说,析取的绑定比 ^$ 还要弱,两个备选项是 ^aabb$。如果要匹配两个字符串 'aa''bb',则需要使用括号

/^(aa|bb)$/

同样,如果要匹配字符串 'aab''abb'

/^a(a|b)b$/

创建正则表达式

您可以通过字面量或构造函数创建正则表达式,并通过标志配置其工作方式。

字面量与构造函数

创建正则表达式有两种方法:可以使用字面量或构造函数 RegExp

字面量

/xyz/i

加载时编译

构造函数(第二个参数是可选的)

new RegExp('xyz', 'i')

运行时编译

字面量和构造函数在编译时间上有所不同

因此,您通常应该使用字面量,但如果要动态组装正则表达式,则需要使用构造函数。

标志

标志是正则表达式字面量的后缀和正则表达式构造函数的参数;它们修改正则表达式的匹配行为。存在以下标志:

简称全称说明

g

global

给定的正则表达式被多次匹配。影响多种方法,尤其是 replace()

i

ignoreCase

在尝试匹配给定的正则表达式时忽略大小写。

m

multiline

在多行模式下,开始运算符 ^ 和结束运算符 $ 匹配每一行,而不是整个输入字符串。

简称用于字面量前缀和构造函数参数(请参阅下一节中的示例)。全称用于正则表达式的属性,这些属性指示在创建过程中设置了哪些标志。

正则表达式的实例属性

正则表达式具有以下实例属性:

  • 标志:指示设置了哪些标志的布尔值

    • global:是否设置了标志 /g
    • ignoreCase:是否设置了标志 /i
    • multiline:是否设置了标志 /m
  • 多次匹配的数据(设置了标志 /g

    • lastIndex 是下次继续搜索的索引。

以下是访问标志实例属性的示例

> var regex = /abc/i;
> regex.ignoreCase
true
> regex.multiline
false

创建正则表达式的示例

在本例中,我们首先使用字面量创建相同的正则表达式,然后使用构造函数创建,并使用 test() 方法确定它是否与字符串匹配:

> /abc/.test('ABC')
false
> new RegExp('abc').test('ABC')
false

在本例中,我们创建一个忽略大小写的正则表达式(标志 /i

> /abc/i.test('ABC')
true
> new RegExp('abc', 'i').test('ABC')
true

RegExp.prototype.test:是否存在匹配项?

test() 方法检查正则表达式 regex 是否与字符串 str 匹配:

regex.test(str)

test() 的操作方式取决于是否设置了标志 /g

如果未设置标志 /g,则该方法检查 str 中的某个位置是否存在匹配项。例如

> var str = '_x_x';

> /x/.test(str)
true
> /a/.test(str)
false

如果设置了标志 /g,则该方法会针对 strregex 的每个匹配项返回 true。属性 regex.lastIndex 包含最后一个匹配项之后的索引

> var regex = /x/g;
> regex.lastIndex
0

> regex.test(str)
true
> regex.lastIndex
2

> regex.test(str)
true
> regex.lastIndex
4

> regex.test(str)
false

String.prototype.search:匹配项位于哪个索引?

search() 方法 str 中查找与 regex 的匹配项:

str.search(regex)

如果存在匹配项,则返回找到匹配项的索引。否则,结果为 -1。执行搜索时会忽略 regex 的属性 globallastIndex(并且不会更改 lastIndex)。

例如

> 'abba'.search(/b/)
1
> 'abba'.search(/x/)
-1

如果 search() 的参数不是正则表达式,则会将其转换为正则表达式

> 'aaab'.search('^a+b+$')
0

RegExp.prototype.exec:捕获组

以下方法调用在将 regexstr 进行匹配时捕获组:

var matchData = regex.exec(str);

如果没有匹配项,则 matchDatanull。否则,matchData 是一个 匹配结果,它是一个具有两个附加属性的数组

数组元素
  • 元素 0 是完整正则表达式的匹配项(如果您愿意,也可以说是组 0)。
  • 元素 n > 1 是组 n 的捕获。
属性
  • input 是完整的输入字符串。
  • index 是找到匹配项的索引。

第一个匹配项(未设置标志 /g)

如果未设置标志 /g,则仅返回第一个匹配项

> var regex = /a(b+)/;
> regex.exec('_abbb_ab_')
[ 'abbb',
  'bbb',
  index: 1,
  input: '_abbb_ab_' ]
> regex.lastIndex
0

所有匹配项(设置了标志 /g)

如果设置了标志 /g,则重复调用 exec() 会返回所有匹配项。返回值 null 表示没有更多匹配项。属性 lastIndex 指示下次匹配将从哪里继续

> var regex = /a(b+)/g;
> var str = '_abbb_ab_';

> regex.exec(str)
[ 'abbb',
  'bbb',
  index: 1,
  input: '_abbb_ab_' ]
> regex.lastIndex
6

> regex.exec(str)
[ 'ab',
  'b',
  index: 7,
  input: '_abbb_ab_' ]
> regex.lastIndex
10

> regex.exec(str)
null

这里我们循环遍历匹配项

var regex = /a(b+)/g;
var str = '_abbb_ab_';
var match;
while (match = regex.exec(str)) {
    console.log(match[1]);
}

我们得到以下输出

bbb
b

String.prototype.match:捕获组或返回所有匹配的子字符串

以下方法调用 regexstr 进行匹配:

var matchData = str.match(regex);

如果未设置 regex 的标志 /g,则此方法的工作方式类似于 RegExp.prototype.exec()

> 'abba'.match(/a/)
[ 'a', index: 0, input: 'abba' ]

如果设置了该标志,则该方法返回一个数组,其中包含 str 中所有匹配的子字符串(即,每个匹配项的组 0),如果没有匹配项,则返回 null

> 'abba'.match(/a/g)
[ 'a', 'a' ]
> 'abba'.match(/x/g)
null

String.prototype.replace:搜索和替换

replace() 方法在 字符串 str 中搜索与 search 的匹配项,并将其替换为 replacement

str.replace(search, replacement)

可以通过多种方式指定这两个参数

搜索

字符串或正则表达式

  • 字符串:要在输入字符串中按字面查找。请注意,仅替换字符串的第一次出现。如果要替换多次出现,则必须使用带有 /g 标志的正则表达式。这是出乎意料的,也是一个主要的陷阱。
  • 正则表达式:要与输入字符串匹配。警告:使用 global 标志,否则只会尝试匹配一次正则表达式。
替换

字符串或函数

  • 字符串:描述如何替换找到的内容。
  • 函数:计算替换,并通过参数给出匹配信息。

替换是字符串

如果 replacement 是字符串,则其内容将按字面用于替换匹配项。唯一的例外是特殊字符美元符号 ($),它启动了所谓的 替换指令

  • 组:$n 插入匹配项中的组 n。n 必须至少为 1($0 没有特殊含义)。
  • 匹配的子字符串

    • $`(反引号)插入匹配项之前的文本。
    • $& 插入完整的匹配项。
    • $'(撇号)插入匹配项之后的文本。
  • $$ 插入单个 $

此示例引用匹配的子字符串及其前缀和后缀

> 'axb cxd'.replace(/x/g, "[$`,$&,$']")
'a[a,x,b cxd]b c[axb c,x,d]d'

此示例引用一个组

> '"foo" and "bar"'.replace(/"(.*?)"/g, '#$1#')
'#foo# and #bar#'

替换是函数

如果 replacement 是函数,则它计算要替换匹配项的字符串。此函数具有 以下签名:

function (completeMatch, group_1, ..., group_n, offset, inputStr)

completeMatch 与之前的 $& 相同,offset 指示找到匹配项的位置,inputStr 是要匹配的内容。因此,您可以使用特殊变量 arguments 来访问组(组 1 通过 arguments[1] 访问,依此类推)。例如

> function replaceFunc(match) { return 2 * match }
> '3 apples and 5 oranges'.replace(/[0-9]+/g, replaceFunc)
'6 apples and 10 oranges'

标志 /g 的问题

如果必须多次调用在 设置了 /g 标志的正则表达式上调用的方法才能返回所有结果,则会出现问题。对于以下两种方法,情况就是这样:

  • RegExp.prototype.test()
  • RegExp.prototype.exec()

然后,JavaScript 将正则表达式滥用作迭代器,作为结果序列中的指针。这会导致问题

问题 1:/g 正则表达式不能内联

例如

// Don’t do that:
var count = 0;
while (/a/g.test('babaa')) count++;

前面的循环是无限的,因为为每个循环迭代创建了一个新的正则表达式,这将重新启动对结果的迭代。因此,必须重写代码

var count = 0;
var regex = /a/g;
while (regex.test('babaa')) count++;

以下是另一个示例

// Don’t do that:
function extractQuoted(str) {
    var match;
    var result = [];
    while ((match = /"(.*?)"/g.exec(str)) != null) {
        result.push(match[1]);
    }
    return result;
}

调用前面的函数将再次导致无限循环。正确的版本是(稍后将解释为什么将 lastIndex 设置为 0)

var QUOTE_REGEX = /"(.*?)"/g;
function extractQuoted(str) {
    QUOTE_REGEX.lastIndex = 0;
    var match;
    var result = [];
    while ((match = QUOTE_REGEX.exec(str)) != null) {
        result.push(match[1]);
    }
    return result;
}

使用函数

> extractQuoted('"hello", "world"')
[ 'hello', 'world' ]

提示

最佳做法是不进行内联(这样您就可以为正则表达式指定描述性名称)。但是您必须意识到您不能这样做,即使是在快速破解中也不行。

问题 2:/g 正则表达式作为参数
想要多次调用 test()exec() 的代码必须小心处理作为参数传递给它的正则表达式。它的标志 /g 必须处于活动状态,并且为了安全起见,它的 lastIndex 应该设置为零(下一个示例中提供了解释)。
问题 3:共享的 /g 正则表达式(例如,常量)
每当您引用一个不是新创建的正则表达式时,在将其用作迭代器之前,都应该将其 lastIndex 属性设置为零(下一个示例中提供了解释)。由于迭代取决于 lastIndex,因此此类正则表达式不能同时在多个迭代中使用。

以下示例说明了问题 2。这是一个函数的简单实现,该函数计算字符串 str 中正则表达式 regex 的匹配项数量

// Naive implementation
function countOccurrences(regex, str) {
    var count = 0;
    while (regex.test(str)) count++;
    return count;
}

以下是使用此函数的示例

> countOccurrences(/x/g, '_x_x')
2

第一个问题是,如果未设置正则表达式的 /g 标志,则此函数将进入无限循环。例如

countOccurrences(/x/, '_x_x') // never terminates

第二个问题是,如果 regex.lastIndex 不是 0,则该函数无法正常工作,因为该属性指示从哪里开始搜索。例如

> var regex = /x/g;
> regex.lastIndex = 2;
> countOccurrences(regex, '_x_x')
1

以下实现解决了这两个问题

function countOccurrences(regex, str) {
    if (! regex.global) {
        throw new Error('Please set flag /g of regex');
    }
    var origLastIndex = regex.lastIndex;  // store
    regex.lastIndex = 0;

    var count = 0;
    while (regex.test(str)) count++;

    regex.lastIndex = origLastIndex;  // restore
    return count;
}

更简单的替代方法是使用 match()

function countOccurrences(regex, str) {
    if (! regex.global) {
        throw new Error('Please set flag /g of regex');
    }
    return (str.match(regex) || []).length;
}

有一个可能的陷阱:如果设置了 /g 标志并且没有匹配项,则 str.match() 返回 null。我们在前面的代码中通过在 match() 的结果不为真时使用 [] 来避免此陷阱。

提示和技巧

本节提供了一些在 JavaScript 中使用正则表达式的提示和技巧。

引用文本

有时,当您 手动组合正则表达式时,您希望按字面使用给定的字符串。 这意味着所有特殊字符(例如,*[)都不应被解释为特殊字符,所有字符都需要转义。JavaScript 没有用于这种引用的内置方法,但您可以编写自己的函数 quoteText,该函数的工作方式如下:

> console.log(quoteText('*All* (most?) aspects.'))
\*All\* \(most\?\) aspects\.

如果您需要对多个匹配项进行搜索和替换,则此类函数特别方便。然后,要搜索的值必须是设置了 global 标志的正则表达式。使用 quoteText(),您可以使用任意字符串。该函数如下所示

function quoteText(text) {
    return text.replace(/[\\^$.*+?()[\]{}|=!<>:-]/g, '\\$&');
}

所有特殊字符都会被转义,因为您可能希望在括号或方括号内引用多个字符。

陷阱:如果没有断言(例如,^、$),则会在任何地方找到正则表达式

如果您不使用 断言(例如 ^$),则大多数正则表达式方法都会在任何地方找到模式。例如:

> /aa/.test('xaay')
true
> /^aa$/.test('xaay')
false

匹配所有内容或不匹配任何内容

这是一种罕见的用例,但有时您需要一个匹配所有内容或不匹配任何内容的正则表达式。例如,一个函数可能有一个带有用于过滤的正则表达式的参数。如果缺少该参数,则为其指定一个默认值,即匹配所有内容的正则表达式。

匹配所有内容

空正则表达式匹配所有内容。我们可以像这样创建一个基于该正则表达式的 RegExp 实例

> new RegExp('').test('dfadsfdsa')
true
> new RegExp('').test('')
true

但是,空正则表达式字面量将是 //,JavaScript 将其解释为注释。因此,以下是最接近通过字面量实现的目标:/(?:)/(空非捕获组)。该组匹配所有内容,但不捕获任何内容,这会影响 exec() 返回的结果。甚至 JavaScript 本身在显示空正则表达式时也使用前面的表示形式

> new RegExp('')
/(?:)/

不匹配任何内容

空正则表达式有一个反义词,即不匹配任何内容的正则表达式

> var never = /.^/;
> never.test('abc')
false
> never.test('')
false

手动实现后向断言

后向断言是一种 断言。 与前向断言类似,模式用于检查输入中当前位置的某些内容,但在其他情况下会被忽略。与前向断言相反,模式的匹配必须在当前位置 结束(而不是从当前位置开始)。

以下函数将字符串 'NAME' 的每次出现替换为参数 name 的值,但前提是该出现前面没有引号。我们通过“手动”检查当前匹配项之前的字符来处理引号

function insertName(str, name) {
    return str.replace(
        /NAME/g,
        function (completeMatch, offset) {
            if (offset === 0 ||
                (offset > 0 && str[offset-1] !== '"')) {
                return name;
            } else {
                return completeMatch;
            }
        }
    );
}
> insertName('NAME "NAME"', 'Jane')
'Jane "NAME"'
> insertName('"NAME" NAME', 'Jane')
'"NAME" Jane'

另一种方法是在正则表达式中包含可能转义的字符。然后,您必须临时将前缀添加到要搜索的字符串中;否则,您将错过该字符串开头的匹配项

function insertName(str, name) {
    var tmpPrefix = ' ';
    str = tmpPrefix + str;
    str = str.replace(
        /([^"])NAME/g,
        function (completeMatch, prefix) {
            return prefix + name;
        }
    );
    return str.slice(tmpPrefix.length); // remove tmpPrefix
}

正则表达式备忘单

原子(请参阅 原子:常规

  • .(点)匹配除行终止符(例如,换行符)之外的所有内容。使用 [\s\S] 来真正匹配所有内容。
  • 字符类转义

    • \d 匹配数字 ([0-9]);\D 匹配非数字 ([^0-9])。
    • \w 匹配拉丁字母数字字符和下划线 ([A-Za-z0-9_]);\W 匹配所有其他字符。
    • \s 匹配所有空白字符(空格、制表符、换行符等);\S 匹配所有非空白字符。
  • 字符类(字符集):[...][^...]

    • 源字符:[abc](除 \ ] - 之外的所有字符都匹配自身)
    • 字符类转义(参见上文):[\d\w]
    • 范围:[A-Za-z0-9]
    • 捕获组:(...);反向引用:\1
    • 非捕获组:(?:...)

量词(参见量词

  • 贪婪

    • ? * +
    • {n} {n,} {n,m}
  • 惰性:在任何贪婪量词后添加 ?

断言(参见断言

  • 输入开头、输入结尾:^ $
  • 单词边界处、非单词边界处:\b \B
  • 正向先行断言:(?=...)(模式必须在后面出现,但会被忽略)
  • 负向先行断言:(?!...)(模式不能在后面出现,但会被忽略)

析取:|

创建正则表达式(参见创建正则表达式

  • 字面量:/xyz/i(加载时编译)
  • 构造函数:new RegExp('xzy', 'i')(运行时编译)

标志(参见标志

方法

有关使用标志 /g 的提示,请参见标志 /g 的问题

致谢

Mathias Bynens (@mathias) 和 Juan Ignacio Dopazo (@juandopazo) 建议使用 match()test() 来统计出现次数,Šime Vidas (@simevidas) 提醒我如果没有任何匹配项,使用 match() 时要小心。全局标志导致无限循环的陷阱来自 Andrea Giammarchi 的演讲 (@webreflection)。Claude Pache 告诉我应该在 quoteText() 中转义更多字符。

下一页:20. 日期