1
0
mirror of https://github.com/apachecn/eloquent-js-3e-zh.git synced 2025-05-23 20:02:20 +00:00
wizardforcel 53c34b58de 9.
2018-05-07 10:12:21 +08:00

38 KiB
Raw Blame History

九、正则表达式

Yuan-Ma said, 'When you cut against the grain of the wood, much strength is needed. When you program against the grain of the problem, much code is needed.'

Master Yuan-Ma《The Book of Programming》

程序设计工具技术的发展与传播方式是在混乱中不断进化。在此过程中获胜的往往不是优雅或杰出的一方,而是那些瞄准主流市场,并能够填补市场需求的,或者碰巧与另一种成功的技术集成在一起的工具技术。

本章将会讨论正则表达式regular expression这种工具。正则表达式是一种描述字符串数据模式的方法。它们形成了一种小而独立的语言也是 JavaScript 和许多其他语言和系统的一部分。

正则表达式虽然不易理解但是功能非常强大。正则表达式的语法有点诡异JavaScript提供的程序设计接口也不太易用。但正则表达式的确是检查、处理字符串的强力工具。如果读者能够正确理解正则表达式将会成为更高效的程序员。

创建正则表达式

正则表达式是一种对象类型。我们可以使用两种方法来构造正则表达式一是使用RegExp构造函数构造一个正则表达式对象二是使用斜杠/)字符将模式包围起来,生成一个字面值。

let re1 = new RegExp("abc");
let re2 = /abc/;

这两个正则表达式对象都表示相同的模式字符a后紧跟一个b接着紧跟一个c。

使用RegExp构造函数时需要将模式书写成普通的字符串因此反斜杠的使用规则与往常相同。

