小览 ES6-ES2019 中正则表达式的新发展

在此前的 《JS正则表达式--从入门到精分》 一文中,曾经较完整的介绍过 Javascript 中正则表达式的用法。而从 ES6(ES2015) 开始,借助 Babel 等标志性的工具,JS 的发展似乎也不想重蹈 Flash 时代的无所作为,走上了每年一个小版本的快车道;在此过程中,正则表达式也陆续演化出一些新的特性。

本文尝试走马观花,一瞥这些特性,看能否籍此简化我们的开发。

ECMAScript 和 TC39

虽然可能是大家普遍了解的事情,但这些称呼反复出现,可能还是需要稍微解释一下先。

ECMA 指的是 “欧洲计算机制造商协会”(European Computer Manufacturers Association);现在也称为 “ECMA 国际”(ECMA International),定位为一家国际性会员制度的信息和电信标准组织。

1996 年 11 月,JavaScript 的创造者 Netscape 公司,决定将 JavaScript 提交给 ECMA,希望这种语言能够成为国际标准。次年,ECMA 发布 262 号标准文件(ECMA-262)的第一版,规定了浏览器脚本语言的标准,并将这种语言称为 ECMAScript (简称 ES)。

此后该标准应用广泛,JavaScript、JScript、ActionScript 等都算是 ECMA-262 标准的实现和扩展。

从 1997 年至 2009 年,陆续发布了 ES1 ~ ES5;其中 ES4 其实是偏向于 Adobe 的 ActionScript 风格的实践设计的,但最终也随着 Flash 被市场上其他厂商封杀,以改动过大为名不了了之,但其中一些特性被后来的 ES6 继承。

2015年,可以说迄今最重要的一个版本 ES6,也就是 ES2015(ES6 的第一个版本) 发布。由各个主流浏览器厂商的代表组成 ECMA 第 39 号技术专家委员会(Technical Committee 39,简称 TC39),负责制订新的 ECMAScript 标准。

新的语法从提案到变成正式标准,需要经历五个阶段。每个阶段的变动都需要由 TC39 委员会批准:

  • Stage 0 - Strawman(展示阶段)

  • Stage 1 - Proposal(征求意见阶段)

  • Stage 2 - Draft(草案阶段)

  • Stage 3 - Candidate(候选人阶段)

  • Stage 4 - Finished(定案阶段)

以上这几个也就是我们之前使用 Babel 转译工具时会引入 babel-preset-stage-0 等预置方案的原由,当然随着 Babel 7 的发布,这些方案都被统一到了 @babel/preset-env 中。

ES6 中的正则表达式特性

以下特性首次在 ES6 中出现:

  • “粘性”修饰符 /y

  • unicode 修饰符 /u

  • 正则表达式对象上的新属性 flags

  • 用构造函数 RegExp() 拷贝正则表达式

“粘性”修饰符 /y

修饰符 /y 只将正则表达式的每个匹配锚定到前一个匹配的末尾

简单的说,这主要与正则表达式对象上的 lastIndex 属性有关 -- 其与 /g/y 的搭配,会产生不同的效果。

在不设置修饰符,或只设置了 /g 修饰符的情况下,只要目标字符串(或上一次匹配的剩余部分)中存在匹配就可以。

/y 修饰符则告知正则表达式,只能不偏不倚的从字符串的 lastIndex 那个位置去匹配,这也就是“粘性、粘连”的涵义。

