正则表达式
有的人遇到问题说,“这个我知道用正则表达式解决。”结果出现了两个问题。逆着木头纹理切割需要更大力气,当你解决一个编程问题如果不按着问题的纹理来,那就需要更多的代码。
编程工具和技术生存发展之道是以混乱渐进式的。不是说最好最聪明的获得胜利,而是那些在小众范围运行良好或是碰巧与最成功的技术融合的工具和技术能活到最后。
这章节我就讨论这样的一个工具,正则表达式。它是描述字符串数据的一种方式。它形成了一个小的单独的语言,作为js的一部分同时也是很多其他语言和系统。
正则表示是不仅极其尴尬也极其好用。它的语法晦涩难懂,js提供的编程接口很难用。但是它确实是检查处理字符串的一个强大工具。恰当地理解使用正则表达式会助你成为一个更高效的程序员。
创建一个正则表达式
正则表达式是一个对象类型的数据。不仅可以利用RegExp constructor创建一个新的对象,也可以用/abc/双斜字符表达。
let re1 = new RegExp("abc");
let re2 = /abc/;这两者都是创建了一个模式,字母abc的序列。使用 RegExp 构造函数时,模式被写成普通字符串,所以反斜杠遵循常规规则。第二种表示法,斜杠模式会处理反斜杠有点不一样。首先在正斜杠作为结束模式前我们要在它前面加一个反斜杠作为模式的一部分。除此以外,不属于特殊字符代码(如 \n)部分的反斜杠将被保留,而不会像在字符串中那样被忽略,这会改变模式的含义。一些字符,比如问号和加号,在正则表达式中有特殊含义。如果想表示这些字符本身,前面必须加上反斜杠。let aPlus = /A+/;
测试是否匹配
正则的对象表示有许多方法。最简单的就是test。传一个字符串,返回一个布林值告诉你该字符串是否包含表示式的模式相匹配的内容。
console.log(/abc/.test("abcde"));
// → true
console.log(/abc/.test("abxde"));
// → false
正则表达式由非特殊字符组成,仅仅表示字符序列。如果字符串里abc到处都有不一定在开头,test仍会返回true值。
字符设置
查找出一个字符串是否有abc可以使用回调indexOf。正则之所以有用就是因为它能描述更多复杂的模式。
比如我们要匹配任意数字。将一组字符放在方括号之间,会使表达式的这部分与方括号内的任何一个字符相匹配。
下面的表达方式匹配包含数字的所有字符串。console.log(/[0123456789]/.test("in 1992"));
// → true
console.log(/[0-9]/.test("in 1992"));
// → true
在方框号内,连字符(-)在两个字符之间表示一个范围的字符,顺序由字符的Unicode编码决定。字符0-9的顺序正好是编号48-57,所以[0-9]包含所有数字编码匹配任意数字。
常见的字符组有他们自己内在的快捷方式,数字是其中一个:\d是与[0-9]一个意思。
\d任意一个数字字符
\w任意一个字母数字字符(单词字符)
\s任意空白字符(回车,换行,新行,和类似的)
\D不是数字的字符
\W不是字母数字的字符
\S不是空白字符的字符
.不是新行的字符
你可以用下列表达式匹配日期时间格式例如01-30-2003 15:20:
let dateTime = /\d\d-\d\d-\d\d\d\d \d\d:\d\d/;
console.log(dateTime.test("01-30-2003 15:20"));
// → true
console.log(dateTime.test("30-jan-2003 15:20"));
// → false
那个正则表达式看起来简直糟透了,不是吗?其中一半都是反斜杠,这就像一种干扰信息,让人很难看清它实际表达的模式。稍后我们会看到这个表达式稍有改进的版本。
这些反斜杠编码也可以用在方括号内。例如,[\d.] 表示任意一个数字或一个句点字符。在方括号内,句点本身失去了它的特殊含义。其他特殊字符,比如加号(+),也是如此。
要对一组字符取反,也就是说,表示想要匹配除了这组字符之外的任意字符,你可以在左方括号之后写一个脱字符(^) 。
let nonBinary = /[^01]/;
console.log(nonBinary.test("1100100010100110"));
// → false
console.log(nonBinary.test("0111010112101001"));
// → true
国际字符
由于 JavaScript 最初采用了简单化的实现方式,且这种简单化的处理方式后来被确立为标准行为,所以 JavaScript 的正则表达式在处理非英语语言字符时相当笨拙。例如,就 JavaScript 的正则表达式而言,“单词字符” 仅指 26 个拉丁字母(大写或小写)、十进制数字,而且不知为何还包括下划线字符。像 é 或 β 这类绝对属于单词字符的,却无法与 \w 匹配(反而会与大写的 \W 匹配,即非单词字符类别)。
由于一个奇特的历史偶然因素,\s(空白字符)不存在这个问题,它能匹配 Unicode 标准认定的所有空白字符,包括诸如不间断空格和蒙古语元音分隔符之类的字符。
在正则表达式中可以使用 \p 来匹配 Unicode 标准赋予特定属性的所有字符。这使我们能够以一种更通用的方式匹配诸如字母之类的字符。然而,同样是出于与原始语言标准的兼容性考虑,只有在正则表达式后面加上一个 u 字符(表示 Unicode)时,这些(基于 \p 的匹配)才会被识别。
\p {L}:任何字母
\p {N}:任何数字字符
\p {P}:任何标点符号字符
\P {L}:任何非字母(大写的 P 表示取反)
\p {Script=Hangul}:来自给定文字体系(见第 5 章)的任何字符
在可能需要处理非英语文本(甚至是包含像 “cliché” 这样外来词的英语文本)的文本处理中,使用 \w 是有缺陷的,因为它不会将像 “é” 这样的字符当作字母。尽管 \p 属性组往往会更冗长一些,但它们更为可靠。
console.log(/\p{L}/u.test("α"));
// → true
console.log(/\p{L}/u.test("!"));
// → false
console.log(/\p{Script=Greek}/u.test("α"));
// → true
console.log(/\p{Script=Arabic}/u.test("α"));
// → false
另一方面,如果你匹配数字是为了对其进行某些操作,你通常会想用\d来匹配数字字符,因为将任意数字字符转换为JavaScript数字,并非像Number这样的函数就能为你完成的。
模式中部分内容的重复
我们现在已经知道如何匹配单个数字。但如果我们想匹配一个整数——由一个或多个数字组成的序列,该怎么办呢?
在正则表达式中,当你在某个元素后面加上加号(+)时,它表示这个元素可以重复出现一次以上。因此,/\d+/可以匹配一个或多个数字字符。
console.log(/'\d+'/.test("'123'"));
// → true
console.log(/'\d+'/.test("''"));
// → false
console.log(/'\d'/.test("'123'"));
// → true
console.log(/'\d'/.test("''"));
// → true
星号(*)有着类似的含义,但它还允许模式匹配零次。带有星号的部分永远不会导致模式匹配失败 —— 如果找不到任何合适的文本进行匹配,它就匹配零次。
问号(?)使模式的一部分成为可选的,意味着它可以出现零次或一次。在下面的例子中,字母 “u” 可以出现,但当它不存在时,该模式同样能匹配:
let neighbor = /neighbou?r/;
console.log(neighbor.test("neighbour"));
// → true
console.log(neighbor.test("neighbor"));
// → true
要表示一个模式应该精确出现特定次数,可使用花括号。例如,在一个元素后面加上{4},表示它必须恰好出现4次。也可以用这种方式指定一个范围:{2,4} 意味着该元素必须至少出现2次,最多出现4次。
下面是日期和时间模式的另一个版本,它既允许一位数也允许两位数的日、月和小时。这个版本也稍微更容易理解。
let dateTime = /\d{1,2}-\d{1,2}-\d{4} \d{1,2}:\d{2}/;
console.log(dateTime.test("1-30-2003 8:45"));
// → true
使用花括号时,你还可以通过省略逗号后的数字来指定无上限的范围。例如,{5,} 表示出现五次或更多次。
子表达式分组
若要同时对多个元素使用诸如 * 或 + 之类的操作符,就必须使用圆括号。就跟在其后的操作符而言,正则表达式中被圆括号括起来的部分会被视为单个元素。
let cartoonCrying = /boo+(hoo+)+/i;
console.log(cartoonCrying.test("Boohoooohoohooo"));
// → true
第一个和第二个“+”字符分别仅作用于“boo”和“hoo”中的第二个“o”。第三个“+”作用于整个组“(hoo+)”,匹配一个或多个类似这样的序列。
示例中表达式末尾的“i”使这个正则表达式不区分大小写,这样即使模式本身全是小写字母,它也能匹配输入字符串中大写的“B”。
匹配与分组
test
方法是匹配正则表达式最简单的方式。它只告诉你是否匹配成功,除此之外不会提供其他信息。正则表达式还有一个 exec
(执行)方法,如果没有找到匹配项,该方法将返回 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"]
以下是对这段JavaScript 代码的解析:
- 正则表达式定义
let quotedText = /'([^']*)'/;
- 这行代码定义了一个正则表达式对象,并将其赋值给变量 quotedText 。
- 正则表达式 /'([^']*)'/ :
- ' :匹配单引号字符,用于界定被引用文本的开始。
- ([^']*) :这是一个捕获组。 [^'] 是一个否定字符类,表示匹配除单引号之外的任何字符, * 表示前面的字符类可以出现0次或多次。即这个捕获组用于捕获两个单引号之间的内容。
- ' :匹配单引号字符,用于界定被引用文本的结束。
- exec 方法调用
console.log(quotedText.exec("she said 'hello'"));
- exec 方法是正则表达式对象的一个方法,用于在字符串中执行匹配操作。它在字符串 "she said 'hello'" 上执行 quotedText 这个正则表达式的匹配。
- 当 exec 方法成功匹配时,它会返回一个数组:
- 数组的第一个元素是与整个正则表达式匹配的文本,在这里是 "'hello'" ,即包括前后单引号的完整被引用部分。
- 后续元素(如果有捕获组)是捕获组匹配到的内容,这里捕获组 ([^']*) 匹配到了 "hello" ,所以数组的第二个元素是 "hello" 。
- 最后通过 console.log 将这个匹配结果数组输出到控制台,得到 ["'hello'", "hello"] 。
当某个分组根本没有匹配到任何内容时(例如,当该分组后面跟着一个问号),它在输出数组中的位置将为 undefined
。当某个分组被匹配多次时(例如,当该分组后面跟着一个 +
号),数组中最终仅保留最后一次匹配的结果。
console.log(/bad(ly)?/.exec("bad"));
// → ["bad", undefined]
console.log(/(\d)+/.exec("123"));
// → ["123", "3"]
如果你只是纯粹为了分组而使用圆括号,不希望它们出现在匹配数组中,那么你可以在左圆括号后面加上 ?:
。
console.log(/(?:na)+/.exec("banana"));
// → ["nana"]
以下是对这段JavaScript代码的解析:
- 正则表达式定义
/(?:na)+/ :
- (?: ) :这是一个非捕获组。与普通捕获组 ( ) 不同,非捕获组不会在匹配结果数组中单独存储其匹配内容,只是用于对其中的子表达式进行分组以便进行整体操作,在这里是对 na 进行分组。
- na :表示匹配字符序列 na 。
- 符号 + :是一个量词,表示前面的元素(这里是 (?:na) ,即 na 这个字符序列 )出现1次或多次。所以整个正则表达式的意思是匹配一个或多个连续出现的 na 字符序列。
- exec 方法调用
console.log(/(?:na)+/.exec("banana"));
- exec 是正则表达式对象的方法,用于在给定字符串中执行匹配操作。这里是在字符串 "banana" 上执行 /(?:na)+/ 这个正则表达式的匹配。
- 在字符串 "banana" 中,从左到右匹配,会找到 "nana" 满足一个或多个连续的 na 字符序列这个条件。
- 当 exec 方法成功匹配时,会返回一个数组。数组的第一个元素是与整个正则表达式匹配的文本,因为这里是非捕获组,不会额外存储捕获内容,所以返回的数组只有一个元素 ["nana"] ,最后通过 console.log 将这个匹配结果数组输出到控制台。
分组对于提取字符串的部分内容很有用。如果我们不只是想验证一个字符串是否包含日期,还想提取日期并构建一个表示它的对象,我们可以用圆括号括住数字模式,然后直接从exec
的结果中提取日期。
但首先,我们稍微绕个弯,来讨论一下JavaScript中表示日期和时间值的内置方式。
Date类
JavaScript有一个标准的Date类,用于表示日期,更确切地说,是表示时间点。如果你仅使用new
创建一个日期对象,你将得到当前的日期和时间。
console.log(new Date());
// → Fri Feb 02 2024 18:03:06 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开始(所以12月是11),但日期编号从1开始。这既令人困惑又很荒谬。务必小心。
最后四个参数(小时、分钟、秒和毫秒)是可选的,若未提供则视为0。
时间戳存储的是自1970年开始以来,在协调世界时(UTC)时区中的毫秒数。这遵循了大约在那个时候制定的 “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对象提供诸如getFullYear(获取年份)、getMonth(获取月份)、getDate(获取日期)、getHours(获取小时)、getMinutes(获取分钟)以及getSeconds(获取秒数)等方法来提取其各个组成部分。除了getFullYear之外,还有getYear方法,它返回的年份是实际年份减去1900(比如98或125),大多没什么用处。
通过用圆括号括住我们感兴趣的表达式部分,现在我们就可以从一个字符串创建一个日期对象。
function getDate(string) {
let [_, month, day, year] =
/(\d{1,2})-(\d{1,2})-(\d{4})/.exec(string);
return new Date(year, month - 1, day);
}
console.log(getDate("1-30-2003"));
// → Thu Jan 30 2003 00:00:00 GMT+0100 (CET)
💡解答
- 函数整体功能概述:
- 这个 getDate 函数的作用是将一个特定格式(月 - 日 - 年,如 "1 - 30 - 2003" )的字符串解析成 Date 对象。
- 正则表达式部分分析:
- 正则表达式 /(\d{1,2})-(\d{1,2})-(\d{4})/ 。
- \d 表示匹配一个数字字符。 {1,2} 表示前面的字符(这里是数字)可以出现1到2次。所以 (\d{1,2}) 用于匹配1到2位的数字。这里第一个 (\d{1,2}) 用于捕获月份部分,第二个 (\d{1,2}) 用于捕获日期部分,第三个 (\d{4}) 用于捕获年份部分。
- 符号 - 是普通字符,用于匹配字符串中的 - 分隔符。
- .exec(string) 方法: exec 是正则表达式对象的方法,它在给定的字符串 string 中执行匹配操作。如果匹配成功,它返回一个数组,数组的第一个元素是整个匹配的字符串,后面的元素依次是正则表达式中捕获组(括号内的部分)匹配到的内容。在 let [_, month, day, year] = /(\d{1,2})-(\d{1,2})-(\d{4})/.exec(string); 中, _ 是一个占位符,通常用于忽略整个匹配的字符串(因为我们更关心捕获组的内容), month 捕获到月份数字, day 捕获到日期数字, year 捕获到年份数字。
- 创建 Date 对象部分分析:
- return new Date(year, month - 1, day); : Date 是JavaScript中的内置构造函数,用于创建日期和时间对象。它接受年、月(注意这里月份是从0开始计数,1月对应0,2月对应1,以此类推,所以 month - 1 )、日等参数来创建一个具体的日期对象。这里将从字符串中解析出来的 year 、调整后的 month 和 day 作为参数传递给 Date 构造函数,从而创建一个表示对应日期的 Date 对象。
- console.log 输出部分:
- console.log(getDate("1 - 30 - 2003")); 调用 getDate 函数并传入字符串 "1 - 30 - 2003" 。函数内部解析该字符串,创建对应的 Date 对象,然后 console.log 将该 Date 对象以特定的日期时间格式( Thu Jan 30 2003 00:00:00 GMT+0100 (CET) )打印到控制台。这里的日期时间格式包含了星期几( Thu )、月份( Jan )、日期( 30 )、年份( 2003 )、时间( 00:00:00 )以及时区信息( GMT+0100 (CET) ) 。
总结,这段代码实现了将特定格式的日期字符串转换为 Date 对象并打印输出的功能。
下划线(_)绑定会被忽略,仅用于跳过 exec
返回的数组中的完整匹配元素。
边界与正向先行断言
遗憾的是,getDate 也会欣然从字符串 “100 - 1 - 30000” 中提取出一个日期。匹配可能在字符串的任何位置发生,所以在这种情况下,它会从第二个字符开始,到倒数第二个字符结束。
如果我们想强制要求匹配必须覆盖整个字符串,可以添加标记 ^ 和 标记美元符号。脱字符匹配输入字符串的开头,而美元符号 匹配结尾。因此,/^\d+$/ 匹配完全由一个或多个数字组成的字符串,/^!/ 匹配任何以感叹号开头的字符串,而 /x^/ 不匹配任何字符串(字符串开头之前不可能有 x)。
还有一个 \b 标记,它匹配单词边界,即一侧是单词字符,另一侧是非单词字符的位置。遗憾的是,它们与 \w 一样,对单词字符的概念定义很简单,因此不太可靠。
请注意,这些边界标记并不匹配任何实际字符。它们只是强制要求在模式中其出现的位置满足给定条件。
正向先行断言的作用类似。它们提供一个模式,如果输入不匹配该模式,则匹配失败,但实际上不会将匹配位置向前移动。它们写在 (?= 和 ) 之间。
console.log(/a(?=e)/.exec("braeburn"));
// → ["a"]
console.log(/a(?! )/.exec("a b"));
// → null
在第一个例子中,“e” 是匹配所必需的,但它并不属于匹配到的字符串。“(?! )” 这种表示法表达的是负向先行断言。只有当括号内的模式不匹配时,它才会匹配,所以第二个例子仅匹配后面没有空格的 “a” 字符。 (第一行代码解析
- 代码内容: console.log(/a(?=e)/.exec("braeburn"));
- /a(?=e)/ 是一个正则表达式,其中 (?=e) 是正向先行断言(positive lookahead assertion) 。它表示匹配 a ,但前提是 a 后面紧挨着 e ,不过匹配结果不包含 e 本身。
- .exec() 是 JavaScript 中 RegExp 对象的方法,用于在字符串中执行匹配操作。这里是在字符串 "braeburn" 中进行匹配。
- 在字符串 "braeburn" 中,能找到 a 后面紧挨着 e 的情况,即 a 符合正则表达式的匹配规则,匹配结果是 ["a"] ,所以 console.log 输出 ["a"] 。
第二行代码解析 - 代码内容: console.log(/a(?! )/.exec("a b"));
- /a(?! )/ 是正则表达式,其中 (?! ) 是负向先行断言(negative lookahead assertion) 。它表示匹配 a ,但前提是 a 后面紧挨着的内容不是两个空格 (这里猜测原正则中可能是想写两个空格,但显示不全)。
- 在字符串 "a b" 中, a 后面是一个空格,不符合 (?! ) (假设是两个空格的负向先行断言条件 )的条件,所以匹配失败。
- .exec() 方法在匹配失败时返回 null ,因此 console.log 输出 null 。)
选择模式
假设我们想知道一段文本中不仅包含一个数字,而且这个数字后面跟着 “pig”(猪)、“cow”(牛)、“chicken”(鸡)这几个词中的一个,或者它们的复数形式。
我们可以编写三个正则表达式,依次进行测试,但还有一种更好的方法。竖线字符(|)表示在其左边的模式和右边的模式之间进行选择。我们可以在这样的表达式中使用它:
let animalCount = /\d+ (pig|cow|chicken)s?/;
console.log(animalCount.test("15 pigs"));
// → true
console.log(animalCount.test("15 pugs"));
// → false
圆括号可用于限定竖线操作符所适用的模式部分,并且你可以将多个这样的操作符并列放置,以表示在两个以上的选项之间进行选择。
匹配机制
从概念上讲,当你使用 exec
或 test
时,正则表达式引擎会通过先尝试从字符串开头匹配表达式,然后从第二个字符开始匹配,依此类推,直至找到匹配项或到达字符串末尾,以此在字符串中寻找匹配内容。它要么返回找到的第一个匹配项,要么根本找不到任何匹配项。
为了进行实际匹配,引擎将正则表达式视为类似于流程图的东西。这是上一个示例中关于家畜表达式的流程图:
如果我们能找到一条从图的左侧通向右侧的路径,那么我们的表达式就匹配成功。我们在字符串中保持一个当前位置,每次经过一个框时,我们都要验证当前位置之后的那部分字符串是否与该框匹配。
回溯
正则表达式 /^([01]+b|[\da - f]+h|\d+)$/
可以匹配以下几种情况:后面跟着字母 b
的二进制数;后面跟着字母 h
的十六进制数(即基数为16,字母 a
到 f
代表数字 10
到 15
);或者不带后缀字符的普通十进制数。这是相应的流程图:
在匹配这个表达式时,即使输入实际上并不包含二进制数,也常常会进入最上面(二进制)的分支。例如,在匹配字符串 “103” 时,直到遇到字符 “3” 时才会发现我们进入了错误的分支。该字符串确实与表达式匹配,只是不与我们当前所在的分支匹配。
所以匹配器会回溯。进入一个分支时,它会记住当前位置(在这种情况下,是在字符串的开头,刚经过图中第一个边界框),以便在当前分支不匹配时可以返回并尝试另一个分支。对于字符串 “103”,遇到字符 “3” 后,匹配器开始尝试十六进制数的分支,由于数字后面没有 “h”,这个分支又失败了。然后它尝试十进制数分支。这个分支匹配成功,最终报告匹配。
匹配器一旦找到完全匹配就会停止。这意味着如果多个分支都可能匹配一个字符串,只会使用第一个(按照分支在正则表达式中出现的顺序)。
回溯也会发生在像 +
和 *
这样的重复操作符上。如果用 /^.*x/
匹配 “abcxe”,.*
部分首先会尝试消耗整个字符串。然后引擎会意识到它需要一个 “x” 来匹配模式。由于字符串末尾之后没有 “x”,星号操作符会尝试少匹配一个字符。但是匹配器在 “abcx” 之后也没有找到 “x”,所以它再次回溯,将星号操作符只匹配到 “abc”。现在它在需要的位置找到了 “x”,并报告从位置 0 到 4 匹配成功。
有可能写出会进行大量回溯的正则表达式。当一个模式可以用多种不同方式匹配一段输入时,就会出现这个问题。例如,如果我们在编写二进制数正则表达式时出错,可能会不小心写出类似 /([01]+)+b/
这样的表达式。
(正则表达式解析
- 表达式内容: /^.*x/
- ^ :在正则表达式中, ^ 是一个元字符,表示匹配字符串的起始位置。
- .* : * 是量词,表示前面的元素(这里是 . )出现 0 次或多次。 . 匹配除换行符之外的任何单个字符。所以 .* 组合起来的意思是匹配从当前位置开始,任意数量的任意字符(除换行符 )。
- x :这里就是匹配字符 x 本身。
匹配示例 - 对于字符串 "abcx" ,从起始位置开始, .* 会匹配 "abc" ,然后遇到 x ,满足整个正则表达式 ^.*x ,所以可以匹配成功。
- 对于字符串 "12345" ,由于其中没有 x ,不满足 ^.*x 中要匹配到 x 的要求,所以匹配失败。
- 对于字符串 "xabc" ,虽然有 x ,但 ^ 要求从起始位置开始匹配,这里起始位置是 x , .* 匹配空字符串(0 个字符 )后就到 x 了,也能匹配成功。)
二进制图表
如果用这个表达式去匹配一长串没有尾随b
字符的0和1,匹配器首先会进入内循环,直到处理完所有数字。然后它会发现没有b
,于是回溯一个位置,进入外循环一次,接着又失败,然后再次尝试从内循环回溯。它会不断尝试通过这两个循环的每一种可能路径。这意味着每增加一个字符,匹配工作量就会翻倍。哪怕只有几十来个字符,最终的匹配几乎要花无穷无尽的时间。
replace 方法
字符串值有一个 replace
方法,可用于将字符串的一部分替换为另一个字符串。
console.log("papa".replace("p", "m"));
// → mapa
第一个参数也可以是一个正则表达式,在这种情况下,正则表达式的第一个匹配项将被替换。当在正则表达式后面加上 g
选项(表示全局)时,字符串中的所有匹配项都将被替换,而不只是第一个。
console.log("Borobudur".replace(/[ou]/, "a"));
// → Barobudur
console.log("Borobudur".replace(/[ou]/g, "a"));
// → Barabadar
(第一行代码 console.log("Borobudur".replace(/[ou]/, "a"));
:
- 使用
replace
方法,第一个参数是正则表达式/[ou]/
,它匹配字符o
或者u
。由于没有g
(全局)标志,它只会替换第一个匹配到的字符。在字符串 “Borobudur” 中,第一个匹配到的字符是o
,将其替换为a
,所以输出 “Barobudur”。
第二行代码console.log("Borobudur".replace(/[ou]/g, "a"));
: - 同样使用
replace
方法,正则表达式/[ou]/g
中的g
标志表示全局匹配。这意味着它会查找字符串 “Borobudur” 中所有匹配o
或u
的字符,并将它们都替换为a
。所以最终输出 “Barabadar”。 )
在 replace
中使用正则表达式的真正强大之处在于,我们可以在替换字符串中引用匹配的组。例如,假设我们有一个很长的字符串,其中包含人们的名字,每行一个名字,格式为 “姓氏, 名字”。如果我们想交换这些名字并去掉逗号,得到 “名字 姓氏” 的格式,我们可以使用以下代码:
console.log(
"Liskov, Barbara\nMcCarthy, John\nMilner, Robin"
.replace(/(\p{L}+), (\p{L}+)/gu, "1"));
// → Barbara Liskov
// John McCarthy
// Robin Milner
(1. 原始字符串
- "Liskov, Barbara\nMcCarthy, John\nMilner, Robin" 是一个包含多行姓名信息的字符串,每行姓名以“姓氏, 名字”的格式呈现,并且行与行之间使用换行符 \n 分隔。
- 正则表达式
- /( \p{L}+), ( \p{L}+)/gu :
- \p{L} 是 Unicode 字符属性,表示任何字母字符。 + 是量词,表示前面的元素(这里是 \p{L} )出现 1 次或多次。所以 \p{L}+ 表示一个或多个字母字符。
- 这里用括号 () 进行分组,第一个括号 ( \p{L}+) 匹配姓氏部分,第二个括号 ( \p{L}+) 匹配名字部分。
- /g 标志表示全局匹配,即对字符串中所有符合模式的部分都进行匹配替换,而不是只替换第一个匹配项。 /u 标志表示使用 Unicode 模式,确保正确处理 Unicode 字符。
- replace 方法
- .replace(/( \p{L}+), ( \p{L}+)/gu, "
1") :
- replace 方法接收两个参数,第一个是要匹配的正则表达式,第二个是替换字符串。
- 在替换字符串 "
1" 中,
2 是反向引用,分别对应正则表达式中第一个和第二个括号分组匹配到的内容。所以这里的作用是将匹配到的“姓氏, 名字”格式,替换为“名字 姓氏”格式。
- console.log 输出
- 最终 console.log 输出的结果是将原始字符串中每行的姓名顺序进行了调整,从“姓氏, 名字”变为“名字 姓氏”,即:
- Barbara Liskov
- John McCarthy
- Robin Milner)
替换字符串中的$1
和$2
指代模式中带括号的组。$1
会被与第一个组匹配的文本替换,$2
会被与第二个组匹配的文本替换,以此类推,最多到$9
。整个匹配内容可以用$&
来指代。
可以将一个函数(而不是字符串)作为 replace
的第二个参数。对于每次替换,该函数会被调用,匹配的组(以及整个匹配内容)会作为参数传入,其返回值将被插入到新字符串中。
下面是一个示例:
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+) (\p{L}+)/gu, minusOne));
// → no lemon, 1 cabbage, and 100 eggs
(1. 变量声明
- let stock = "1 lemon, 2 cabbages, and 101 eggs"; 定义了一个字符串变量 stock ,存储了库存信息,包含商品数量和名称。
- 函数定义
- function minusOne(match, amount, unit) :
- 这是一个回调函数,用于处理 replace 方法匹配到的内容。
- 参数 match 是整个匹配到的字符串(在 replace 方法使用正则匹配时,自动传入 ); amount 是正则表达式中匹配到的数量部分(对应正则分组 ), unit 是匹配到的商品单位部分(对应正则分组 )。
- amount = Number(amount) - 1; 将传入的数量字符串转换为数字并减 1 。
- if (amount == 1) :如果数量减 1 后等于 1 ,通过 unit = unit.slice(0, unit.length - 1); 去掉商品名称的复数形式(去掉末尾的 's' ) 。
- else if (amount == 0) :如果数量减 1 后等于 0 ,将 amount 赋值为 "no" 。
- 最后 return amount + " " + unit; 返回处理后的数量和商品名称组合。
- 正则替换
- stock.replace(/(\d+) (\p{L}+)/gu, minusOne) :
- 正则表达式 /(\d+) (\p{L}+)/gu : \d+ 匹配一个或多个数字, \p{L}+ 匹配一个或多个字母字符(表示商品名称 ), g 标志表示全局匹配, u 标志表示使用 Unicode 模式。两个括号分别对数量和商品名称进行分组。
- replace 方法将字符串 stock 中符合正则表达式的部分,按 minusOne 回调函数进行处理替换。
- 输出结果
- 最终 console.log 输出的结果是将库存中每种商品的数量减 1 ,并在数量为 1 时去掉商品名称复数形式,数量为 0 时显示 no ,即 no lemon, 1 cabbage, and 100 eggs 。)
代码功能解析 - 所属对象: slice() 是 JavaScript 中字符串( String )对象的方法 ,用于提取字符串的一部分,返回一个新字符串,不会修改原字符串。
- 参数含义:
- 0 是起始索引,表示从字符串的第 0 个位置(即第一个字符 )开始提取。
- unit.length - 1 是结束索引,表示提取到距离字符串末尾前 1 个位置。例如,对于字符串 "apples" ,其长度 length 为 6 , unit.length - 1 就是 5 ,那么会提取从第 0 个位置到第 5 个位置的字符,也就是 "apple" ,实现去掉末尾字符(通常用于去掉复数形式的 's' )的效果。
- 示例:
let str = "books";
let newStr = str.slice(0, str.length - 1);
console.log(newStr); // 输出 "book"
这段代码获取一个字符串,找到所有数字后跟一个字母数字单词的出现情况,并返回一个字符串,其中每个这样的数量都减少一个。
(\d+)
组最终成为函数的 amount
参数,而 (\p{L}+)
组被绑定到 unit
。该函数将 amount
转换为数字(这总是可行的,因为它之前匹配了 \d+
),并在只剩下一个或零个的情况下进行一些调整。
贪婪性
我们可以使用 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
竖线(|)运算符之前的部分匹配两个斜杠字符,后面跟着任意数量的非换行字符。多行注释部分则更为复杂。我们使用 [^](不在空字符集中的任何字符,即任意字符)来匹配任何字符。这里不能只用句号(.),因为块注释可能跨多行延续,而句号字符不匹配换行符。
但最后一行的输出似乎出了问题。为什么呢?
正如我在回溯部分所描述的,表达式中的 [^]* 部分首先会尽可能多地匹配。如果这导致模式的下一部分匹配失败,匹配器就会回退一个字符并从那里再次尝试。在这个例子中,匹配器首先尝试匹配字符串的整个剩余部分,然后从那里回退。它会在回退四个字符后找到 */ 的出现并匹配它。这并非我们想要的——我们的意图是匹配单个注释,而不是一直匹配到代码末尾并找到最后一个块注释的结尾。
由于这种行为,我们说重复运算符(+、、? 和 {})是贪婪的,意味着它们会尽可能多地匹配,然后从那里回溯。如果在它们后面加上问号(+?、?、??、{}?),它们就会变成非贪婪的,首先尽可能少地匹配,只有在剩余模式不匹配较小的匹配结果时才会匹配更多。
而这正是我们在这种情况下所需要的。通过让星号匹配能使我们到达 */ 的最小字符片段,我们就只会消耗一个块注释,而不会匹配更多。 (这段JavaScript代码定义了一个名为stripComments
的函数,用于从给定的JavaScript代码字符串中去除注释。以下是对代码的详细分析:
-
函数定义:
function stripComments(code) { return code.replace(/\/\/.*|\/\*[^]*\*\//g, ""); }
-
stripComments
函数接受一个参数code
,这是包含JavaScript代码的字符串。 - 在函数内部,使用
replace
方法对code
字符串进行替换操作。replace
的第一个参数是一个正则表达式/\/\/.*|\/\*[^]*\*\//g
,第二个参数是一个空字符串""
。这意味着找到符合正则表达式的部分,就用空字符串替换,从而实现删除注释的效果。
-
-
正则表达式分析:
-
/\/\/.*|\/\*[^]*\*\//g
:-
g
标志表示全局匹配,即查找字符串中所有符合该模式的部分。 -
\/\/.*
:匹配以//
开头,后面跟着任意字符(直到行尾)的字符串,用于匹配单行注释。 -
|
:表示“或”的逻辑,即匹配\/\/.*
或者\/\*[^]*\*\//
。 -
\/\*[^]*\*\//
:- /* 匹配字符串中的 /* ,表示多行注释的开始。因为 * 在正则表达式中有特殊含义,所以需要用 \ 进行转义。
-
-
[^]*? : [^] 这里表示非 * 字符, * 表示前面的非 * 字符出现0次或多次, ? 是惰性匹配修饰符,它使得匹配尽可能少地消耗字符,也就是在遇到第一个 */ 时就停止匹配。这部分用于匹配多行注释中间的内容。
*// 匹配字符串中的 */ ,表示多行注释的结束。 g 是正则表达式的全局标志,表示对整个字符串进行匹配,而不是只匹配第一个符合条件的部分。
不过正如前文所述,这种贪婪匹配在某些情况下可能导致匹配到超出预期的内容,但在这里对于简单的多行注释处理基本能满足需求。
-
测试输出:
-
console.log(stripComments("1 + /* 2 */3"));
:输出1 + 3
,成功去除了多行注释/* 2 */
。 -
console.log(stripComments("x = 10;// ten!"));
:输出x = 10;
,成功去除了单行注释// ten!
。 -
console.log(stripComments("1 /* a */+/* b */ 1"));
:输出1 1
,成功去除了两个多行注释/* a */
和/* b */
。
总体来说,这个函数能够有效地去除简单JavaScript代码中的单行和多行注释,但对于更复杂的注释嵌套等情况,可能需要进一步完善。 )
-
function stripComments(code) {
return code.replace(/\/\/.*|\/\*[^]*?\*\//g, "");
}
console.log(stripComments("1 /* a */+/* b */ 1"));
// → 1 + 1
正则表达式程序中的许多错误都可归因于在本应使用非贪婪运算符的地方无意中使用了贪婪运算符。在使用重复运算符时,应优先选择非贪婪变体。
动态创建RegExp对象
在某些情况下,当你编写代码时,可能并不清楚需要匹配的确切模式。比如说,你想在一段文本中检测用户的名字。你可以构建一个字符串,然后对其使用RegExp构造函数。
let name = "harry";
let regexp = new RegExp("(^|\\s)" + name + "($|\\s)", "gi");
console.log(regexp.test("Harry is a dodgy character."));
// → true
在创建字符串中的 \s
部分时,我们必须使用两个反斜杠,因为我们是在普通字符串中编写它们,而不是在以斜杠包围的正则表达式中。RegExp
构造函数的第二个参数包含正则表达式的选项 —— 在这种情况下,“gi” 表示全局匹配且不区分大小写。
但是,如果用户名是 “dea+hl[]rd”,因为我们的用户是个痴迷于技术的青少年,那会怎样呢?这将导致一个无意义的正则表达式,实际上无法匹配该用户名。
为了解决这个问题,我们可以在任何具有特殊含义的字符前添加反斜杠。
let name = "dea+hl[]rd";
let escaped = name.replace(/[\\[.+*?(){|^$]/g, "\\$&");
let regexp = new RegExp("(^|\\s)" + escaped + "($|\\s)",
"gi");
let text = "This dea+hl[]rd guy is super annoying.";
console.log(regexp.test(text));
// → true
解析(这段JavaScript代码的作用是,在给定文本中,通过动态构建正则表达式来搜索特定用户名,并且在构建正则表达式时对用户名中的特殊字符进行转义,以确保能准确匹配。下面详细解释每一步:
-
定义用户名:
let name = "dea+hl[]rd";
定义了一个名为
name
的变量,值为dea+hl[]rd
,这个字符串包含了一些在正则表达式中有特殊含义的字符。 -
转义特殊字符:
let escaped = name.replace(/[\\[.+*?(){|^$]/g, "\\$&");
使用
replace
方法和一个正则表达式,对name
中的特殊字符进行转义。正则表达式/[\\[.+*?(){|^$]/g
匹配所有在正则表达式中有特殊含义的字符(如\
、[
、.
等)。"\\$&"
中的$&
表示匹配到的整个子字符串,这里通过在前面再加一个\
来转义这些特殊字符。例如,如果匹配到+
,$&
就是+
,"\\$&"
就变成\+
。 -
构建正则表达式:
let regexp = new RegExp("(^|\\s)" + escaped + "($|\\s)", "gi");
使用
RegExp
构造函数动态创建一个正则表达式。构造函数的第一个参数是一个字符串,它由三部分组成:-
(^|\\s)
:匹配字符串开头(^
)或者空白字符(\s
),确保匹配的用户名是独立的单词(不是其他单词的一部分)。 -
escaped
:即经过转义后的用户名。 -
($|\\s)
:匹配字符串结尾($
)或者空白字符(\s
),同样是为了确保匹配的用户名是独立的单词。
构造函数的第二个参数"gi"
表示全局匹配(g
)并且不区分大小写(i
)。
-
-
测试文本:
let text = "This dea+hl[]rd guy is super annoying."; console.log(regexp.test(text)); // → true
定义一个文本字符串
text
,然后使用regexp.test(text)
方法测试text
中是否包含目标用户名。test
方法会返回一个布尔值,表示正则表达式是否在字符串中找到匹配项。这里输出true
,说明找到了匹配的用户名。 )
search 方法
虽然字符串的 indexOf 方法不能使用正则表达式调用,但有另一个方法 search,它接受正则表达式作为参数。和 indexOf 一样,它返回找到表达式的第一个索引位置,如果未找到则返回 -1。
console.log(" word".search(/\S/));
// → 2
console.log(" ".search(/\S/));
// → -1
(解析在这两段代码中,search
方法用于在字符串中查找与正则表达式匹配的子字符串,并返回其起始位置。
-
第一段代码:
console.log(" word".search(/\S/)); // → 2
-
search
方法的参数是正则表达式/\S/
,\S
表示匹配任何非空白字符。 - 在字符串
" word"
中,前两个字符是空白字符,第三个字符'w'
是非空白字符。所以search
方法返回2
,表示从字符串开头开始,第一个非空白字符的索引位置是2
。
-
-
第二段代码:
console.log(" ".search(/\S/)); // → -1
- 同样使用
search
方法查找非空白字符。 - 字符串
" "
全部由空白字符组成,没有任何非空白字符。因此search
方法没有找到匹配的内容,返回-1
。 )
遗憾的是,没办法指定匹配应从给定偏移量处开始(就像我们可以给indexOf
方法传入第二个参数那样),而这往往是很有用的功能。
- 同样使用
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
会被重置为 0
,这也是新创建的正则表达式对象中 lastIndex
的初始值。
全局(g
)选项和粘性(y
)选项的区别在于,启用粘性选项时,只有当匹配直接从 lastIndex
位置开始时才会成功;而使用全局选项时,它会向前搜索可以开始匹配的位置。
let global = /abc/g;
console.log(global.exec("xyz abc"));
// → ["abc"]
let sticky = /abc/y;
console.log(sticky.exec("xyz abc"));
// → null
(1. 关于全局匹配(g
)的 exec
调用:
- 定义了一个全局匹配的正则表达式
global = /abc/g
。 - 当执行
global.exec("xyz abc")
时,exec
方法会在字符串"xyz abc"
中搜索与正则表达式/abc/g
匹配的内容。 - 由于是全局匹配,它会从字符串的开头开始搜索,找到第一个匹配项
"abc"
。exec
方法返回一个数组,数组的第一个元素就是匹配到的子字符串,所以输出["abc"]
。同时,global.lastIndex
属性会被更新为匹配到的子字符串之后的位置,即7
(因为"abc"
在字符串中的位置是从索引4
开始,长度为3
,所以之后的位置是4 + 3 = 7
)。
-
关于粘性匹配(
y
)的exec
调用:- 定义了一个粘性匹配的正则表达式
sticky = /abc/y
。 - 当执行
sticky.exec("xyz abc")
时,粘性匹配要求匹配必须从lastIndex
位置开始(初始时lastIndex
为0
)。 - 从字符串的索引
0
位置开始,字符串是"xyz"
,并不匹配/abc/y
。由于粘性匹配的严格要求,它不会继续往后搜索其他可能匹配的位置,所以exec
方法返回null
,表示没有找到匹配项。此时sticky.lastIndex
会被重置为0
。
- 定义了一个粘性匹配的正则表达式
这两段代码展示了全局匹配(g
)和粘性匹配(y
)在 exec
方法使用上的不同行为。 )
当对多个 exec
调用使用同一个共享的正则表达式值时,lastIndex
属性的这些自动更新可能会引发问题。你的正则表达式可能会意外地从上一次调用遗留的索引位置开始匹配。
(1. 第一次 exec
调用:
- 定义了全局匹配数字的正则表达式
digit = /\d/g
。 - 当执行
digit.exec("here it is: 1")
时,exec
方法会在字符串"here it is: 1"
中搜索匹配/\d/g
的内容。 - 它找到了数字
1
,并返回包含匹配结果的数组["1"]
。同时,digit.lastIndex
会被更新到匹配的数字1
之后的位置,即13
(因为"1"
在字符串中的位置是从索引12
开始,长度为1
,所以之后的位置是12 + 1 = 13
)。
-
第二次
exec
调用:- 当执行
digit.exec("and now: 1")
时,由于digit
是共享的正则表达式对象,并且lastIndex
仍保持在上一次调用后的13
。 - 此时,
exec
方法会尝试从索引13
开始在字符串"and now: 1"
中搜索匹配项。但该字符串长度远小于13
,所以找不到匹配的数字,最终返回null
。
- 当执行
这种情况体现了在多次使用共享的全局正则表达式进行 exec
调用时,lastIndex
属性的自动更新可能导致的意外结果。为避免此类问题,有时可能需要在每次调用 exec
前手动重置 lastIndex
为 0
,或者根据具体需求合理处理 lastIndex
的值。 )
全局选项(global
)的另一个有趣效果是,它改变了字符串的 match
方法的工作方式。当使用全局表达式调用 match
方法时,它不会返回与 exec
方法返回类似的数组,而是会找到字符串中模式的所有匹配项,并返回一个包含所有匹配字符串的数组。
console.log("Banana".match(/an/g));
// → ["an", "an"]
所以使用全局正则表达式时要谨慎。通常在确实有必要的场景下,比如调用 replace
方法,或者明确要使用 lastIndex
属性的地方,才应该使用它们。
一件常见的事是在字符串中找到正则表达式的所有匹配项。我们可以通过使用 matchAll
方法来做到这一点。
let input = "A string with 3 numbers in it... 42 and 88.";
let matches = input.matchAll(/\d+/g);
for (let match of matches) {
console.log("Found", match[0], "at", match.index);
}
// → Found 3 at 14
// Found 42 at 33
// Found 88 at 40
(解析这段代码的目的是在给定的字符串中查找所有连续的数字序列,并输出每个匹配到的数字及其在字符串中的位置。下面是详细解释:
-
定义输入字符串:
let input = "A string with 3 numbers in it... 42 and 88.";
这行代码定义了一个字符串
input
,其中包含一些文本和数字。 -
使用
matchAll
方法:let matches = input.matchAll(/\d+/g);
-
matchAll
方法用于在字符串input
上查找所有与正则表达式/\d+/g
匹配的子字符串。 - 正则表达式
/\d+/g
中,\d
表示匹配任何数字字符(0 - 9),+
表示匹配前面的字符(即数字字符)一次或多次,g
表示全局匹配,即查找字符串中所有符合该模式的子字符串。 -
matchAll
方法返回一个迭代器matches
,该迭代器包含所有匹配结果。
-
-
遍历匹配结果:
for (let match of matches) { console.log("Found", match[0], "at", match.index); }
- 使用
for...of
循环遍历matches
迭代器。 - 对于每个匹配结果
match
,match[0]
表示实际匹配到的子字符串(即数字),match.index
表示该匹配子字符串在原始字符串input
中的起始位置。 - 通过
console.log
输出找到的数字及其位置,输出结果为:
// → Found 3 at 14 // Found 42 at 33 // Found 88 at 40
表明在字符串的索引
14
处找到了数字3
,在索引33
处找到了数字42
,在索引40
处找到了数字88
。 )
此方法返回一个匹配数组组成的数组。传递给matchAll
的正则表达式必须启用g
标志。 - 使用
解析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文件)的确切规则如下:
空行和以分号开头的行将被忽略。
用 [
和 ]
括起来的行开始一个新的节。
包含字母数字标识符,后面跟着一个 =
字符的行,会向当前节添加一个设置项。
其他任何内容都是无效的。
我们的任务是将这样的字符串转换为一个对象,该对象的属性包含在第一个节标题之前写入的设置的字符串,对于各个节则使用子对象表示,这些子对象包含节的设置项。
由于必须逐行处理这种格式,将文件拆分为单独的行是个不错的开始。我们在第4章中见过 split
方法。然而,一些操作系统不仅仅使用换行符来分隔行,而是使用回车符后跟换行符(\r\n
)。鉴于 split
方法也允许使用正则表达式作为其参数,我们可以使用像 /\r?\n/
这样的正则表达式进行拆分,这样就可以处理行与行之间为 \n
或 \r\n
的情况。
function parseINI(string) {
// Start with an object to hold the top-level fields
let result = {};
let section = result;
for (let line of string.split(/\r?\n/)) {
let match;
if (match = line.match(/^(\w+)=(.*)$/)) {
section[match[1]] = match[2];
} else if (match = line.match(/^\[(.*)\]$/)) {
section = result[match[1]] = {};
} else if (!/^\s*(;|$)/.test(line)) {
throw new Error("Line '" + line + "' is not valid.");
}
};
return result;
}
console.log(parseINI(`
name=Vasilis
[address]
city=Tessaloniki`));
// → {name: "Vasilis", address: {city: "Tessaloniki"}}
(解析这段代码定义了一个名为 parseINI
的函数,用于解析INI格式的字符串。以下是代码的详细解释:
-
函数定义与初始化:
function parseINI(string) { let result = {}; let section = result;
-
parseINI
函数接受一个字符串参数string
,这个字符串应该是INI格式的内容。 -
result
对象用于存储解析后的结果,初始时它是一个空对象,用来保存顶级字段。 -
section
变量初始指向result
,它用于跟踪当前正在处理的节。随着解析过程中遇到新的节,section
会指向相应节的子对象。
-
-
逐行解析:
for (let line of string.split(/\r?\n/)) { let match; if (match = line.match(/^(\w+)=(.*)$/)) { section[match[1]] = match[2]; } else if (match = line.match(/^\[(.*)\]$/)) { section = result[match[1]] = {}; } else if (!/^\s*(;|$)/.test(line)) { throw new Error("Line '" + line + "' is not valid."); } };
- 使用
for...of
循环遍历string
按行拆分后的每一行。这里通过string.split(/\r?\n/)
将字符串按行拆分,/\r?\n/
这个正则表达式可以匹配\n
或者\r\n
,以兼容不同操作系统的换行格式。 -
let match;
声明一个变量match
,用于存储正则表达式匹配的结果。 - 第一个
if
块:-
if (match = line.match(/^(\w+)=(.*)$/))
使用正则表达式^(\w+)=(.*)$
匹配以字母数字字符(\w
)开头,后跟一个=
符号,再跟任意字符的行。如果匹配成功,match[1]
是等号左边的标识符,match[2]
是等号右边的值。 -
section[match[1]] = match[2];
将这个设置项添加到当前的section
中。
-
- 第二个
else if
块:-
else if (match = line.match(/^\[(.*)\]$/))
使用正则表达式^\[(.*)\]$
匹配以[
开头,以]
结尾的行,这表示一个新的节。如果匹配成功,match[1]
是节的名称。 -
section = result[match[1]] = {};
创建一个新的空对象作为该节的设置,并将section
指向这个新对象,同时将这个新对象添加到result
中,键为节的名称。
-
- 第三个
else if
块:-
else if (!/^\s*(;|$)/.test(line))
使用正则表达式^\s*(;|$)
检查行是否为空行(全是空白字符)或以分号开头。如果都不是(即test
返回false
),表示这行无效。 -
throw new Error("Line '" + line + "' is not valid.");
抛出一个错误,提示该行无效。
-
- 使用
-
返回结果:
return result;
函数最后返回解析后的
result
对象,它包含了INI文件中所有的设置和节。 -
测试函数:
console.log(parseINI(` name=Vasilis [address] city=Tessaloniki`)); // → {name: "Vasilis", address: {city: "Tessaloniki"}}
调用
parseINI
函数并传入一个示例INI格式的字符串,然后将解析结果打印到控制台。输出结果是一个对象,包含顶级的name
设置和address
节及其city
设置。 )
代码逐行遍历文件内容并构建一个对象。顶级属性直接存储在该对象中,而在节中找到的属性则存储在一个单独的节对象中。section
绑定指向当前节的对象。
有两种重要的行类型 —— 节标题行或属性行。当一行是常规属性行时,它会被存储在当前节中。当它是节标题行时,会创建一个新的节对象,并且 section
被设置为指向这个新对象。
注意反复使用 ^
和 $
来确保表达式匹配整行,而不仅仅是行的一部分。省略这些符号会导致代码在大多数情况下似乎能正常工作,但对于某些输入会表现异常,这可能是一个很难追踪的 bug。
if (match = string.match(...))
这种模式利用了赋值表达式(=
)的值就是所赋的值这一特性。你通常不确定对 match
的调用是否会成功,所以只能在测试其结果的 if
语句内部访问得到的对象。为了不破坏 else if
形式的连贯结构,我们将 match
的结果赋值给一个绑定,然后立即将这个赋值作为 if
语句的测试条件。
如果一行既不是节标题行也不是属性行,函数会使用表达式 /^\s*(;|$)/
检查它是否是注释行或空行,该表达式匹配只包含空白字符,或者空白字符后跟分号(使该行其余部分成为注释)的行。当一行不匹配任何预期形式时,函数会抛出一个异常。
代码单元与字符
JavaScript 正则表达式中另一个已标准化的设计失误在于,默认情况下,像 .
或 ?
这样的操作符作用于代码单元(如第 5 章所讨论的),而不是实际的字符。这意味着由两个代码单元组成的字符表现会很奇怪。
console.log(/🍎{3}/.test("🍎🍎🍎"));
// → false
console.log(/<.>/.test("<🌹>"));
// → false
console.log(/<.>/u.test("<🌹>"));
// → true
问题在于,第一行中的 🍎 被视为两个代码单元,而 {3}
仅应用于第二个单元。同样,点号(.
)匹配单个代码单元,而非构成玫瑰 emoji 的两个代码单元。
你必须在正则表达式中添加 u
(Unicode)选项,以便它能正确处理这类字符。
console.log(/🍎{3}/u.test("🍎🍎🍎"));
// → true
总结
正则表达式是表示字符串中模式的对象,它们使用自身特有的语言来表达这些模式。
-
/abc/
:字符序列 -
/[abc]/
:字符集中的任意一个字符 -
/[^abc]/
:不在字符集中的任意一个字符 -
/[0 - 9]/
:字符范围内的任意一个字符 -
/x+/
:模式x
出现一次或多次 -
/x+?/
:模式x
出现一次或多次,非贪婪模式 -
/x*/
:模式x
出现零次或多次 -
/x?/
:模式x
出现零次或一次 -
/x{2,4}/
:模式x
出现二到四次 -
(abc)
:一个分组 -
/a|b|c/
:多个模式中的任意一个 -
\d
:任意数字字符 -
\w
:任意字母数字字符(“单词字符”) -
\s
:任意空白字符 -
/./
:除换行符外的任意字符 -
/\p{L}/u
:任意字母字符 -
/^/
:输入的开始位置 -
/$/
:输入的结束位置 -
/(?=a)/
:正向先行断言测试
正则表达式有一个 test
方法,用于测试给定字符串是否与之匹配。它还有一个 exec
方法,当找到匹配项时,返回一个包含所有匹配分组的数组。这样的数组有一个 index
属性,用于指示匹配开始的位置。
字符串有一个 match
方法,用于将字符串与正则表达式进行匹配;还有一个 search
方法,用于搜索匹配项,仅返回匹配项的起始位置。字符串的 replace
方法可以用替换字符串或函数替换模式的匹配项。
正则表达式可以有选项,写在结束斜杠之后。i
选项使匹配不区分大小写。g
选项使表达式成为全局的,这会导致 replace
方法替换所有实例,而不仅仅是第一个。y
选项使表达式具有粘性,这意味着在查找匹配项时,它不会向前搜索并跳过字符串的部分内容。u
选项开启 Unicode 模式,启用 \p
语法,并解决处理占用两个代码单元的字符时出现的一些问题。
正则表达式是一把利刃,但使用起来不太顺手。它们极大地简化了一些任务,但应用于复杂问题时,可能很快就变得难以驾驭。知道如何使用它们的一部分诀窍在于,克制将不适合用正则表达式清晰表达的内容强行塞入其中的冲动。
练习
在做这些练习的过程中,你几乎不可避免地会被某些正则表达式莫名其妙的行为搞得困惑和沮丧。有时,将你的表达式输入到像debuggex.com这样的在线工具中会有所帮助,这样可以查看它的可视化表示是否与你的预期相符,并试验它对各种输入字符串的响应方式。
正则表达式高尔夫
“代码高尔夫”是一个术语,指的是尝试用尽可能少的字符来表达特定程序的游戏。类似地,“正则表达式高尔夫”就是指编写尽可能简短的正则表达式,使其仅匹配给定的模式且只匹配该模式。
对于以下每一项,编写一个正则表达式来测试给定的模式是否出现在字符串中。该正则表达式应该只匹配包含该模式的字符串。当你的表达式能正常工作后,看看是否还能让它更简短。
-
car
andcat
:- A regular expression to match either
car
orcat
could be/car|cat/
. This uses the|
(alternation) operator to match either of the two words. It's hard to make it much smaller as it clearly defines the two words we want to match.
- A regular expression to match either
-
pop
andprop
:- The regular expression
/pop|prop/
works. Here again, we use the alternation operator to match eitherpop
orprop
. This is a fairly minimal way to express this pattern.
- The regular expression
-
ferret
,ferry
, andferrari
:- One possible regular expression is
/ferr(et|y|ari)/
. We group the common partferr
and then use alternation within the group to match the different endings. This is a compact way to represent these three words.
- One possible regular expression is
-
Any word ending in
ious
:-
/\w+ious\b/
will work. Here,\w+
matches one or more word characters (letters, digits, or underscores), andious
matches the specific ending. The\b
is a word boundary, ensuring that we match a complete word ending withious
and not part of a longer word. We could try/\S+ious\b/
where\S
matches any non - whitespace character, but it's not really smaller and might match non - word characters at the start, so/\w+ious\b/
is a better option.
-
-
A whitespace character followed by a period, comma, colon, or semicolon:
-
/\s[.,:;]/
does the job. The\s
matches any whitespace character, and the character class[.,:;]
matches either a period, comma, colon, or semicolon.
-
-
A word longer than six letters:
-
/\b\w{7,}\b/
is a good solution. The\b
represents word boundaries, and\w{7,}
matches seven or more word characters. This ensures we are matching a complete word longer than six letters.
-
-
A word without the letter
e
(orE
):-
/\b[^eE]+\b/
works. The\b
marks word boundaries, and the character class[^eE]
matches any character excepte
andE
. The+
ensures we have at least one such character within the word.
-
Here are some simple test cases in JavaScript:
// Test for "car and cat"
console.log(/car|cat/.test('car')); // true
console.log(/car|cat/.test('dog')); // false
// Test for "pop and prop"
console.log(/pop|prop/.test('pop')); // true
console.log(/pop|prop/.test('top')); // false
// Test for "ferret, ferry, and ferrari"
console.log(/ferr(et|y|ari)/.test('ferret')); // true
console.log(/ferr(et|y|ari)/.test('berry')); // false
// Test for "Any word ending in ious"
console.log(/\w+ious\b/.test('ridiculous')); // true
console.log(/\w+ious\b/.test('curiousness')); // false
// Test for "A whitespace character followed by a period, comma, colon, or semicolon"
console.log(/\s[.,:;]/.test(' a')); // false
console.log(/\s[.,:;]/.test(' :')); // true
// Test for "A word longer than six letters"
console.log(/\b\w{7,}\b/.test('computer')); // true
console.log(/\b\w{7,}\b/.test('book')); // false
// Test for "A word without the letter e (or E)"
console.log(/\b[^eE]+\b/.test('fly')); // true
console.log(/\b[^eE]+\b/.test('elephant')); // false
Quoting style
Imagine you have written a story and used single quotation marks throughout to mark pieces of dialogue. Now you want to replace all the dialogue quotes with double quotes, while keeping the single quotes used in contractions like aren’t.
Think of a pattern that distinguishes these two kinds of quote usage and craft a call to the replace method that does the proper replacement.
-
Analysis of the problem:
- We need to find a way to distinguish between single quotes used for dialogue and single quotes used in contractions. A key difference is that single quotes used in dialogue are usually surrounded by non - word characters (like spaces, commas, periods, etc.) on both sides. In contractions, the single quote is between word characters.
-
Regular expression construction:
- The regular expression
/\s'(?!\w)|'(?<!\w)\s/
can be used.-
\s'(?!\w)
matches a single quote that is preceded by a whitespace character (\s
) and is not followed by a word character ((?!\w)
). This part is for cases where the single quote starts a piece of dialogue. -
'(?<!\w)\s
matches a single quote that is followed by a whitespace character (\s
) and is not preceded by a word character ((?<!\w)
). This part is for cases where the single quote ends a piece of dialogue.
-
- The regular expression
-
Using the
replace
method:- In JavaScript, we can use the following code:
let text = "'I'm the cook,' he said, 'it's my job.'";
console.log(text.replace(/\s'(?!\w)|'(?<!\w)\s/g, '"'));
- The
replace
method is called on thetext
string. The first argument is the regular expression we constructed, and the second argument is the replacement string, which is a double quote ("
). Theg
flag is used to perform a global search and replacement, so all relevant single quotes in the string will be replaced.
So the complete code with the proper replace
call is:
let text = "'I'm the cook,' he said, 'it's my job.'";
console.log(text.replace(/\s'(?!\w)|'(?<!\w)\s/g, '"'));
This code will output: "I'm the cook," he said, "it's my job."
Numbers again
Write an expression that matches only JavaScript-style numbers. It must support an optional minus or plus sign in front of the number, the decimal dot, and exponent notation—5e-3
or 1E10
—again with an optional sign in front of the exponent. Also note that it is not necessary for there to be digits in front of or after the dot, but the number cannot be a dot alone. That is, .5
and 5.
are valid JavaScript numbers, but a lone dot isn’t.
-
Regular expression breakdown:
- For the sign part: We want to match an optional plus or minus sign at the start. This can be represented as
[-+]?
. The?
makes the character group[-+]
(either a minus or a plus sign) optional. - For the integer part before the decimal dot: We can match zero or more digits using
\d*
. The*
means zero or more occurrences of the preceding character class\d
(digit). - For the decimal dot: We need to match a dot, but we want to ensure it's not the only character in the number. So we can use
(\.\d*)?
. This part allows for a dot followed by zero or more digits, and the outer?
makes this entire group optional. - For the exponent part: Exponent notation in JavaScript is in the form of
e
orE
followed by an optional sign and some digits. We can represent this as([eE][-+]?\d+)?
. The outer?
makes the exponent part optional. The inner[-+]?
allows for an optional sign in front of the exponent digits, and\d+
matches one or more digits for the exponent value.
- For the sign part: We want to match an optional plus or minus sign at the start. This can be represented as
-
Putting it all together:
- The regular expression to match JavaScript - style numbers is
/^[-+]?\d*(\.\d*)?([eE][-+]?\d+)?$/
. The^
and$
at the start and end ensure that the entire string is matched, not just part of it.
- The regular expression to match JavaScript - style numbers is
Here is a JavaScript code example to test this regular expression:
let numbers = ["5", "-5", "5.5", ".5", "5.", "5e10", "5e-10", "+5e+10", "-5e+10"];
let nonNumbers = [".", "e10", "5e", "5e+"];
let numberRegex = /^[-+]?\d*(\.\d*)?([eE][-+]?\d+)?$/;
numbers.forEach((num) => {
console.log(`${num}: ${numberRegex.test(num)}`);
});
nonNumbers.forEach((nonNum) => {
console.log(`${nonNum}: ${numberRegex.test(nonNum)}`);
});
This code will test a set of valid and invalid JavaScript - style numbers against the regular expression and log the results to the console.
// Fill in this regular expression.
let number = /^...$/;
// Tests:
for (let str of ["1", "-1", "+15", "1.55", ".5", "5.",
"1.3e2", "1E-4", "1e+12"]) {
if (!number.test(str)) {
console.log(`Failed to match '${str}'`);
}
}
for (let str of ["1a", "+-1", "1.2.3", "1+1", "1e4.5",
".5.", "1f5", "."]) {
if (number.test(str)) {
console.log(`Incorrectly accepted '${str}'`);
}
}</pre>