第二种写法将模式写在斜杠之间,处理反斜杠的方式与第一种方法略有差别。首先,由于斜杠会结束整个模式,因此模式中包含斜杠时,需在斜杠前加上反斜杠。此外,如果反斜杠不是特殊字符代码(比如\n的一部分则会保留反斜杠不像字符串中会将其忽略也不会改变模式的含义。一些字符比如问号、加号在正则表达式中有特殊含义如果你想要表示其字符本身需要在字符前加上反斜杠。

let eighteenPlus = /eighteen\+/;

匹配测试

正则表达式对象有许多方法。其中最简单的就是test方法。test方法接受用户传递的字符串并返回一个布尔值表示字符串中是否包含能与表达式模式匹配的字符串。

console.log(/abc/.test("abcde"));
// → true
console.log(/abc/.test("abxde"));
// → false

不包含特殊字符的正则表达式简单地表示一个字符序列。如果使用test测试字符串时字符串中某处出现abc不一定在开头则返回true。

字符集

我们也可调用indexOf来找出字符串中是否包含abc。正则表达式允许我们表达一些更复杂的模式。

假如我们想匹配任意数字。在正则表达式中,我们可以将一组字符放在两个方括号之间,该表达式可以匹配方括号中的任意字符。

下面两个表达式都可以匹配包含数字的字符串。

console.log(/[0123456789]/.test("in 1992"));
// → true
console.log(/[0-9]/.test("in 1992"));
// → true

我们可以在方括号中的两个字符间插入连字符来指定一个字符范围范围内的字符顺序由字符Unicode代码决定。在Unicode字符顺序中0到9是从左到右彼此相邻的代码从48到57因此[0-9]覆盖了这一范围内的所有字符,也就是说可以匹配任意数字。

许多常见字符组都有自己的内置简写。 数字就是其中之一:\ d[0-9]表示相同的东西。

\d任意数字符号

\w字母和数字符号单词符号

\s任意空白符号空格制表符换行符等类似符号

\D非数字符号

\W非字母和数字符号

\S非空白符号

.除了换行符以外的任意符号

因此你可以使用下面的表达式匹配类似于30-01-20031520这样的日期数字格式

let dateTime = /\d\d-\d\d-\d\d\d\d \d\d:\d\d/;
console.log(dateTime.test("30-01-2003 15:20"));
// → true
console.log(dateTime.test("30-jan-2003 15:20"));
// → false

这个表达式看起来是不是非常糟糕?该表达式中一半都是反斜杠,影响读者的理解,使得读者难以揣摩表达式实际想要表达的模式。稍后我们会看到一个稍加改进的版本。

我们也可以将这些反斜杠代码用在方括号中。例如,[\d.]匹配任意数字或一个句号。但是方括号中的句号会失去其特殊含义。其他特殊字符也是如此,比如+。

你可以在左方括号后添加脱字符(^来排除Invert某个字符集即表示不匹配这组字符中的任何字符。

let notBinary = /[^01]/;
console.log(notBinary.test("1100100010100110"));
// → false
console.log(notBinary.test("1100100010200110"));
// → true

部分模式重复

现在我们已经知道如何匹配一个数字。如果我们想匹配一个整数(一个或多个数字的序列),该如何处理呢?

在正则表达式某个元素后面添加一个加号(+),表示该元素至少重复一次。因此/\d+/可以匹配一个或多个数字字符。

console.log(/'\d+'/.test("'123'"));
// → true
console.log(/'\d+'/.test("''"));
// → false
console.log(/'\d*'/.test("'123'"));
// → true
console.log(/'\d*'/.test("''"));
// → true

星号(*)拥有类似含义,但是可以匹配模式不存在的情况。在正则表达式的元素后添加星号并不会导致正则表达式停止匹配该元素后面的字符。只有正则表达式无法找到可以匹配的文本时才会考虑匹配该元素从未出现的情况。

元素后面跟一个问号表示这部分模式“可选”即模式可能出现0次或1次。下面的例子可以匹配neighbouru出现1次也可以匹配neighboru没有出现

let neighbor = /neighbou?r/;
console.log(neighbor.test("neighbour"));
// → true
console.log(neighbor.test("neighbor"));
// → true

我们可以使用花括号准确指明某个模式的出现次数。例如,在某个元素后加上{4}则该模式需要出现且只能出现4次。也可以使用花括号指定一个范围比如{24}表示该元素至少出现2次至多出现4次。

这里给出另一个版本的正则表达式,可以匹配日期、月份、小时,每个数字都可以是一位或两位数字。这种形式更易于解释。

let dateTime = /\d{1,2}-\d{1,2}-\d{4} \d{1,2}:\d{2}/;
console.log(dateTime.test("30-1-2003 8:45"));
// → true

花括号中也可以省略逗号任意一侧的数字,表示不限制这一侧的数量。因此{5}表示0到5次而{5.}表示至少五次。

子表达式分组

为了一次性对多个元素使用*或者+,那么你必须使用圆括号,创建一个分组。对于后面的操作符来说,圆括号里的表达式算作单个元素。

let cartoonCrying = /boo+(hoo+)+/i;
console.log(cartoonCrying.test("Boohoooohoohooo"));
// → true

第一个和第二个+字符分别作用于boo与hoo的o字符而第三个+字符则作用于整个元组hoo+可以匹配hoo+这种正则表达式出现一次及一次以上的情况。

示例中表达式末尾的i表示正则表达式不区分大小写虽然模式中使用小写字母但可以匹配输入字符串中的大写字母B。

匹配和分组

test方法是匹配正则表达式最简单的方法。该方法只负责判断字符串是否与某个模式匹配。正则表达式还有一个exec执行execute方法如果无法匹配模式则返回null否则返回一个表示匹配字符串信息的对象。

let match = /\d+/.exec("one two 100");
console.log(match);
// → ["100"]
console.log(match.index);
// → 8

exec方法返回的对象包含index属性表示字符串成功匹配的起始位置。除此之外该对象看起来像而且实际上就是一个字符串数组其首元素是与模式匹配的字符串——在上面的例子中就是我们查找的数字序列。

字符串也有一个类似的match方法。

console.log("one two 100".match(/\d+/));
// → ["100"]

若正则表达式包含使用圆括号包围的子表达式分组,与这些分组匹配的文本也会出现在数组中。第一个元素是与整个模式匹配的字符串,其后是与第一个分组匹配的部分字符串(表达式中第一次出现左圆括号的那部分),然后是第二个分组。

let quotedText = /'([^']*)'/;
console.log(quotedText.exec("she said 'hello'"));
// → ["'hello'", "hello"]

若分组最后没有匹配任何字符串例如在元组后加上一个问号结果数组中与该分组对应的元素将是undefined。类似的若分组匹配了多个元素则数组中只包含最后一个匹配项。

console.log(/bad(ly)?/.exec("bad"));
// → ["bad", undefined]
console.log(/(\d)+/.exec("123"));
// → ["123", "3"]

分组是提取部分字符串的实用特性。如果我们不只是想验证字符串中是否包含日期还想将字符串中的日期字符串提取出来并将其转换成等价的日期对象那么我们可以使用圆括号包围那些匹配数字的模式字符串并直接将日期从exec的结果中提取出来。

不过,我们暂且先讨论另一个话题——在 JavaScript 中存储日期和时间的内建方法。

日期类

JavaScript提供了用于表示日期的标准类我们甚至可以用其表示时间点。该类型名为Date。如果使用new创建一个Date对象你会得到当前的日期和时间。