exec() 的使用为例:


   
   
  1. //不设置修饰符

  2. const re1 = /a/;

  3. re1.lastIndex = 7; // 设置的十分明确然而并不会有什么用

  4. const match = re1.exec('haha');

  5. console.log(match.index); // 1

  6. console.log(re1.lastIndex); // 7 (没有变化呢)

  7. //设置了 `/g` 修饰符

  8. const re2 = /a/g;

  9. re2.lastIndex = 2;

  10. const match = re2.exec('haha');

  11. console.log(match.index); // 3 (这次被 lastIndex 影响了)

  12. console.log(re2.lastIndex); // 4 (更新为匹配成功后的下一位了)

  13. console.log(re2.exec('xaxa')); // null (4以后就没有再匹配的了)

  14. //设置了 `/y` 修饰符

  15. const re3 = /a/y;

  16. re3.lastIndex = 2;

  17. console.log(re3.exec('haha')); // null (在位置 2 并不匹配)

  18. re3.lastIndex = 3;

  19. const match = re3.exec('haha');

  20. console.log(match.index); // 3 (在位置 3 准确匹配了)

  21. console.log(re3.lastIndex); // 4 (也更新了)

当然,一般情况下 -- 比如第一次运行匹配,或不特别设置 lastIndex 时, /y 的功效大抵和 ^起始匹配符相同,因为此时 lastIndex0 :


   
   
  1. const re1 = /^a/g;

  2. const re2 = /a/y;

  3. console.log(re1.test('haha')); // false

  4. console.log(re2.test('haha')); // false

需要注意的是,如果同时设置了 /g/y,则只有 /y 会生效。

sticky 属性

/y 修饰符相配套,ES6 的正则表达式对象多了 sticky 属性,表示是否设置了 /y 修饰符:


   
   
  1. var r = /hello\d/y;

  2. r.sticky // true

unicode 修饰符 /u

这里简单解释一下 Unicode,其目标是为世界上每一个字符提供唯一标识符,该唯一标识符可称为 码点(code point) 或 字符编码(character encode)。

在 ES6 之前, JS 的字符串以 16 位字符编码(UTF-16)为基础。每个 16 位序列(相当于2个字节)是一个编码单元(code unit,可简称为码元),用于表示一个字符。字符串所有的属性与方法(如 length属性与 charAt() 方法等)都是基于这样 16 位的序列。

本来 JS 允许采用 \uxxxx 形式表示一个常用的 unicode 字符,其中的 4 个十六进制数字表示字符的 unicode 码点:


   
   
  1. console.log("\u0061"); // "a"

同时,这种表示法只能表示码点局限于 0x0000~ 0xFFFF 之间的字符。超出这个范围的字符,必须用两个码元连接的形式(称为 surrogate-pairs,代理对)表示一个码点:


   
   
  1. console.log("\uD842\uDFB7"); // "????"

这就导致了一个问题,对于一些超出 16 位 0xFFFF 的 unicode 字符,传统的方法就会出错;比如直接在 \u20BB7,JS 会理解成 \u20BB + 7;所以会显示成一个特殊字符,后面跟着一个 7。

对此 ES6 做出了改进,将 unicode 编码放入大括号(这种语法称为 unicode 码点转义符),就可以正确解读字符了:


   
   
  1. console.log("\u{20BB7}"); // "????"

同样的例子:


   
   
  1. '\u{1F680}' === '\uD83D\uDE80' //true

  2. console.log('\u{1F680}') //????

  3. console.log('\uD83D\uDE80') //????

