all articles

understand js regular expression

2016-10-08 @sunderls

js RegExp

写代码这么久了,在「 一个ToDo App的前端思考」系列中我真是深深发现自己的基础之不牢固,已经达到了知识肤浅的程度了。嗯,现在开始要各种巩固基础,这次先来一个正则表达的攻略。

常用场景

目前为止我知道的利用方法大概是这样:

var match = "sunderls's blog".match(/^(.*)'s(\s+)(.*)$/);
console.log(match);
// ["sunderls's blog", "sunderls", " ", "blog"]
var match2 = "sunderls's blog".match(new RegExp("^(.*)'s(\\s+)(.*)$"));
console.log(match);
// ["sunderls's blog", "sunderls", " ", "blog"]
console.log(/^(.*)'s(\s+)(.*)$/.test("sunderls's blog"));
// true

注意 new RegExp的时候,反斜线需要转译,因为在字符串中反斜线是用来escape的,所以为了把反斜线传如正则表达中,反斜线本身需要被escape。这个很好理解。

深入理解第一步 - flag

我知道可以在正则后面加入i来忽略大小写,g来表示全局搜索,其他就不太清楚了。其实这个叫正则的flag,共有5种,可以进行组合:

flag 解释
g 全局搜索
i 忽略大小写
m 每一行开头结束匹配^$
u 激活surrogate pair
y 规定头部, sticky

flag - g

「全局搜索」这名字听上去容易懂,实际上还是有点绕。简单的说如果不加g,正则只匹配第一个匹的内容,而且内部括号的子匹配也返回。如果有g,则只返回整个正则表达匹配的内容,正则内部的括号的细节部分会被忽略,看了下面的例子就明白了:

var match = "ab cd ef".match(/[a-z]{2}/);
// ['ab']

var match2 = "ab cd ef".match(/[a-z]{2}/g);
// ["ab", "cd", "ef"]

var match3 = "ab cd ef".match(/([a-z])([a-z])/);
// ["ab", "a", "b"]

var match4 = "ab cd ef".match(/([a-z])([
a-z])/g);
// ["ab", "cd", "ef"]

可以看出 在使用g的情况下,括号是不管用的,而且整个字符串都会跑一遍 ,而不用g的情况下,找到第一个匹配点的时候就结束了。

flag - i

这个很简单了,忽略大小写。

var match = "AA Bb cc".match(/[a-z]{2}/);
// ["cc"]
var match2 = "AA Bb cc".match(/[a-z]{2}/i);
// ["AA"]
var match3 = "AA Bb cc".match(/[a-z]{2}/g);
// ["cc"]
var match4 = "AA Bb cc".match(/[a-z]{2}/gi);
// ["AA", "Bb", "cc"]

flag - m

这个就没怎么用过了,得想一个好的例子。假设上面的例子分了三行: "AA\nBb\ncc",我们知道^是开头,$是结尾,这个字符串虽然有2个换行符,但是作为字符串只有一个头和一个尾,所以有如下的结果:

var match = "AA\nBb\ncc".match(/^[a-z]{2}/i);
// ["AA"]
var match2 = "AA\nBb\ncc".match(/^[a-z]{2}/gi);
// ["AA"]
var match3 = "AA\nBb\ncc".match(/^[a-z]{2}$/i);
// null

如果我们想让每一行的开头都算做开头,每一行的结尾都算做结尾,那么上述字符串就有3个开头 3个结尾,所以上述正则加上"m"过后有如下结果:

var match = "AA\nBb\ncc".match(/^[a-z]{2}/mi);
// ["AA"]
var match2 = "AA\nBb\ncc".match(/^[a-z]{2}/mgi);
// ["AA", "Bb", "cc"]
var match3 = "AA\nBb\ncc".match(/^[a-z]{2}$/mi);
// ["AA"]

总结一下,m就是让每一行的头尾都匹配^$

flag - u

这个感觉基本用不到吧。不过我们还是来了解一下。文档的定义中写的是说如果有u, 表示正则表达式是unicode字符的罗列。实话说看了也不懂是啥意思。经过网上的搜索,发现javascript的字符串有历史的原因导致es5版本的字符串并不是常用的unicode编码。具体可以看这里 http://www.ruanyifeng.com/blog/2014/12/unicode.html#cmid=67684 以及这里 https://mathiasbynens.be/notes/es6-unicode-regex, 以下总结一下使用u的场景。

首先u的使用将导致一般字符上使用转移字符的情况报错

/a/.test("a")   // true
/\a/.test("a")  // true
/a/u.test("a")  // true
/\a/u.test("a") // error

因为"a"是普通的字符而不是\n这种专门需要转义的字符,所以普通情况下\a会和a一样被处理,但是加了u的情况下会报错。

u 对于 . 的影响

我们知道.用来匹配所有字符的通配符。但是实际上这个定义不准确,没有u的情况下,.匹配所有的BMP字符(除了line termimators),这个看上去就比较复杂了,下面来解释一下什么叫BMP。

unicode

unicode包含了所有字符,总共有10多万个,这个就二进制编码一个一个对应就完了。但是这些编码有先后顺序,总共这些字符分成了17个区,每个区域216个字符,也就是说占用了16个bit位来表示一个字符。第一个区,也就是前65536个字符叫做基本平面(BMP),范围是0 ~ 216 -1 ,剩下的2~17区叫做辅助平面(SMP, supplementary planes),打趣的说法叫做 astral planes, 所以补充平面的字符也叫做 astral symbol。 具体参见 wiki: https://en.wikipedia.org/wiki/Plane_(Unicode)。

BMP 字符编码16进制下是 0000 到 FFFF,SMP范围是 10000 到 10FFFF。总共17个平面,所以SMP应该新增2×2×2×2×2 = 32 5个bit,所以16进制下的话增加了2位。但是为什么最大值不是FFFFFF,而是10FFFF呢? 这个问题需要反过来思考,首先某种原因unicode编码的最大值是0x10FFFF,所有总共有0 ~ 0x10 共有17个数字所以有17个平面。 那最大值是10FFFF的原因是啥?貌似是为了兼容各种编码方式,各种编码有的占用空间小,能显示字符就多,有的反过来,比如UTF-16编码方式,就不行。所以unicode编码选择其中的短板0x10FFFF,见下面的utf-16说明。

unicode编码的方式

嗯,上述平面是对字符的分区,那具体怎么编码呢?

utf-32: 一个字符用32位即四个字节进行编码。所以总共可以有232种情况。但是可以看到简单的ACSII码也需要用32位表示,这非常浪费空间,实际上ASCII一个字节就可以表示了。

utf-8: 一个动态表示的方法,越常用的字符越靠前,用越小的空间。(这个有点复杂就不说明了,请参考wiki: https://wikipedia.org/wiki/UTF-8)

utf-16: 类似utf-8,小的用2个字节,其他用4个字节表示。显然2个字节的话范围在0x0000 ~ 0xFFFF。

但是你得告诉人们连续对4个字节是一个字符还是两个字符。所以对于4个字节表示的字符需要有特殊做法。UTF-16采用了Surrogate Pairs的办法,对于BMP以外的字符,第一个字节在 0xD800 ~ 0xDBFF,叫高位,第二个字节在0xDC00 ~ 0xDFFF 叫低位。这两个部分单独的码并不存在。一个16进制是4个bit,B - 8 是 4,所以有2bit,加上00~FF的8个bit,所以这个区域总共有210个点。剩下0xDC00 ~ 0xDFFF 中也有210个点,这样相乘可以得到总共220个字符,这个因为SMP里面有220个字符,所以变成的这样的设计。

这个计算一下,2个字节有216个字符,减去两个未被编码的码点数 2 * 210,加上这两个未编码区域所组成的surrogate pair的220个字符,总共是能表示如下个字符:

2**16 - 2**11 + 2**20 = 1112064; // 0x10F800

但是虽然其中两个区段没有分配字符,但是码点是占据了,所以码点个数是:

2**16 + 2**20 = 1114112; // 0x110000

所以16进制下unicode码点范围是0x000000 ~ 0x10FFFF。

关于surrogate pair, 当遇到第一个字符是0xD800 ~ 0xDBFF的话,就取第二个字节连在一起进行判断。 一个4字节字符的码点可以直接计算出其surrogate pair:

H = Math.floor((C - 0x10000) / 0x400) + 0xD800
L = (C - 0x10000) % 0x400 + 0xDC00

放过来也可以计算surrogate pair对应的码点。

C = (H - 0xD800) * 0x400 + L - 0xDC00 + 0x10000

但是JS的ECMAScript规范定义js 引擎可以采用UCS-2或者UTF-16,但是js语言本身依照的是UCS-2,UCS-2不支持上面的surrogate pair,会把这个pair显示为两个字符,所以会有某些单个字符的长度是2的情况。

js string 测试

unicode所有的字符可以在wiki上看到: https://ja.wikipedia.org/wiki/Unicode#cite_note-374

我们取三个字符,一个是ASC的'a',一个是ASCII之外的BMP中的汉字'我',一个是SMP中的音乐字符'𝄞' U+1D11E,这个属于U+1D100-1D1FF这个音乐字符码段。全部当字符可以在这里看到: http://www.unicode.org/charts/PDF/U1D100.pdf

首先看他们的长度 .length

'a'.length    // 1
'我'.length    // 1
'𝄞'.length    // 2

显然𝄞是一个surrogate pair。我们来计算它的上下位

H = Math.floor((0x1D11E - 0x10000) / 0x400) + 0xD800
// 55348, 0xD834
L = (0x1D11E - 0x10000) % 0x400 + 0xDC00
// 56606, 0xDD1E

然后连接起来看看是否相等

'\uD834\uDD1E' === '𝄞' // true

也就是说𝄞的内部表示就是\uD834\uDD1E。在ES6中,这一情况得到了优化,可以简单的使用如下表示:

'\u{1D11E}' === '\uD834\uDD1E' // true
'\u{1D11E}' === '𝄞' //true

上面也可以得出,内部是surrogate的方式,而我们肉眼看到的𝄞只是浏览器在显示的时候做的工作。

回到原题

回到 flag - u的问题,.不能匹配BMP以外的字符,因为BMP以外的字符被当作了两个字符,所以以下的结果我们就能理解了

/./.test('a') // true
/a./.test('a𝄞') // true
/a../.test('a𝄞') // true
'a𝄞b'.match(/.../) // ["a𝄞"]

/./.test('𝄞') // true
/../.test('𝄞') // true
/a.b/.test('a𝄞b') // false
/a..b/.test('a𝄞b') // true
'a𝄞b'.match(/a(..)b/) // ["a𝄞b", "𝄞"]

只要把记住js内部把𝄞当作两个字符来看,就可以理解了。比如/a..b/.test('a𝄞b') 等价于`/a..b/.test('a\uD834\uDD1Eb')

但是加上了flag-u的话,情况就不一样了:

/a.b/u.test('a𝄞b')  //true
/a..b/u.test('a𝄞b') // false
'a𝄞b'.match(/a(.)b/u) // ["a𝄞b", "𝄞"]

也就是说flag-u的存在,把surrogate pair当作一个字符,可以用.来匹配了。

u 对于数量修饰符*,+,?,{m,n} 的影响

记住没有u的时候surrogate pair被当作一个两个字符,数量修饰符只被用在了低位L,这样就可以理解下面的出错内容了:

/a{2}/.test('aa') // true
/𝄞{2}/.test('𝄞') // false
/\uD834\uDD1E{2}/.test('\uD834\uDD1E\uD834\uDD1E') // false
/(\uD834\uDD1E){2}/.test('\uD834\uDD1E\uD834\uDD1E') // true
/(𝄞){2}/.test('𝄞𝄞') // true
/𝄞{2}/u.test('𝄞𝄞') // true
/\uD834\uDD1E{2}/u.test('\uD834\uDD1E\uD834\uDD1E') // true
/(𝄞){2}/u.test('𝄞𝄞') // true

其他例子

/^[𝄞]$/.test('𝄞') // false
/^[\uD834\uDD1E]$/.test('\uD834\uDD1E') // false
/^[𝄞]*$/.test('𝄞') // true
/^[𝄞]$/u.test('𝄞') // true
/^\S$/.test('𝄞') // false
/^\S+$/.test('𝄞') // true
/^\S$/u.test('𝄞') // true

flag - y

这个还出处在试验阶段,暂不考虑,有兴趣的同学可以看这里: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/RegExp/sticky

深入理解第二步 - 通配符等特殊语义的字符

通配符有总表在这里可以查看: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/RegExp 接下来进行更详细说明。

特殊意义字符

感觉理解为某些常用特殊符号的简写更方便,因为只匹配一个字符,包括:

符号 意思
\t \u0009, 水瓶 tab
\r \u000d, 换行 Carriage Return, CR
\n \u000a, 换行 Line Feed, LF
\v \u000b, 垂直 tab
\f \u000c, 换页,Form Feed
[\b] 匹配退格键,backspace,注意 /a\bc/.test('a\bc') === false 而 `/a[\b]c/.test('a\bc') === true`,也就是说\b只在[]有用。 注意要和 \b 区分,\b表示单词之间的区隔。/a\b/.test('a word') === true
\0 匹配NUL文字。这个是unicode第一个字符。不知道有啥用。/\0b/.test('a\0b') === true

通配符 - .

这个意思上可以匹配所有的1个字符,但是不包括换行符。换行符共有四个,可以在这里看到:http://www.ecma-international.org/ecma-262/5.1/#sec-7.3 包括\n(\u000A),\r(\u000D),\u2028,\u2029。

还有需要注意的就是我们之前看到的surrogate pair的问题。

通配符 - \d

这个也经常用到,匹配数字,等价于[0-9]

通配符 - \D

这个没怎么用过。意思就是匹配除了数字以外的字符,等价于[^0-9]。有点疑问是\D包含.所不匹配的四个换行符么?答案是True

/\D/.test('\u2029') //true
/\D/.test('\n') // true

通配符 - \w

这个也经常用到,表示匹配可以用作单词word的字符,包括字母数字和下划线,等价于[a-zA-Z0-0_],注意不包含经常遇到的美元符号$

通配符 - \W

\D类似,匹配\w不匹配的一切字符,当然换行符也匹配。

通配符 - '\s`

这个经常用,表示匹配空白字符,但是具体哪些空白字符我记得不准确。\s等价于[ \f\n\r\t\v​\u00a0\u1680​\u180e\u2000​-\u200a​\u2028\u2029\u202f\u205f​\u3000\ufeff],包括

空白字符 解释
\t \u0009, 水瓶 tab
\n \u000a, 换行 Line Feed, LF
\v \u000b, 垂直 tab
\f \u000c, 换页,Form Feed
\r \u000d, 换行 Carriage Return, CR
\u0020, 空格
\u00a0 No-Break Space, 即html中经常用到的  ,表示不能在此换行的空格
\u1680​ Ogham Space Mark , 这个是加拿大原住民的啥原住民的空白符号。Unified Canadian Aboriginal Syllabics
\u180e 蒙古语啥的分隔符
\u2000​-\u200a 这共有10个不同宽度的空格,具体可以在这里看到: https://en.wikipedia.org/wiki/Whitespace_character
\u2029 paragraph separator
\u202f narrow no-break space ,窄版的nbsp
\u205f​ medium mathematical space,数学公式中的空格。
\u3000 ideographic space,全角空格
\ufeff zero width non-breaking space,没有宽度的空格? 不太了解。

通配符 - \S

上述\s不能匹配的字符。

通配符 - \cX

完全没听过。X必须是a-zA-Z,匹配控制字符,比如 Control-M可以用\cM匹配。(虽然我还是没懂)

通配符 - \xhh

用16进制的数来匹配ASCII字符,比如A的编码是65, 16进制下是0x41,所以有:

/\x41/.test('A') // true
/\x411/.test('A1') // true
/\x4/.test('A1') // false
/\x4/.test('x4') // true

\x后面如果不是2位数字的话,\x会被当作x

通配符 - \uhhhh

和上面一样,用unicode来匹配,比如:

/\u0041/.test('A') // true
/\uD834\uDD1E/.test('𝄞') // true

通配符 - \u{hhhh}/u, \u{hhhhh}/u

为了解决surrogate pair问题,ES6中新增的解决方案。注意必须要有/u flag。

'\u{1D11E}' === '𝄞' // true
'\uD834\uDD1E' === '𝄞' // true
/\uD834\uDD1E/.test('𝄞') // true
/\u{1D11E}/u.test('𝄞') // true

特殊符号

有一些特殊符号如下:

特殊符号 意思
[] 字符集合,[ab]表示a或者b的字符集,[^ab]表示不是a或b的字符,[a-z]表示a到z,以此类推。
| 表示或者关系,比如 a|b表示a或者b(等价于[ab]), ab|bcabc|bcd|def等是使用方法
^ 字符串的起始位置。/^a/.test("ab cd") === true, /^c/.test("ab cd") === false。 注意前面说道的flag:/m会影响这个字符的表现。
$ ^一样,表示字符串的结束,同时注意m的flag
\b 表示单词之间的区隔,注意不要和[\b]搞混了。/\bc/.test("ab cd") === true/\s\bc/.test("ab cd") === true
\B 不含有单词间的区隔。比如/\Bab/将会只匹配前面不是单词区隔的ab"ab abab".replace(/\Bab/,() => 'c') === 'ab abc'

括号子匹配 - ()

这个表示子匹配,主要可以用来记忆位置。叫做capturing groups,貌似这个性能比较差,不建议使用。

var match = "ab cd ef".match(/([a-z])([a-z])/);
// ["ab", "a", "b"]

var match2 = "ab cd ef".match(/([a-z])([a-z])/g);
// ["ab", "cd", "ef"]

注意有g flag的时候,子匹配不会返回。

重复子匹配 - \n

比如 "abcd efcd"可以用/ab(cd) ef(cd)/匹配,但是括号里面是相同的cd,这样可以用\1来简写,如下:

/ab(cd) ef(cd)/.test("abcd efcd") // true
/ab(cd) ef\1/.test("abcd efcd") // true
var match = "abcd efcd".match(/ab(cd) ef(cd)/);
// ["abcd efcd", "cd", "cd"]
var match = "abcd efcd".match(/ab(cd) ef\1/);
// ["abcd efcd", "cd", "cd"]

注意n从1开始计数。

匹配但不记忆 - (?:x)

这个,解释稍微麻烦一点。括号中的x正则会进行匹配,但不会记忆位置。比如前面的\1的使用,我们可以在第一个cd中加上?:而让后面的引用实效。

/ab(?:cd) ef\1/.test("abcd efcd") // false

这可以用于比如为了添加|或者语句而不得不添加括号,但又不需要记住位置的场景。比如url正则中需要指明http还是https但是又不需要知道到底是哪个的时候,

"http://colla.me".match(/(http|https):\/\/(.*)/)
// ["http://colla.me", "http", "colla.me"]
"http://colla.me".match(/(?:http|https):\/\/(.*)/)
// ["http://colla.me", "colla.me"]

数量指定

重复次数的指定有:

符号 意思
* 重复0次以上,比如 /b*/.test('a') === true, /b*/.test('ab') === true
+ 重复1次以上
? 要么0次,要么1次。比如 /https?\/\//.test('https//') === true
{n} 重复n次。比如 /a{2}/.test('aa') === true
{n,} 重复n次以上(包括n次)
{n,m} 重复n到m次之间。(n 和m均包括)

数量指定 非贪婪 ?

注意,上面的数量指定时,默认是贪婪的,也就是会匹配最多的匹配,为了让其不贪婪,可以在最后加上?

"aaaa".match(/a*/) // ["aaaa"]
"aaaa".match(/a*?/) // [""]

"aaaa".match(/a+/) // ["aaaa"]
"aaaa".match(/a+?/) // ["a"]

"aaaa".match(/a?/) // ["a"]
"aaaa".match(/a??/) // [""]

"aaaa".match(/a{2,}/) // ["aaaa"]
"aaaa".match(/a{2,}?/) // ["aa"]

"aaaa".match(/a{2,3}/) // ["aaa"]
"aaaa".match(/a{2,3}?/) // ["aa"]

后缀肯定条件 - (?=y)

满足条件的字符串之后必须匹配另外一个正则才算数。比如我们想要匹配后接cdab

"ab abcd abcde".match(/abcd/) // ["abcd"]
"ab abcd abcde".match(/abcd/g) // ["abcd", "abcd"]
"ab abcd abcde".match(/ab(?=cd)/) // ["ab"]
"ab abcd abcde".match(/ab(?=cd)/g) // ["ab", "ab"]

"ab abcd abcde".replace(/ab(?=cd)/g, () => 'AB')
// "ab ABcd ABcde"

后缀否定条件 - (?!y)

满足条件的字符串之后必须不能匹配另外一个正则才算数。比如我们想要匹配后接不是cdab

"ab abcd abcde".match(/ab(?=cd)/) // ["ab"]
"ab abcd abcde".match(/ab(?!cd)/g) // ["ab"]

"ab abcd abcde".replace(/ab(?!cd)/g, () => 'AB')
// "AB abcd abcde"

RegExp.prototype.test(String)

前面演示的很多了,返回true 或者 false

String.prototype.match(RegExp)

前面也有很多例子,对象时String,对一个正则表达式进行match,将返回匹配的子字符串。这里要注意 /g的字符串。全局搜索的时候需要用/g,否则只匹配到第一个能匹配的位置就结束了。可以用来分解字符串获取特定格式下的数据。

"ab abcd abcde".match(/abcd/g) // ["abcd", "abcd"]

RegExp.prototype.exec(String)

和match是类似的关系,但是参数和对象是反过来的。exec和match有很大不同是在于当flag- /g存在的时候,exec可以一遍一遍的逐次执行。

var r = /[a-e]+/g;
var str = "ab abcd abcde";
"ab abcd abcde".match(r);
//["ab", "abcd", "abcde"]

var match = r.exec(str);
console.log(match);
console.log(r.lastIndex)
// ["ab", index: 0, input: "ab abcd abcde"]
// 2

match = r.exec(str);
console.log(match);
console.log(r.lastIndex);
// ["abcd", index: 3, input: "ab abcd abcde"]
// 7

match = r.exec(str);
console.log(match);console.log(r.lastIndex);
// ["abcde", index: 8, input: "ab abcd abcde"]
// 13

match = r.exec(str);
console.log(match);console.log(r.lastIndex)
// null
// 0

match = r.exec(str);
console.log(match);console.log(r.lastIndex)
// ["ab", index: 0, input: "ab abcd abcde"]
// 2

其中 .lastIndex是指下一次exec开始的位置。 可以看到,当最后没有匹配返回null过后,exec用重新开始了。

注意的时候 String.prototyp.match不受exec的执行阶段的影响,而且match会将lastIndex重置为0。

通过RegExp.prototyp.exec我们可以加深理解后缀条件判定的逻辑。

var r = /[a-e]+(?:\s)/g;
var str = "ab abcd abcde";
var match = r.exec(str);
console.log(match);
console.log(r.lastIndex);
// ["ab "]
// 3

var r = /[a-e]+(?=\s)/g;
match = r.exec(str);
console.log(match);
console.log(r.lastIndex);
// ["ab"]
// 2

嗯,到此基本上js正则相关的内容就整理完了,我自己也有很大的收获。感谢阅读。

@sunderls