console.log(new Date());
// → Mon Nov 13 2017 16:19:11 GMT+0100 (CET)

你也可以创建表示特定时间的对象。

console.log(new Date(2009, 11, 9));
// → Wed Dec 09 2009 00:00:00 GMT+0100 (CET)
console.log(new Date(2009, 11, 9, 12, 59, 59, 999));
// → Wed Dec 09 2009 12:59:59 GMT+0100 (CET)

JavaScript中约定是使用从0开始的数字表示月份因此使用11表示12月而使用从1开始的数字表示日期。这非常容易令人混淆。要注意这个细节。

构造函数的后四个参数小时、分钟、秒、毫秒是可选的如果用户没有指定这些参数则参数的值默认为0。

时间戳存储为 UTC 时区中 1970 年以来的毫秒数。 这遵循一个由“Unix 时间”设定的约定,该约定是在那个时候发明的。 您可以对 1970 年以前的时间使用负数。 日期对象上的getTime方法返回这个数字。 你可以想象它会很大。

console.log(new Date(2013, 11, 19).getTime());
// → 1387407600000
console.log(new Date(1387407600000));
// → Thu Dec 19 2013 00:00:00 GMT+0100 (CET)

如果你为Date构造函数指定了一个参数构造函数会将该参数看成毫秒数。你可以创建一个新的Date对象并调用getTime方法或调用Date.now函数来获取当前时间对应的毫秒数。