此中的转换对应关系,这篇文章( https://blog.youkuaiyun.com/hherima/article/details/38961575 )做了比较清楚的探寻,感兴趣的话可以结合文末的资料进行研究;本文不展开掰哧,能体会示例即可。

书归正传,在 ES6 的正则中:

修饰符 /u 将正则表达式切换为特殊的 Unicode 模式

在 Unicode 模式下,既可以使用新的大括号 unicode 编码点转义符表示范围更大的字符,也可以继续使用 UTF-16 码元。该模式具有如下特征:

  • “单独代理”(lone surrogates)特性:


   
   
  1. //传统的非 Unicode 模式

  2. /\uD83D/.test('\uD83D\uDC2A') //true,按16位码元识别

  3. //Unicode 模式

  4. /\uD83D/u.test('\uD83D\uDC2A') //false,此模式下会识别成码点中的原子部分

  5. /\uD83D/u.test('\uD83D \uD83D\uDC2A') //true

  6. /\uD83D/u.test('\uD83D\uDC2A \uD83D') //true

  • 可以将码点放入正则的字符类中:


   
   
  1. /^[\uD83D\uDC2A]$/.test('\uD83D\uDC2A') //false

  2. /^[\uD83D\uDC2A]$/u.test('\uD83D\uDC2A') //true

  3. /^[\uD83D\uDC2A]$/.test('\uD83D') //true

  4. /^[\uD83D\uDC2A]$/u.test('\uD83D') //false

  • 点操作符匹配码点,而非码元


   
   
  1. '\uD83D\uDE80'.match(/./gu).length //1

  2. '\uD83D\uDE80'.match(/./g).length //2

  • 数量描述符也同样匹配到码点


   
   
  1. /\uD83D\uDE80{2}/u.test('\uD83D\uDE80\uD83D\uDE80') //true

  2. /\uD83D\uDE80{2}/.test('\uD83D\uDE80\uD83D\uDE80') //false

  3. /\uD83D\uDE80{2}/.test('\uD83D\uDE80\uDE80') //true

正则表达式对象上的新属性 flags

新增的 flags 属性,会返回正则表达式的修饰符


   
   
  1. const re = /abc/ig;

  2. console.log( re.source ); //'abc'

  3. console.log( re.flags ); //'gi'

用 RegExp() 拷贝正则表达式

正则表达式构造函数的传统签名是 newRegExp(pattern:string,flags=''),比如:


   
   
  1. const re1 = new RegExp("^a\d{3}", 'gi')

  2. // 等同于: /^ad{3}/gi

ES6中,新增的用法是 newRegExp(regex:RegExp,flags=regex.flags)


   
   
  1. var re2 = new RegExp(re1, "yi")

  2. // 结果是: /^ad{3}/iy

这就提供了一种拷贝已有正则表达式,或更改其修饰符的方法。

ES2018/ES2019 中的新特性

在 ES2018 - ES2019 中,又增加了一些特性:

  • 命名捕获组

  • 反向引用

  • 反向断言

  • unicode 属性转义

  • dotAll 修饰符 /s

命名捕获组

此前的正则表达式操作中,采用的是“编号捕获组”(Numbered capture groups)匹配字符串并将之分组,比如:


   
   
  1. const RE_DATE = /([0-9]{4})-([0-9]{2})-([0-9]{2})/; //小括号的顺序决定了其编号

  2. const matchObj = RE_DATE.exec('1999-12-31');

  3. const year = matchObj[1]; // 1999

  4. const month = matchObj[2]; // 12

  5. const day = matchObj[3]; // 31

这种方式无论从易用性还是复杂度方面都不太理想,尤其当字段过多、存在嵌套等情况时;如果改动了正则表达式还容易忘记同步改变分散在各处的编号。

ES6 带来的“命名捕获组”(Named capture groups),则可以通过名称来识别捕获的分组

  • 其格式如 (?<year>[0-9]{4})

  • 通过捕获结果中的 groups.year 属性取出

  • 任何匹配失败的命名组都将返回 undefined


   
   
  1. const RE_DATE = /(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})/;

  2. const matchObj = RE_DATE.exec('1999-12-31');

  3. const year = matchObj.groups.year; // 1999

  4. const month = matchObj.groups.month; // 12

  5. const day = matchObj.groups.day; // 31

  6. //编号的方式同时被保留

  7. const year2 = matchObj[1]; // 1999

  8. const month2 = matchObj[2]; // 12

  9. const day2 = matchObj[3]; // 31

这还为 replace() 方法提供了额外的便利,注意其语法:


   
   
  1. const RE_DATE = /(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})/;

  2. console.log( '2018-04-30'.replace(RE_DATE, '$<month>-$<day>-$<year>') ); //04-30-2018

  3. //编号捕获组的写法:

  4. console.log( '2018-04-30'.replace(RE_DATE, "$2-$3-$1") ); //04-30-2018

