正则表达式扩展

让我们承认吧:长久以来在JS中正则表达式都没怎么改变过。所以一件很棒的事情是,在ES6中它们终于学会了一些新招数。我们将在这里简要地讲解一下新增的功能,但是正则表达式整体的话题是如此厚重,以至于如果你需要复习一下的话你需要找一些关于它的专门章节/书籍(有许多!)。

Unicode标志

我们将在本章稍后的“Unicode”一节中讲解关于Unicode的更多细节。在此,我们将仅仅简要地看一下ES6+正则表达式的新u标志,它使这个正则表达式的Unicode匹配成为可能。

JavaScript字符串通常被解释为16位字符的序列,它们对应于 基本多文种平面(Basic Multilingual Plane (BMP)) (http://en.wikipedia.org/wiki/Plane_%28Unicode%29)中的字符。但是有许多UTF-16字符在这个范围以外,而且字符串可能含有这些多字节字符。

在ES6之前,正则表达式只能基于BMP字符进行匹配,这意味着在匹配时那些扩展字符被看作是两个分离的字符。这通常不理想。

所以,在ES6中,u标志告诉正则表达式使用Unicode(UTF-16)字符的解释方式来处理字符串,这样一来一个扩展的字符将作为一个单独的实体被匹配。

警告: 尽管名字的暗示是这样,但是“UTF-16”并不严格地意味着16位。现代的Unicode使用21位,而且像UTF-8和UTF-16这样的标准大体上是指有多少位用于表示一个字符。

一个例子(直接从ES6语言规范中拿来的): ? (G大调音乐符号)是Unicode代码点U+1D11E(0x1D11E)。

如果这个字符出现在一个正则表达式范例中(比如/?/),标准的BMP解释方式将认为它是需要被匹配的两个字符(0xD834和0xDD1E)。但是ES6新的Unicode敏感模式意味着/?/u(或者Unicode的转义形式/\u{1D11E}/u)将会把"?"作为一个单独的字符在一个字符串中进行匹配。

你可能想知道为什么这很重要。在非Unicode的BMP模式下,这个正则表达式范例被看作两个分离的字符,但它仍然可以在一个含有"?"字符的字符串中找到匹配,如果你试一下就会看到:

  1. /?/.test( "?-clef" ); // true

重要的是匹配的长度。例如:

  1. /^.-clef/ .test( "?-clef" ); // false
  2. /^.-clef/u.test( "?-clef" ); // true

这个范例中的^.-clef说要在普通的"-clef"文本前面只匹配一个单独的字符。在标准的BMP模式下,这个匹配会失败(因为是两个字符),但是在Unicode模式标志位u打开的情况下,这个匹配会成功(一个字符)。

另外一个重要的注意点是,u使像+*这样的量词实施于作为一个单独字符的整个Unicode代码点,而不仅仅是字符的 低端替代符(也就是符号最右边的一半)。对于出现在字符类中的Unicode字符也是一样,比如/[?-?]/u

注意: 还有许多关于u在正则表达式中行为的细节,对此Mathias Bynens(https://twitter.com/mathias)撰写了大量的作品(https://mathiasbynens.be/notes/es6-unicode-regex)。

粘性标志

另一个加入ES6正则表达式的模式标志是y,它经常被称为“粘性模式(sticky mode)”。粘性 实质上意味着正则表达式在它开始时有一个虚拟的锚点,这个锚点使正则表达式仅以自己的lastIndex属性所指示的位置为起点进行匹配。

为了展示一下,让我们考虑两个正则表达式,第一个没有使用粘性模式而第二个有:

  1. var re1 = /foo/,
  2. str = "++foo++";
  3. re1.lastIndex; // 0
  4. re1.test( str ); // true
  5. re1.lastIndex; // 0 —— 没有更新
  6. re1.lastIndex = 4;
  7. re1.test( str ); // true —— `lastIndex`被忽略了
  8. re1.lastIndex; // 4 —— 没有更新

关于这个代码段可以观察到三件事:

  • test(..)根本不在意lastIndex的值,而总是从输入字符串的开始实施它的匹配。
  • 因为我们的模式没有输入的起始锚点^,所以对"foo"的搜索可以在整个字符串上自由向前移动。
  • lastIndex没有被test(..)更新。

现在,让我们试一下粘性模式的正则表达式:

  1. var re2 = /foo/y, // <-- 注意粘性标志`y`
  2. str = "++foo++";
  3. re2.lastIndex; // 0
  4. re2.test( str ); // false —— 在`0`没有找到“foo”
  5. re2.lastIndex; // 0
  6. re2.lastIndex = 2;
  7. re2.test( str ); // true
  8. re2.lastIndex; // 5 —— 在前一次匹配后更新了
  9. re2.test( str ); // false
  10. re2.lastIndex; // 0 —— 在前一次匹配失败后重置

于是关于粘性模式我们可以观察到一些新的事实:

  • test(..)str中使用lastIndex作为唯一精确的位置来进行匹配。在寻找匹配时不会发生向前的移动 —— 匹配要么出现在lastIndex的位置,要么就不存在。
  • 如果发生了一个匹配,test(..)就更新lastIndex使它指向紧随匹配之后的那个字符。如果匹配失败,test(..)就将lastIndex重置为0

没有使用^固定在输入起点的普通非粘性范例可以自由地在字符串中向前移动来搜索匹配。但是粘性模式制约这个范例仅在lastIndex的位置进行匹配。

正如我在这一节开始时提到过的,另一种考虑的方式是,y暗示着一个虚拟的锚点,它位于正好相对于(也就是制约着匹配的起始位置)lastIndex位置的范例的开头。

警告: 在关于这个话题的以前的文献中,这种行为曾经被声称为y像是在范例中暗示着一个^(输入的起始)锚点。这是不准确的。我们将在稍后的“锚定粘性”中讲解更多细节。

粘性定位

对反复匹配使用y可能看起来是一种奇怪的限制,因为匹配没有向前移动的能力,你不得不手动保证lastIndex恰好位于正确的位置上。

这是一种可能的场景:如果你知道你关心的匹配总是会出现在一个数字(例如,01020,等等)倍数的位置。那么你就可以只构建一个受限的范例来匹配你关心的东西,然后在每次匹配那些固定位置之前手动设置lastIndex

考虑如下代码:

  1. var re = /f../y,
  2. str = "foo far fad";
  3. str.match( re ); // ["foo"]
  4. re.lastIndex = 10;
  5. str.match( re ); // ["far"]
  6. re.lastIndex = 20;
  7. str.match( re ); // ["fad"]

然而,如果你正在解析一个没有像这样被格式化为固定位置的字符串,在每次匹配之前搞清楚为lastIndex设置什么东西的做法可能会难以维系。

这里有一个微妙之处要考虑。y要求lastIndex位于发生匹配的准确位置。但它不严格要求 来手动设置lastIndex

取而代之的是,你可以用这样的方式构建你的正则表达式:它们在每次主匹配中都捕获你所关心的东西的前后所有内容,直到你想要进行下一次匹配的东西为止。

因为lastIndex将被设置为一个匹配末尾之后的下一个字符,所以如果你已经匹配了到那个位置的所有东西,lastIndex将总是位于下次y范例开始的正确位置。

警告: 如果你不能像这样足够范例化地预知输入字符串的结构,这种技术可能不合适,而且你可能不应使用y

拥有结构化的字符串输入,可能是y能够在一个字符串上由始至终地进行反复匹配的最实际场景。考虑如下代码:

  1. var re = /\d+\.\s(.*?)(?:\s|$)/y
  2. str = "1. foo 2. bar 3. baz";
  3. str.match( re ); // [ "1. foo ", "foo" ]
  4. re.lastIndex; // 7 —— 正确位置!
  5. str.match( re ); // [ "2. bar ", "bar" ]
  6. re.lastIndex; // 14 —— 正确位置!
  7. str.match( re ); // ["3. baz", "baz"]

这能够工作是因为我事先知道输入字符串的结构:总是有一个像"1. "这样的数字的前缀出现在期望的匹配("foo",等等)之前,而且它后面要么是一个空格,要么就是字符串的末尾($锚点)。所以我构建的正则表达式在每次主匹配中捕获了所有这一切,然后我使用一个匹配分组( )使我真正关心的东西被方便地分离出来。

在第一次匹配("1. foo ")之后,lastIndex7,它已经是开始下一次匹配"2. bar "所需的位置了,如此类推。

如果你要使用粘性模式y进行反复匹配,那么你就可能想要像我们刚刚展示的那样寻找一个机会自动地定位lastIndex

粘性对比全局

一些读者可能意识到,你可以使用全局匹配标志位gexec(..)方法来模拟某些像lastIndex相对匹配的东西,就像这样:

  1. var re = /o+./g, // <-- 看,`g`!
  2. str = "foot book more";
  3. re.exec( str ); // ["oot"]
  4. re.lastIndex; // 4
  5. re.exec( str ); // ["ook"]
  6. re.lastIndex; // 9
  7. re.exec( str ); // ["or"]
  8. re.lastIndex; // 13
  9. re.exec( str ); // null —— 没有更多的匹配了!
  10. re.lastIndex; // 0 —— 现在重新开始!

虽然使用exec(..)g范例确实从lastIndex的当前值开始它们的匹配,而且也在每次匹配(或失败)之后更新lastIndex,但这与y的行为不是相同的东西。

注意前面代码段中被第二个exec(..)调用匹配并找到的"ook",被定位在位置6,即便在这个时候lastIndex4(前一次匹配的末尾)。为什么?因为正如我们前面讲过的,非粘性匹配可以在它们的匹配过程中自由地向前移动。一个粘性模式表达式在这里将会失败,因为它不允许向前移动。

除了也许不被期望的向前移动的匹配行为以外,使用g代替y的另一个缺点是,g改变了一些匹配方法的行为,比如str.match(re)

考虑如下代码:

  1. var re = /o+./g, // <-- 看,`g`!
  2. str = "foot book more";
  3. str.match( re ); // ["oot","ook","or"]

看到所有的匹配是如何一次性地被返回的吗?有时这没问题,但有时这不是你想要的。

test(..)match(..)这样的工具一起使用,粘性标志位y将给你一次一个的推进式的匹配。只要保证每次匹配时lastIndex总是在正确的位置上就行!

锚定粘性

正如我们早先被警告过的,将粘性模式认为是暗含着一个以^开头的范例是不准确的。在正则表达式中锚点^拥有独特的含义,它 没有 被粘性模式改变。^总是 一个指向输入起点的锚点,而且 以任何方式相对于lastIndex

在这个问题上,除了糟糕/不准确的文档,一个在Firefox中进行的老旧的前ES6粘性模式实验不幸地加深了这种困惑,它确实 曾经 使^相对于lastIndex,所以这种行为曾经存在了许多年。

ES6选择不这么做。^在一个范例中绝对且唯一地意味着输入的起点。

这样的后果是,一个像/^foo/y这样的范例将总是仅在一个字符串的开头找到"foo"匹配,如果它被允许在那里匹配的话。如果lastIndex不是0,匹配就会失败。考虑如下代码:

  1. var re = /^foo/y,
  2. str = "foo";
  3. re.test( str ); // true
  4. re.test( str ); // false
  5. re.lastIndex; // 0 —— 失败之后被重置
  6. re.lastIndex = 1;
  7. re.test( str ); // false —— 由于定位而失败
  8. re.lastIndex; // 0 —— 失败之后被重置

底线:y^lastIndex > 0是一种不兼容的组合,它将总是导致失败的匹配。

注意: 虽然y不会以任何方式改变^的含义,但是多行模式m,这样^就意味着输入的起点 或者 一个换行之后的文本的起点。所以,如果你在一个范例中组合使用ym,你会在一个字符串中发现多个开始于^的匹配。但是要记住:因为它的粘性y,将不得不在后续的每次匹配时确保lastIndex被置于正确的换行的位置(可能是通过匹配到行的末尾),否者后续的匹配将不会执行。

正则表达式flags

在ES6之前,如果你想要检查一个正则表达式来看看它被施用了什么标志位,你需要将它们 —— 讽刺的是,可能是使用另一个正则表达式 —— 从source属性的内容中解析出来,就像这样:

  1. var re = /foo/ig;
  2. re.toString(); // "/foo/ig"
  3. var flags = re.toString().match( /\/([gim]*)$/ )[1];
  4. flags; // "ig"

在ES6中,你现在可以直接得到这些值,使用新的flags属性:

  1. var re = /foo/ig;
  2. re.flags; // "gi"

虽然是个细小的地方,但是ES6规范要求表达式的标志位以"gimuy"的顺序罗列,无论原本的范例中是以什么顺序指定的。这就是出现/ig"gi"的区别的原因。

是的,标志位被指定和罗列的顺序无所谓。

ES6的另一个调整是,如果你向构造器RegExp(..)传递一个既存的正则表达式,它现在是flags敏感的:

  1. var re1 = /foo*/y;
  2. re1.source; // "foo*"
  3. re1.flags; // "y"
  4. var re2 = new RegExp( re1 );
  5. re2.source; // "foo*"
  6. re2.flags; // "y"
  7. var re3 = new RegExp( re1, "ig" );
  8. re3.source; // "foo*"
  9. re3.flags; // "gi"

在ES6之前,构造re3将抛出一个错误,但是在ES6中你可以在复制时覆盖标志位。