Date对象提供了一些方法来提取时间中的某些数值,比如getFullYeargetMonthgetDategetHoursgetMinutesgetSeconds。除了getFullYear之外该对象还有一个getYear方法,会返回使用两位数字表示的年份(比如 93 或 14但很少用到。

通过在希望捕获的那部分模式字符串两边加上圆括号我们可以从字符串中创建对应的Date对象。

function getDate(string) {
  let [_, day, month, year] =
    /(\d{1,2})-(\d{1,2})-(\d{4})/.exec(string);
  return new Date(year, month - 1, day);
}
console.log(getDate("30-1-2003"));
// → Thu Jan 30 2003 00:00:00 GMT+0100 (CET)

_(下划线)绑定被忽略,并且只用于跳过由exec返回的数组中的,完整匹配元素。

单词和字符串边界

不幸的是,getDate会从字符串"100-1-30000"中提取出一个无意义的日期——00-1-3000。正则表达式可以从字符串中的任何位置开始匹配,在我们的例子中,它从第二个字符开始匹配,到倒数第二个字符为止。

如果我们想要强制匹配整个字符串,可以使用^标记和$标记。脱字符表示输入字符串起始位置,美元符号表示字符串结束位置。因此/^\d+$/可以匹配整个由一个或多个数字组成的字符串,/^/匹配任何以感叹号开头的字符串,而/x^/不匹配任何字符串字符串起始位置之前不可能有字符x

另一方面,如果我们想要确保日期字符串起始结束位置在单词边界上,可以使用\b标记。所谓单词边界指的是起始和结束位置都是单词字符也就是\w代表的字符集合而起始位置的前一个字符以及结束位置的后一个字符不是单词字符。

console.log(/cat/.test("concatenate"));
// → true
console.log(/\bcat\b/.test("concatenate"));
// → false

这里需要注意,边界标记并不匹配实际的字符,只在强制正则表达式满足模式中的条件时才进行匹配。

选项模式

假如我们不仅想知道文本中是否包含数字还想知道数字之后是否跟着一个单词pig、cow或chicken或其复数形式。

那么我们可以编写三个正则表达式并轮流测试,但还有一种更好的方式。管道符号(|)表示从其左侧的模式和右侧的模式任意选择一个进行匹配。因此代码如下所示。

let animalCount = /\b\d+ (pig|cow|chicken)s?\b/;
console.log(animalCount.test("15 pigs"));
// → true
console.log(animalCount.test("15 pigchickens"));
// → false

小括号可用于限制管道符号选择的模式范围,而且你可以连续使用多个管道符号,表示从多于两个模式中选择一个备选项进行匹配。

匹配原理

从概念上讲,当你使用exectest时,正则表达式引擎在你的字符串中寻找匹配,通过首先从字符串的开头匹配表达式,然后从第二个字符匹配表达式,直到它找到匹配或达到字符串的末尾。 它会返回找到的第一个匹配,或者根本找不到任何匹配。

为了进行实际的匹配,引擎会像处理流程图一样处理正则表达式。 这是上例中用于家畜表达式的图表:

如果我们可以找到一条从图表左侧通往图表右侧的路径,则可以说“表达式产生了匹配”。我们保存在字符串中的当前位置,每移动通过一个盒子,就验证当前位置之后的部分字符串是否与该盒子匹配。

因此,如果我们尝试从位置 4 匹配"the 3 pigs",大致会以如下的过程通过流程图:

  • 在位置4有一个单词边界因此我们通过第一个盒子。

  • 依然在位置4我们找到一个数字因此我们通过第二个盒子。

  • 在位置5有一条路径循环回到第二个盒子数字之前而另一条路径则移动到下一个盒子单个空格字符。由于这里是一个空格而非数字因此我们必须选择第二条路径。

  • 我们目前在位置6pig的起始位置而表中有三路分支。这里看不到“cow”或“chicken”但我们看到了“pig”因此选择“pig”这条分支。

  • 在位置9三路分支之后有一条路径跳过了s这个盒子直接到达最后的单词边界另一条路径则匹配s。这里有一个s字符而非单词边界因此我们通过s这个盒子。

  • 我们在位置10字符串结尾只能匹配单词边界。而字符串结尾可以看成一个单词边界因此我们通过最后一个盒子成功匹配字符串。

回溯

正则表达式/\b[01]+b|\d+|[\da-f]h\b/可以匹配三种字符串以b结尾的二进制数字以h结尾的十六进制数字即以16为进制字母a到f表示数字10到15或者没有后缀字符的常规十进制数字。这是对应的图表。

当匹配该表达式时常常会发生一种情况输入的字符串进入上方二进制分支的匹配过程但输入中并不包含二进制数字。我们以匹配字符串“103”为例匹配过程只有遇到字符3时才知道进入了错误分支。该字符串匹配我们给出的表达式但没有匹配目前应当处于的分支。

因此匹配器执行“回溯”。进入一个分支时,匹配器会记住当前位置(在本例中,是在字符串起始,刚刚通过图中第一个表示边界的盒子),因此若当前分支无法匹配,可以回退并尝试另一条分支。对于字符串"103"遇到字符3之后它会开始尝试匹配十六进制数字的分支它会再次失败因为数字后面没有h。所以它尝试匹配进制数字的分支由于这条分支可以匹配因此匹配器最后的会返回十进制数的匹配信息。

一旦字符串与模式完全匹配,匹配器就会停止。这意味着多个分支都可能匹配一个字符串,但匹配器最后只会使用第一条分支(按照出现在正则表达式中的出现顺序排序)。

回溯也会发生在处理重复模式运算符(比如+和*时。如果使用“abcxe”匹配/^.x/.部分首先尝试匹配整个字符串接着引擎发现匹配模式还需要一个字符x。由于字符串结尾没有x因此运算符尝试少匹配一个字符。但匹配器依然无法在abcx之后找到x字符因此它会再次回溯此时运算法只匹配abc。现在匹配器发现了所需的x接着报告从位置0到位置4匹配成功。

我们有可能编写需要大量回溯的正则表达式。当模式能够以许多种不同方式匹配输入的一部分时,这种问题就会出现。例如,若我们在编写匹配二进制数字的正则表达式时,一时糊涂,可能会写出诸如/[01]++b/之类的表达式。

若我们尝试匹配一些只由0与1组成的长序列匹配器首先会不断执行内部循环直到它发现没有数字为止。接下来匹配器注意到这里不存在b因此向前回溯一个位置开始执行外部循环接着再次放弃再次尝试执行一次内部循环。该过程会尝试这两个循环的所有可能路径。这意味着每多出一个字符其工作量就会加倍。甚至只需较少的一堆字符就可使匹配实际上永不停息地执行下去。

9.12 replace方法

字符串有一个replace方法该方法可用于将字符串中的一部分替换为另一个字符串。

console.log("papa".replace("p", "m"));
// → mapa

该方法第一个参数也可以是正则表达式这种情况下会替换正则表达式首先匹配的部分字符串。若在正则表达式后追加g选项全局Global该方法会替换字符串中所有匹配项而不是只替换第一个。

console.log("Borobudur".replace(/[ou]/, "a"));
// → Barobudur
console.log("Borobudur".replace(/[ou]/g, "a"));
// → Barabadar

如果JavaScript为replace添加一个额外参数或提供另一个不同的方法replaceAll来区分替换一次匹配还是全部匹配将会是较为明智的方案。遗憾的是因为某些原因JavaScript依靠正则表达式的属性来区分替换行为。

如果我们在替换字符串中使用元组就可以体现出replace方法的真实威力。例如假设我们有一个规模很大的字符串包含了人的名字每个名字占据一行名字格式为“姓名”。若我们想要交换姓名并移除中间的逗号转变成名姓这种格式我们可以使用下面的代码

console.log(
  "Liskov, Barbara\nMcCarthy, John\nWadler, Philip"
    .replace(/(\w+), (\w+)/g, "$2 $1"));
// → Barbara Liskov
//   John McCarthy
//   Philip Wadler

替换字符串中的$1和$2引用了模式中使用圆括号包裹的元组。$1会替换为第一个元组匹配的字符串$2会替换为第二个依次类推直到$9为止。也可以使用$&来引用整个匹配。

第二个参数不仅可以使用字符串,还可以使用一个函数。每次匹配时,都会调用函数并以匹配元组(也可以是匹配整体)作为参数,该函数返回值为需要插入的新字符串。

这里给出一个小示例:

let s = "the cia and fbi";
console.log(s.replace(/\b(fbi|cia)\b/g,
            str => str.toUpperCase()));
// → the CIA and FBI

这里给出另一个值得讨论的示例:

let stock = "1 lemon, 2 cabbages, and 101 eggs";
function minusOne(match, amount, unit) {
  amount = Number(amount) - 1;
  if (amount == 1) { // only one left, remove the 's'
    unit = unit.slice(0, unit.length - 1);
  } else if (amount == 0) {
    amount = "no";
  }
  return amount + " " + unit;
}
console.log(stock.replace(/(\d+) (\w+)/g, minusOne));
// → no lemon, 1 cabbage, and 100 eggs

该程序接受一个字符串,找出所有满足模式“一个数字紧跟着一个单词(数字和字母)”的字符串,返回时将捕获字符串中的数字减一。

元组(\d+最后会变成函数中的amount参数\w+元组将会绑定unit。该函数将amount转换成数字由于该参数是\d+的匹配结果因此此过程总是执行成功并根据剩下0还是1决定如何做出调整。

9.13 贪婪模式

使用replace编写一个函数移除JavaScript代码中的所有注释也是可能的。这里我们尝试一下

function stripComments(code) {
  return code.replace(/\/\/.*|\/\*[^]*\*\//g, "");
}
console.log(stripComments("1 + /* 2 */3"));
// → 1 + 3
console.log(stripComments("x = 10;// ten!"));
// → x = 10;
console.log(stripComments("1 /* a */+/* b */ 1"));
// → 1  1

或运算符之前的部分匹配两个斜杠字符,后面跟着任意数量的非换行字符。多行注释部分较为复杂,我们使用[^](任何非空字符集合)来匹配任意字符。我们这里无法使用句号,因为块注释可以跨行,句号无法匹配换行符。

但最后一行的输出显然有错。

为何?

在回溯一节中已经提到过,表达式中的[^]部分会首先匹配所有它能匹配的部分。如果其行为引起模式的下一部分匹配失败,匹配器才会回溯一个字符,并再次尝试。在本例中,匹配器首先匹配整个剩余字符串,然后向前移动。匹配器回溯四个字符后,会找到/,并完成匹配。这并非我们想要的结果。我们的意图是匹配单个注释,而非到达代码末尾并找到最后一个块注释的结束部分。

因为这种行为,所以我们说模式重复运算符(+、、?和{})是“贪婪”的,指的是这些运算符会尽量多地匹配它们可以匹配的字符,然后回溯。若读者在这些符号后加上一个问号(+?、?、??、{}?),它们会变成非贪婪的,此时这些符号会尽量少地匹配字符,只有当剩下的模式无法匹配时才会多进行匹配。

而这便是我们想要的情况。通过让星号尽量少地匹配字符,我们可以匹配第一个*/,进而匹配一个块注释,而不会匹配过多内容。

function stripComments(code) {
  return code.replace(/\/\/.*|\/\*[^]*?\*\//g, "");
}
console.log(stripComments("1 /* a */+/* b */ 1"));
// → 1 + 1

对于使用了正则表达式的程序而言,其中出现的大量缺陷都可归咎于一个问题:在非贪婪模式效果更好时,无意间错用了贪婪运算符。若使用了模式重复运算符,请首先考虑一下是否可以使用非贪婪符号替代贪婪运算符。

9.14 动态创建RegExp对象

有些情况下,你无法在编写代码时准确知道需要匹配的模式。假设你想寻找文本片段中的用户名,并使用下划线字符将其包裹起来使其更显眼。由于你只有在程序运行时才知道姓名,因此你无法使用基于斜杠的记法。

但你可以构建一个字符串并使用RegExp构造函数根据该字符串构造正则表达式对象。

这里给出一个示例。

let name = "harry";
let text = "Harry is a suspicious character.";
let regexp = new RegExp("\\b(" + name + ")\\b", "gi");
console.log(text.replace(regexp, "_$1_"));
// → _Harry_ is a suspicious character.

由于我们创建正则表达式时使用的是普通字符串,而非使用斜杠包围的正则表达式,因此如果想创建\b边界我们不得不使用两个反斜杠。RegExp构造函数的第二个参数包含了正则表达式选项。在本例中“gi”表示全局和不区分大小写。

但由于我们的用户是怪异的青少年如果用户将名字设定为“dea+hl[]rd”将会发生什么这将会导致正则表达式变得没有意义无法匹配用户名。

为了能够处理这种情况,我们可以在任何有特殊含义的字符前添加反斜杠。

let name = "dea+hl[]rd";
let text = "This dea+hl[]rd guy is super annoying.";
let escaped = name.replace(/[^\w\s]/g, "\\$&");
let regexp = new RegExp("\\b(" + escaped + ")\\b", "gi");
console.log(text.replace(regexp, "_><_"));
// → This _dea+hl[]rd_ guy is super annoying.

9.15 search方法

字符串的indexOf方法不支持以正则表达式为参数。

但还有一个search方法调用该方法时需要传递一个正则表达式。类似于indexOf该方法会返回首先匹配的表达式的索引若没有找到则返回1。

console.log("  word".search(/\S/));
// → 2
console.log("    ".search(/\S/));
// → -1

遗憾的是没有任何方式可以指定匹配的起始偏移就像indexOf的第二个参数而指定起始偏移这个功能是很实用的。

9.16 lastIndex属性

exec方法同样没提供方便的方法来指定字符串中的起始匹配位置。但我们可以使用一种比较麻烦的方法来实现该功能。

正则表达式对象包含了一些属性。其中一个属性是source该属性包含用于创建正则表达式的字符串。另一个属性是lastIndex可以在极少数情况下控制下一次匹配的起始位置。

所谓的极少数情况指的是当正则表达式启用了全局g或者粘性y并且使用exec匹配模式的时候。此外另一个解决方案应该是向exec传递的额外参数但JavaScript的正则表达式接口能设计得如此合理才是怪事。

let pattern = /y/g;
pattern.lastIndex = 3;
let match = pattern.exec("xyzzy");
console.log(match.index);
// → 4
console.log(pattern.lastIndex);
// → 5

如果成功匹配模式exec调用会自动更新lastIndex属性来指向匹配字符串后的位置。如果无法匹配会将lastIndex清零就像新构建的正则表达式对象lastIndex属性为零一样

全局和粘性选项之间的区别在于,启用粘性时,仅当匹配直接从lastIndex开始时,搜索才会成功,而全局搜索中,它会搜索匹配可能起始的所有位置。

let global = /abc/g;
console.log(global.exec("xyz abc"));
// → ["abc"]
let sticky = /abc/y;
console.log(sticky.exec("xyz abc"));
// → null

对多个exec调用使用共享的正则表达式值时,这些lastIndex属性的自动更新可能会导致问题。 您的正则表达式可能意外地在之前的调用留下的索引处开始。

let digit = /\d/g;
console.log(digit.exec("here it is: 1"));
// → ["1"]
console.log(digit.exec("and now: 1"));
// → null

全局选项还有一个值得深思的效果它会改变match匹配字符串的工作方式。如果调用match时使用了全局表达式不像exec返回的数组match会找出所有匹配模式的字符串并返回一个包含所有匹配字符串的数组。

console.log("Banana".match(/an/g));
// → ["an", "an"]

因此使用全局正则表达式时需要倍加小心。只有以下几种情况中你确实需要全局表达式即调用replace方法时或是需要显示使用lastIndex时。这也基本是全局表达式唯一的应用场景了。

循环匹配

一个常见的事情是找出字符串中所有模式的出现位置这种情况下我们可以在循环中使用lastIndex和exec访问匹配的对象。

let input = "A string with 3 numbers in it... 42 and 88.";
let number = /\b(\d+)\b/g;
let match;
while (match = number.exec(input)) {
  console.log("Found", match[0], "at", match.index);
}
// → Found 3 at 14
//   Found 42 at 33
//   Found 88 at 40

这里我们利用了赋值表达式的一个特性该表达式的值就是被赋予的值。因此通过使用match=re.execinput作为while语句的条件我们可以在每次迭代开始时执行匹配将结果保存在变量中当无法找到更多匹配的字符串时停止循环。

9.17 解析INI文件

为了总结一下本章介绍的内容,我们来看一下如何调用正则表达式来解决问题。假设我们编写一个程序从因特网上获取我们敌人的信息(这里我们实际上不会编写该程序,仅仅编写读取配置文件的那部分代码,对不起)。配置文件如下所示。

searchengine=https://duckduckgo.com/?q=$1
spitefulness=9.7

; comments are preceded by a semicolon...
; each section concerns an individual enemy
[larry]
fullname=Larry Doe
type=kindergarten bully
website=http://www.geocities.com/CapeCanaveral/11451

[davaeorn]
fullname=Davaeorn
type=evil wizard
outputdir=/home/marijn/enemies/davaeorn

该配置文件格式的语法规则如下所示它是广泛使用的格式我们通常称之为INI文件

  • 忽略空行和以分号起始的行。

  • 使用[]包围的行表示一个新的节section

  • 如果行中是一个标识符(包含字母和数字),后面跟着一个=字符,则表示向当前节添加选项。

  • 其他的格式都是无效的。

我们的任务是将这样的字符串转换成对象的数组数组中每个元素包含一个name属性和一个选项数组。我们需要使用一个对象表示节也需要使用一个对象表示全局选项。

由于我们需要逐行处理这种格式的文件因此预处理时最好将文件分割成一行行文本。我们使用第6章中的string.split“\n”来分割文件内容。但是一些操作系统并非使用换行符来分隔行而是使用回车符加换行符“\r\n”。考虑到这点我们也可以使用正则表达式作为split方法的参数我们使用类似于/\r\n/的正则表达式,这样可以同时支持“\n”和“\r\n”两种分隔符。

function parseINI(string) {
  // Start with an object to hold the top-level fields
  let currentSection = {name: null, fields: []};
  let categories = [currentSection];

  string.split(/\r?\n/).forEach(function(line) {
    let match;
    if (/^\s*(;.*)?$/.test(line)) {
      return;
    } else if (match = line.match(/^\[(.*)\]$/)) {
      currentSection = {name: match[1], fields: []};
      categories.push(currentSection);
    } else if (match = line.match(/^(\w+)=(.*)$/)) {
      currentSection.fields.push({name: match[1],
                                  value: match[2]});
    } else {
      throw new Error("Line '" + line + "' is invalid.");
    }
  });

  return categories;
}

上面这段代码会遍历文件中的每一行,并更新当前节对应的对象。首先,使用表达式/^\s*.*$/检查是否应该忽略该行。你能看出这段表达式是如何工作的吗?括号中的表达式负责匹配注释,而?则匹配全为空白字符的行。

如果这行文本不是注释,代码会检查这一行是否是一个新节的起始位置。如果是的话,则创建一个新的节对象,并将后续选项都添加到这个对象中。

这行文本还可能是一个普通选项,这种情况下代码会将选项添加到当前节对象中。

如果某行文本无法匹配任何模式,那么函数会抛出一个错误。

这里需要注意,我们反复使用^和$确保表达式匹配整行,而非一行中的一部分。如果不使用这两个符号,大多数情况下程序也可以正常工作,但在处理特定输入时,程序就会出现不合理的行为,我们一般很难发现这个缺陷的问题所在。

类似于在while循环中使用赋值表达式ifmatch=string.match...这种形式的表达式也是一种技巧。一般我们无法确定match是否执行成功因此一般只需要在确实存在匹配结果时我们才在if语句中访问match的匹配结果。为了不打破if这种优雅的链形式我们需要将匹配结果赋值给一个变量并使用赋值表达式的值来作为if中的判断条件。

9.18 国际化字符

由于JavaScript最初的实现非常简单而且这种简单的处理方式后来也成了标准因此JavaScript正则表达式处理非英语字符时非常无力。例如就JavaScript的正则表达式而言“单词字符”只是26个拉丁字母大写和小写而且由于某些原因还包括下划线字符。

像a`或或β这种明显的单词字符,则无法匹配\w会匹配大写的\W因为它们属于非单词字符

由于奇怪的历史性意外,\s空白字符则没有这种问题会匹配所有Unicode 标准中规定的空白字符,包括不间断空格和蒙古文元音分隔符。

一些程序设计语言中的正则表达式语法实现可以匹配特定Unicode 字符集比如所有大写字母所有标点符号或控制字符JavaScript 中也计划加入对这些字符集的支持,但遗憾的是在不远的将来这貌似是无法实现的。

9.19 本章小结

正则表达式是表示字符串模式的对象,使用自己的语法来表达这些模式:

  • /abc/:字符序列

  • /[abc]/:字符集中的任何字符

  • /[^abc]/:任何不在字符集中的字符

  • /[0-9]/:任何在字符范围内的字符

  • /x+/出现模式x一次或多次

  • /x+/出现模式x一次或多次非贪婪模式

  • /x*/:出现模式次或多次

  • /x/:出现模式零次或多次,非贪婪模式

  • /x{24}/:出现次数在两个数字范围之间

  • /abc/:元组

  • /a|b|c/:匹配任意一个模式

  • /\d/:数字字符

  • /\w/:字母和数字字符(单词字符)

  • /\s/:任意空白字符

  • /./:任意字符(除换行符外)

  • /\b/:单词边界

  • /^/:输入起始位置

  • /$/:输入结束位置

正则表达式有test方法用于测试给定的字符串是否匹配模式。还有一个exec方法当匹配模式后返回包含所有匹配元组的数组。这类数组有一个index属性用于指出匹配的起始位置。

字符串有一个match方法可以使用正则表达式进行匹配还有一个search方法可以搜索一个匹配项返回匹配的起始位置。字符串的replace方法可以使用匹配字符串替换原字符串中匹配模式的文本。你还可以向replace传递一个函数根据匹配文本和匹配元组创建匹配字符串。

正则表达式可以在结尾的斜杠后添加选项。选项i使得匹配不区分大小写选项g使得表达式变成全局匹配这种情况下replace方法会替换所有匹配项而非第一项。

RegExp构造函数可以用于通过字符串创建正则表达式。

正则表达式是难以驾驭的强力工具。它可以简化一些任务,但用到一些复杂问题上时也会难以控制管理。想要学会使用正则表达式的重要一点是:不要将其用到无法使用正则表达式描述的问题中。

9.20 习题

在做本章习题时,读者不可避免地会对一些正则表达式的莫名其妙的行为感到困惑,因而备受挫折。读者可以使用类似于http://debuggex.com/这样的在线学习工具,将你想编写的正则表达式可视化,并试验其对不同输入字符串的响应。

9.20.1 RegexpGolf

Code Golf是一种游戏尝试尽量用最少的字符来描述特定程序。类似的Regexp Golf这种活动是编写尽量短小的正则表达式来匹配给定模式而且只能匹配给定模式

针对以下几项,编写正则表达式,测试给定的子串是否在字符串中出现。正则表达式匹配的字符串,应该只包含以下描述的子串之一。除非明显提到单词边界,否则千万不要担心边界问题。当你的表达式有效时,请检查一下能否让正则表达式更短小。

1.car和cat

2.pop和prop

3.ferret、ferry和ferrari

4.以ious结尾的单词

5.空白字符后面紧跟着句号、冒号、分号

6.多于六个字母的单词

7.不包含e的单词

需要帮助时,请参考本章总结中的表格。使用少量测试字符串来测试每个解决方案。

// Fill in the regular expressions

verify(/.../,
       ["my car", "bad cats"],
       ["camper", "high art"]);

verify(/.../,
       ["pop culture", "mad props"],
       ["plop"]);

verify(/.../,
       ["ferret", "ferry", "ferrari"],
       ["ferrum", "transfer A"]);

verify(/.../,
       ["how delicious", "spacious room"],
       ["ruinous", "consciousness"]);

verify(/.../,
       ["bad punctuation ."],
       ["escape the dot"]);

verify(/.../,
       ["hottentottententen"],
       ["no", "hotten totten tenten"]);

verify(/.../,
       ["red platypus", "wobbling nest"],
       ["earth bed", "learning ape"]);


function verify(regexp, yes, no) {
  // Ignore unfinished exercises
  if (regexp.source == "...") return;
  yes.forEach(function(s) {
    if (!regexp.test(s))
      console.log("Failure to match '" + s + "'");
  });
  no.forEach(function(s) {
    if (regexp.test(s))
      console.log("Unexpected match for '" + s + "'");
  });
}

9.20.2 QuotingStyle

想象一下,你编写了一个故事,自始至终都使用单引号来标记对话。现在你想要将对话的引号替换成双引号,但不能替换在缩略形式中使用的单引号。

思考一下可以区分这两种引号用法的模式并手动调用replace方法进行正确替换。

9.20.3 NumbersAgain

一个数字序列可以使用简单的正则表达式/\d+/匹配。

编写一个表达式只匹配JavaScript风格的数字。支持数字前可选的正号与负号、十进制小数点、指数计数法5e-3或1E10指数前也需要支持可选的符号。也请注意小数点前或小数点后的数字也是不必要的但数字不能只有小数点。例如.5和5.都是合法的JavaScript数字但单个点则不是。