反向引用(Backreferences)

正则表达式中的 \k<name> 表示这样的意思:根据前一次匹配到的命名捕获组中的名称,匹配相应的字符串,比如:


   
   
  1. const RE_TWICE = /^(?<word>[a-z]+)!\k<word>$/;

  2. RE_TWICE.test('abc!abc'); // true

  3. RE_TWICE.test('abc!ab'); // false

这种称为反向引用的语法,对于编号捕获组同样适用:


   
   
  1. const RE_TWICE = /^(?<word>[a-z]+)!\1$/;

  2. RE_TWICE.test('abc!abc'); // true

  3. RE_TWICE.test('abc!ab'); // false

两掺儿的,也没问题:


   
   
  1. const RE_TWICE = /^(?<word>[a-z]+)!\k<word>!\1$/;

  2. RE_TWICE.test('abc!abc!abc'); // true

  3. RE_TWICE.test('abc!abc!ab'); // false

反向断言(lookbehind assertions)

根据之前文章的介绍,JS 中已经支持了“正向断言”(Lookahead assertions),或称为正向查找。

  • x(?=y) 匹配'x'仅仅当'x'后面跟着'y'。称为正向肯定查找

  • x(?!y) 匹配'x'仅仅当'x'后面不跟着'y'。称为正向否定查找

ES2018 引入了反向断言(lookbehind assertions),与正向断言的工作方式相同,只是方向相反

同样也分为两种子类型:

  • y(?<=x) 匹配'x'仅仅当'x'前面挨着'y'。称为反向肯定查找


   
   
  1. //传统的做法:

  2. const RE_DOLLAR_PREFIX = /(\$)foo/g;

  3. '$foo %foo foo'.replace(RE_DOLLAR_PREFIX, '$1bar'); // '$bar %foo foo'

  4. //用了反向肯定查找的方法:

  5. const RE_DOLLAR_PREFIX = /(?<=\$)foo/g;

  6. '$foo %foo foo'.replace(RE_DOLLAR_PREFIX, 'bar'); // '$bar %foo foo'

  • y(?<!x) 匹配'x'仅仅当'x'前面不挨着'y'。称为反向否定查找


   
   
  1. //传统的做法:

  2. const RE_NO_DOLLAR_PREFIX = /([^\$])foo/g

  3. '$foo %foo *foo'.replace(RE_NO_DOLLAR_PREFIX, '$1bar');  //"$foo %bar *bar"

  4. //用了反向肯定查找的方法:

  5. const RE_NO_DOLLAR_PREFIX = /(?<!\$)foo/g;

  6. '$foo %foo foo'.replace(RE_NO_DOLLAR_PREFIX, 'bar'); // '$foo %bar bar'

unicode 属性转义

在 ES6 的 /u 修饰符基础上,ES2018 添加了 "unicode 属性转义"(Unicode property escapes) -- 形式为 \p{...}\P{...},分别表示“包含”和“不包含”

从目的和形式上这很类似于用 \s 来匹配空格等 whitespace,而 \p{}\P{} 花括号中的部分称为 "unicode 字符属性"(Unicode character properties),让正则表达式有了更好的可读性。


   
   
  1. /^\p{Script=Greek}+$/u.test('μετά') //true,匹配希腊字母,prop=value 的形式

  2. /^\p{White_Space}+$/u.test('\t \n\r') //true,匹配所有空格,bin_prop 的形式

