diff --git a/9.md b/9.md new file mode 100644 index 0000000..d21b9b1 --- /dev/null +++ b/9.md @@ -0,0 +1,742 @@ +## 九、正则表达式 + +程序设计工具技术的发展与传播方式是在混乱中不断进化。在此过程中获胜的往往不是优雅或杰出的一方,而是那些瞄准主流市场,并能够填补市场需求的工具技术——比如我们可以将一种技术与另一种成功的技术集成在一起。 + +本章将会讨论正则表达式(regular expression)这种工具。正则表达式是一种描述字符串数据模式的方法。在JavaScript和其他语言或工具中,正则表达式往往是一种小巧且独立的语言。 + +正则表达式虽然不易理解,但是功能非常强大。正则表达式的语法有点诡异,JavaScript提供的程序设计接口也不太易用。但正则表达式的确是检查、处理字符串的强力工具。如果读者能够正确理解正则表达式,将会成为更高效的程序员。 + +### 9.1 创建正则表达式 + +正则表达式是一种对象类型。我们可以使用两种方法来构造正则表达式:一是使用RegExp构造函数构造一个正则表达式对象;二是使用斜杠(/)字符将模式包围起来,生成一个字面值。 + +``` +var re1 = new RegExp("abc"); +var re2 = /abc/; +``` + +这两个正则表达式对象都表示相同的模式:字符a后紧跟一个b,接着紧跟一个c。 + +使用RegExp构造函数时,需要将模式书写成普通的字符串,因此反斜杠的使用规则与往常相同。 + +第二种写法将模式写在斜杠之间,处理反斜杠的方式与第一种方法略有差别。首先,由于斜杠会结束整个模式,因此模式中包含斜杠时,需在斜杠前加上反斜杠。此外,如果反斜杠不是特殊字符代码(比如\n)的一部分,则会保留反斜杠,不像字符串中会将其忽略,也不会改变模式的含义。一些字符,比如问号、加号在正则表达式中有特殊含义,如果你想要表示其字符本身,需要在字符前加上反斜杠。 + +``` +var eighteenPlus = /eighteen\+/; +``` + +编写正则表达式时,如果想知道哪些字符需要使用反斜杠进行转义,则需要知道所有具有特殊含义的字符。眼下这是不切实际的,因此当不确定字符是否具有特殊含义,而且不是字母、数字和空格时,只要在这些字符前加上反斜杠即可。 + +### 9.2 匹配测试 + +正则表达式对象有许多方法。其中最简单的就是test方法。test方法接受用户传递的字符串,并返回一个布尔值,表示字符串中是否包含能与表达式模式匹配的字符串。 + +``` +console.log(/abc/.test("abcde")); +// → true +console.log(/abc/.test("abxde")); +// → false +``` + +不包含特殊字符的正则表达式简单地表示一个字符序列。如果使用test测试字符串时,字符串中某处出现abc(不一定在开头),则返回true。 + +### 9.3 匹配字符集 + +我们也可调用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-200315:20这样的日期数字格式: + +``` +var 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)某个字符集,即表示不匹配这组字符中的任何字符。 + +``` +var notBinary = /[^01]/; +console.log(notBinary.test("1100100010100110")); +// → false +console.log(notBinary.test("1100100010200110")); +// → true +``` + +### 9.4 部分模式重复 + +现在我们已经知道如何匹配一个数字。如果我们想匹配一个整数(一个或多个数字的序列),该如何处理呢? + +在正则表达式某个元素后面添加一个加号(+),表示该元素至少重复一次。因此/\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次。下面的例子可以匹配neighbour(u出现1次),也可以匹配neighbor(u没有出现)。 + +``` +var neighbor = /neighbou?r/; +console.log(neighbor.test("neighbour")); +// → true +console.log(neighbor.test("neighbor")); +// → true +``` + +我们可以使用花括号准确指明某个模式的出现次数。例如,在某个元素后加上{4},则该模式需要出现且只能出现4次。也可以使用花括号指定一个范围:比如{2,4}表示该元素至少出现2次,至多出现4次。 + +这里给出另一个版本的正则表达式,可以匹配日期、月份、小时,每个数字都可以是一位或两位数字。这种形式更易于理解。 + +``` +var 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.}表示至少五次。 + +### 9.5 子表达式分组 + +如果想一次性对多个元素使用*或者+,那么你需要使用圆括号将这些元素包围起来,创建一个分组。这些操作符会将包围在括号中的那部分正则表达式当作一个整体处理。 + +``` +var cartoonCrying = /boo+(hoo+)+/i; +console.log(cartoonCrying.test("Boohoooohoohooo")); +// → true +``` + +第一个和第二个+字符分别作用于boo与hoo的o字符,而第三个+字符则作用于整个元组(hoo+),可以匹配hoo+这种正则表达式出现一次及一次以上的情况。 + +上述示例中表达式末尾的i表示正则表达式不区分大小写,虽然模式中使用小写字母,但可以匹配输入字符串中的大写字母B。 + +### 9.6 匹配和分组 + +test方法是匹配正则表达式最简单的方法。该方法只负责判断字符串是否与某个模式匹配。正则表达式还有一个exec(执行,execute)方法,如果无法匹配模式则返回null,否则返回一个表示匹配字符串信息的对象。 + +``` +var 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"] +``` + +若正则表达式包含使用圆括号包围的子表达式分组,与这些分组匹配的文本也会出现在数组中。第一个元素是与整个模式匹配的字符串,其后是与第一个分组匹配的部分字符串(表达式中第一次出现左圆括号的那部分),然后是第二个分组。 + +``` +var 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中较为推荐的存储日期和时间的方法。 + +### 9.7 日期类型 + +JavaScript提供了用于表示日期的标准对象类型,我们甚至可以用其表示时间点。该类型名为Date。如果使用new创建一个Date对象,你会得到当前的日期和时间。 + +``` +console.log(new Date()); +// → Wed Dec 04 2013 14:24:57 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。 + +我们使用从1970年开始流逝的毫秒数表示时间戳,如果在1970年前,则使用负数(遵循由“Unix时间”设定的约定,Unix时间就是在1970左右创造出来的)。Date对象的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对象提供了一些方法来提取时间中的某些数值,比如getFullYear、getMonth、getDate、getHours、getMinutes、getSeconds。该对象还有一个getYear方法,会返回使用两位数字表示的年份(比如93或14),但很少用到。 + +我们可以在希望捕获的那部分模式字符串两边加上圆括号,轻松地通过字符串创建对应的Date对象。 + +``` +function findDate(string) { + var dateTime = /(\d{1,2})-(\d{1,2})-(\d{4})/; + var match = dateTime.exec(string); + return new Date(Number(match[3]), + Number(match[2]) - 1, + Number(match[1])); +} +console.log(findDate("30-1-2003")); +// → Thu Jan 30 2003 00:00:00 GMT+0100 (CET) +``` + +### 9.8 单词和字符串边界 + +不幸的是,findDate会从字符串“100-1-30000”中提取出一个无意义的日期——00-1-3000。正则表达式可以从字符串中的任何位置开始匹配,在我们的例子中,它从第二个字符开始匹配,到倒数第二个字符为止。 + +如果我们想要强制匹配整个字符串,可以使用^标记和$标记。脱字符表示输入字符串起始位置,美元符号表示字符串结束位置。因此/^\d+$/可以匹配整个由一个或多个数字组成的字符串,/^!/匹配任何以感叹号开头的字符串,而/x^/不匹配任何字符串(字符串起始位置之前不可能有字符x)。 + +另一方面,如果我们想要确保日期字符串起始结束位置在单词边界上,可以使用\b标记。所谓单词边界,指的是起始和结束位置都是单词字符(也就是\w代表的字符集合),而起始位置的前一个字符以及结束位置的后一个字符不是单词字符。 + +``` +console.log(/cat/.test("concatenate")); +// → true +console.log(/\bcat\b/.test("concatenate")); +// → false +``` + +这里需要注意,边界标记并不表示实际的字符,只在强制正则表达式满足模式中的条件时才进行匹配。 + +### 9.9 选项模式 + +假如我们不仅想知道文本中是否包含数字,还想知道数字之后是否跟着一个单词(pig、cow或chicken)或其复数形式。 + +那么我们可以编写三个正则表达式并轮流测试,但还有一种更好的方式。管道符号(|)表示从其左侧的模式和右侧的模式任意选择一个进行匹配。因此代码如下所示。 + +``` +var animalCount = /\b\d+ (pig|cow|chicken)s?\b/; +console.log(animalCount.test("15 pigs")); +// → true +console.log(animalCount.test("15 pigchickens")); +// → false +``` + +小括号可用于限制管道符号选择的模式范围,而且你可以连续使用多个管道符号,表示从多于两个模式中选择一个模式进行匹配。 + +### 9.10 匹配原理 + +我们可以将正则表达式看成流程图。下图描述了前文示例中匹配牲畜的表达式。 + +![](../Images/00302.jpeg) + +如果我们可以找到一条从图表左侧通往图表右侧的路径,则可以说“表达式匹配了字符串”。我们保存在字符串中的当前位置,每移动通过一个盒子,就验证当前位置之后的部分字符串是否与该盒子匹配。 + +因此,如果我们尝试使用我们的正则表达式匹配“the 3 pigs”,大致会以如下的过程通过流程图: + ++ 在位置4,有一个单词边界,因此我们通过第一个盒子。 + ++ 依然在位置4,我们找到一个数字,因此我们通过第二个盒子。 + ++ 在位置5,有一条路径循环回到第二个盒子(数字)之前,而另一条路径则移动到下一个盒子(单个空格字符)。由于这里是一个空格,而非数字,因此我们必须选择第二条路径。 + ++ 我们目前在位置6(pig的起始位置),而表中有三路分支。这里看不到“cow”或“chicken”,但我们看到了“pig”,因此选择“pig”这条分支。 + ++ 在位置9(三路分支之后),有一条路径跳过了s这个盒子,直接到达最后的单词边界,另一条路径则匹配s。这里有一个s字符,而非单词边界,因此我们通过s这个盒子。 + ++ 我们在位置10(字符串结尾),只能匹配单词边界。而字符串结尾可以看成一个单词边界,因此我们通过最后一个盒子,成功匹配字符串。 + +从概念上讲,正则表达式引擎在字符串中寻找匹配部分时,会从字符串起始位置尝试匹配。在本例中,有一个单词边界,因此引擎通过第一个盒子,但这里没有数字,因此在第二个盒子这里失败。接着引擎移动到字符串中第二个字符,开始新一轮匹配过程……以此类推,直到其找到一个匹配部分,或到达字符串结尾(确定没有任何匹配项)为止。 + +### 9.11 回溯 + +正则表达式/\b([01]+b|\d+|[\da-f]h)\b/可以匹配三种字符串:以b结尾的二进制数字、没有后缀字符的十进制数字、以h结尾的十六进制数字(即以16为基,字母a到f表示数字10到15)。这是对应的图表。 + +当匹配该表达式时,常常会发生一种情况:输入的字符串进入上方(二进制)分支的匹配过程,但输入中并不包含二进制数字。我们以匹配字符串“103”为例,匹配过程只有遇到字符3时才知道进入了错误分支。该字符串匹配我们给出的表达式,但没有匹配目前应当处于的分支。 + +因此匹配器需要“回溯”。进入一个分支时,匹配器会记住当前位置(在本例中,是在字符串起始,刚刚通过图中第一个表示边界的盒子),因此若当前分支无法匹配,可以回退并尝试另一条分支。遇到字符3时,字符串“103”会开始尝试匹配十进制数字那条分支。由于这条分支可以匹配,因此匹配器最后的会返回十进制数的匹配信息。 + +![](../Images/00303.jpeg) + +一旦字符串与模式完全匹配,匹配器就会停止。这意味着多个分支都可能匹配一个字符串,但匹配器最后只会使用第一条分支(按照出现在正则表达式中的出现顺序排序)。 + +回溯也会发生在处理重复模式运算符(比如+和*)时。如果使用“abcxe”匹配/^.*x/,.*部分,首先尝试匹配整个字符串,接着引擎发现匹配模式还需要一个字符x。由于字符串结尾没有x,因此*运算符尝试少匹配一个字符。但匹配器依然无法在abcx之后找到x字符,因此它会再次回溯,此时*运算法只匹配abc。现在匹配器发现了所需的x,接着报告从位置0到位置4匹配成功。 + +我们有可能编写需要大量回溯的正则表达式。当模式能够以许多种不同方式匹配输入的一部分时,这种问题就会出现。例如,若我们在编写匹配二进制数字的正则表达式时,一时糊涂,可能会写出诸如/([01]+)+b/之类的表达式。 + +![](../Images/00304.jpeg) + +若我们尝试匹配一些只由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( + "Hopper, Grace\nMcCarthy, John\nRitchie, Dennis" + .replace(/([\w ]+), ([\w ]+)/g, "$2 $1")); +// → Grace Hopper +// John McCarthy +// Dennis Ritchie +``` + +替换字符串中的$1和$2引用了模式中使用圆括号包裹的元组。$1会替换为第一个元组匹配的字符串,$2会替换为第二个,依次类推,直到$9为止。也可以使用$&来引用整个匹配。 + +第二个参数不仅可以使用字符串,还可以使用一个函数。每次匹配时,都会调用函数并以匹配元组(也可以是匹配整体)作为参数,该函数返回值为需要插入的新字符串。 + +这里给出一个简单示例: + +``` +var s = "the cia and fbi"; +console.log(s.replace(/\b(fbi|cia)\b/g, function(str) { + return str.toUpperCase(); +})); +// → the CIA and FBI +``` + +这里给出另一个值得讨论的示例: + +``` +var 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构造函数根据该字符串构造正则表达式对象。 + +这里给出一个示例。 + +``` +var name = "harry"; +var text = "Harry is a suspicious character."; +var regexp = new RegExp("\\b(" + name + ")\\b", "gi"); +console.log(text.replace(regexp, "_$1_")); +// → _Harry_ is a suspicious character. +``` + +由于我们创建正则表达式时使用的是普通字符串,而非使用斜杠包围的正则表达式,因此如果想创建\b边界,我们不得不使用两个反斜杠。RegExp构造函数的第二个参数包含了正则表达式选项。在本例中,“gi”表示全局和不区分大小写。 + +但由于我们的用户是怪异的青少年,如果用户将名字设定为“dea+hl[]rd”,将会发生什么?这将会导致正则表达式变得没有意义,无法匹配用户名。 + +为了能够处理这种情况,我们可以在任何不信任的字符前添加反斜杠。在字符前添加反斜杠并不是个好主意,因为\b和\n这些字符都有特殊含义。但对所有非字母和数字字符进行转义则是安全的。 + +``` +var name = "dea+hl[]rd"; +var text = "This dea+hl[]rd guy is super annoying."; +var escaped = name.replace(/[^\w\s]/g, "\\$&"); +var regexp = new RegExp("\\b(" + escaped + ")\\b", "gi"); +console.log(text.replace(regexp, "_$1_")); +// → 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),并且使用exec匹配模式的时候。此外,另一个解决方案应该是向exec传递的额外参数,但JavaScript的正则表达式接口能设计得如此合理才是怪事。 + +``` +var pattern = /y/g; +pattern.lastIndex = 3; +var match = pattern.exec("xyzzy"); +console.log(match.index); +// → 4 +console.log(pattern.lastIndex); +// → 5 +``` + +如果成功匹配模式,exec调用会自动更新lastIndex属性,来指向匹配字符串后的位置。如果无法匹配,会将lastIndex清零(就像新构建的正则表达式对象lastIndex属性为零一样)。 + +若使用同一个全局正则表达式对象多次调用exec,自动更新lastIndex属性可能造成问题。正则表达式可能恰好处于前一次调用exec之后剩余字符的起始位置。 + +``` +var 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访问匹配的对象。 + +``` +var input = "A string with 3 numbers in it... 42 and 88."; +var number = /\b(\d+)\b/g; +var match; +while (match = number.exec(input)) + console.log("Found", match[1], "at", match.index); +// → Found 3 at 14 +// Found 42 at 33 +// Found 88 at 40 +``` + +这里我们利用了赋值表达式的一个特性,该表达式的值就是被赋予的值。因此通过使用match=re.exec(input)作为while语句的条件,我们可以在每次迭代开始时执行匹配,将结果保存在变量中,当无法找到更多匹配的字符串时停止循环。 + +### 9.17 解析INI文件 + +为了总结一下本章介绍的内容,我们来看一下如何调用正则表达式来解决问题。假设我们编写一个程序从因特网上获取我们敌人的信息(这里我们实际上不会编写该程序,仅仅编写读取配置文件的那部分代码,对不起,让读者失望了)。配置文件如下所示。 + +``` +searchengine=http://www.google.com/search?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 + +[gargamel] +fullname=Gargamel +type=evil sorcerer +outputdir=/home/marijn/enemies/gargamel +``` + +该配置文件格式的语法规则如下所示(实际上这是广泛使用的格式,我们通常称之为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 + var currentSection = {name: null, fields: []}; + var categories = [currentSection]; + + string.split(/\r?\n/).forEach(function(line) { + var 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循环中使用赋值表达式,if(match=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{2,4}/:出现次数在两个数字范围之间 + ++ /(abc)/:元组 + ++ /a|b|c/:匹配任意一个模式 + ++ /\d/:数字字符 + ++ /\w/:字母和数字字符(单词字符) + ++ /\s/:任意空白字符 + ++ /./:任意字符(除换行符外) + ++ /\b/:单词边界 + ++ /^/:输入起始位置 + ++ /$/:输入结束位置 + +正则表达式有test方法,用于测试给定的字符串是否匹配模式。还有一个exec方法,当匹配模式后,返回包含所有匹配元组的数组。这类数组有一个index属性,用于指出匹配的起始位置。 + +字符串有一个match方法,可以使用正则表达式进行匹配,还有一个search方法,可以搜索一个匹配项,返回匹配的起始位置。字符串的replace方法可以使用匹配字符串替换原字符串中匹配模式的文本。你还可以向replace传递一个函数,根据匹配文本和匹配元组创建匹配字符串。 + +正则表达式可以在结尾的斜杠后添加选项。选项i使得匹配不区分大小写,选项g使得表达式变成全局匹配,这种情况下,replace方法会替换所有匹配项,而非第一项。 + +RegExp构造函数可以用于通过字符串创建正则表达式。 + +正则表达式是难以驾驭的强力工具。它可以简化一些任务,但用到一些复杂问题上时也会难以控制管理。想要学会使用正则表达式的重要一点是:不要将其用到无法使用正则表达式描述的问题中。 + +### 9.20 习题 + +在做本章习题时,读者不可避免地会对一些正则表达式的莫名其妙的行为感到困惑,因而备受挫折。读者可以使用类似于[http://debuggex.com/](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数字,但单个点则不是。