所谓“unicode 字符属性”,是指在 Unicode 标准中,每个字符都有用于描述其性质的元数据: properties,比如:

  • Name: 一个唯一的名称,由大写字母、数字、连字符、空格组成,如:

    • A: Name = LATIN CAPITAL LETTER A

    • ????: Name = GRINNING FACE

  • General_Category: 分类的字符,如:

    • x: GeneralCategory = LowercaseLetter

    • $: GeneralCategory = CurrencySymbol

  • White_Space: 用于标记不可见的空格、制表符、换行等字符,如:

    • \t: WhiteSpace = True

    • π: WhiteSpace = False

  • Age: Unicode 标准的版本号,如:

    • : Age = 2.1

  • Block: 码点的一个连续范围,不会重复,命名也是唯一的,如:

    • S: Block = Basic_Latin (range U+0000..U+007F)

    • ????: Block = Emoticons (range U+1F600..U+1F64F)

  • Script: 一个字符集合,用于一个或多个书写系统

    • α: Script = Greek

    • Д: Script = Cyrillic

    • 某些 script 支持多个书写系统,比如 Latin script 支持 English, French, German, Latin 等

    • 某些语言可以用由多种 script 支持的多种替代书写系统书写。例如,土耳其语在 20 世纪早期转变为 Latin script 之前就使用了 Arabic script。

    • 举例来说:

另外几个例子:


   
   
  1. "AaBbCcDD".split("").filter(letter=>{

  2.    return /\p{Lower}/u.test(letter);

  3. }).join("") //"abc"

  4. const regex = /^\p{Number}+$/u;

  5. regex.test('²³¾????????????㉝ⅠⅡⅻⅼⅽⅾⅿ'); //true

  6. /\p{Currency_Symbol}+/u.test("¥$€"); //true

dotAll 修饰符 /s

我们通常会在很多正则表达式中见到一种 [\s\S] 的匹配小技巧,这种看似多余的写法其实是为了弥补 . 标记无法在多行的情况下实现正确匹配的缺憾。

修饰符 /s 解决了这个问题,所以也称为 dotAll 修饰符。


   
   
  1. console.log(/hello.world/.test('hello\nworld'));  // false

  2. console.log(/hello[\s\S]world/.test('hello\nworld'));  // true

  3. console.log(/hello.world/s.test('hello\nworld')); // true

关于 . 标记,顺便一提的是:


   
   
  1. /^.$/.test('????') //false,并不将 emoji 识别为一个字符

  2. /^.$/u.test('????') //true,通过 u 修正

参考资料:

  • 《JS正则表达式--从入门到精分》 https://mp.weixin.qq.com/s?__biz=MzI0MDYzOTEyOA==&mid=2247483694&idx=1&sn=e79f23c86e48b6a85d30d612e1d5a2eb&chksm=e9168cd9de6105cf647ceabac1a2ee737d0867e7b3cdab243e3cd889b295e3ab9666820ad02d

  • http://exploringjs.com/es6/chregexp.html#secregexp

  • http://exploringjs.com/es2018-es2019/toc.html

  • http://www.cnblogs.com/dandelion-drq/p/jsregularexpression_note.html

  • http://www.appui.org/2496.html

  • https://stackoverflow.com/questions/4542304/what-does-regex-flag-y-do

  • http://www.cnblogs.com/detanx/p/es6zz.html

  • http://www.cnblogs.com/xiaohuochai/p/7230328.html

  • https://www.princeton.edu/~mlovett/reference/Regular-Expressions.pdf

  • https://unicodebook.readthedocs.io/unicode_encodings.html#utf-16-c

  • https://docs.microsoft.com/zh-cn/previous-versions/8k5611at(v=vs.110)

  • https://blog.youkuaiyun.com/hherima/article/details/38961575

  • https://arui.tech/es2018-new-features/#UnicodeUnicodepropertyescapes

  • https://github.com/tc39/proposal-regexp-unicode-property-escapes

  • http://caibaojian.com/es6/

  • https://zhuanlan.zhihu.com/p/27762556

  • https://babeljs.io/docs/en/babel-preset-env



--End--

搜索 fewelife 关注公众号

转载请注明出处

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值