郁郁青青 长过千寻

学习正则

    笔记本

    1. 捕获组
    2. 贪婪模式和惰性模式
    3. String.prototype.replace
    4. 词边界和锚点
    5. 环视
    6. RegExp.lastIndex
    7. 修饰符
    8. 字符类
    9. 编码、字符串
    10. 性能
    11. 可读性和性能
    12. 特殊的地方
    13. 匹配 URL
    14. 示例
    15. 练习
    16. TODO
    17. 其它
    18. 引用

正则很常用,或者说总是会用到,它就像躺在工具箱角落边的工具,越是生疏越要操练。有人调侃正则,“在你意识到这个问题需要正则,现在你有两个问题了”。下面是我在学习正则时的记录,如果没有特殊说明,环境是 macOS 13.0.1,语言使用 javascript,浏览器使用 Chrome 107.0.5304.110。

捕获组

关于括号、括号加量词对 match 返回结果的影响,结果中可能匹配到空字符串""或是undefined

1
2
3
4
5
6
7
8
9
10
11
'a'.match(/a(z?)(c?)/);
// ['a', '', '', index: 0, input: 'a', groups: undefined]

'a'.match(/a(z)?(c)?/);
// ['a', undefined, undefined, index: 0, input: 'a', groups: undefined]

'a'.match(/a(z)*(c)?/);
// ['a', undefined, undefined, index: 0, input: 'a', groups: undefined]

'a'.match(/a()()/);
// ['a', '', '', index: 0, input: 'a', groups: undefined]

关于反向引用\N\k<name>

  • 对之前的捕获组进行反向引用
  • 例如"He said: \"She's the one!\".".match(/(['"])(.*?)\1/g)是正确的,得到结果["\"She's the one!\""]
  • 例如"He said: \"She's the one!\".".match(/(['"])(.*?)['"]/g)是错误的,得到结果["\"She'"]
  • \k<name>中的 k 取自backReference(反向引用)中的 k。

相关链接:

贪婪模式和惰性模式

相关链接:

String.prototype.replace

第二个参数是字符串时,可以利用一些模版变量来替换。

变量名 解释
$$ 插入一个 “$”。
$& 插入匹配的子串。
$` 插入当前匹配的子串左边的内容。
$’ 插入当前匹配的子串右边的内容。
$n 插入第 n 个分组。
$<Name> 插入有名称的分组。

交换分组的单词,这里用到了修饰符g,代表会交换所有的匹配:

1
2
3
4
var re = /(\w+)\s(\w+)/g;
var str = "John Smith David Martinez";
str.replace(re, "$2, $1"); // 交换匹配到的 John Smith,再交换匹配到的 David Martinez
// 'Smith, John Martinez, David'

相关链接:

词边界和锚点

关于词边界\b

  • 词边界指的是\w旁边不是\w的位置,也就是说\b找的是字母(或数字下划线)的旁边不是字母(或数字下划线)的位置;
  • 换一种说法,词边界指的是\w\W之间的位置,\w$(末尾位置)之间的位置,\w^(开头位置)之间的位置。

下面是词边界的例子,可以观察词边界被替换成“🤔”的位置:

1
2
3
4
5
6
7
8
9
10
11
"So what do you wanna do, what's your point-of-view.".replace(/\b/g, () => "🤔");
// "🤔So🤔 🤔what🤔 🤔do🤔 🤔you🤔 🤔wanna🤔 🤔do🤔, 🤔what🤔'🤔s🤔 🤔your🤔 🤔point🤔-🤔of🤔-🤔view🤔."
// “🤔”出现在开头、字母和空格之间、字母和逗号之间,以及最后字幕和句点之间
"Oh,你好Lucy,我是David。".replace(/\b/g, () => "🤔");
// '🤔Oh🤔,你好🤔Lucy🤔,我是🤔David🤔。'
''.replace(/\b/g, '🤔');
// ''
// 空字符串没有词边界,因为没有字母数字下划线
'爱'.replace(/\b/g, '🤔');
// '爱'
// 中文没有词边界

锚点^:紧跟每一个行终止符后的位置,字符串的开头。

锚点$:紧靠每一个行终止符前的位置,字符串的末尾。

关于锚点的匹配:

1
2
3
4
5
6
var withSpace = "     begin\n between\t\n\n\n\nend   ";
var reg = /(^\s+|\s+$)/mg;
var reg2 = /^\s+/mg;
var reg3 = /\s+$/mg;
withSpace.replace(reg2, "") // 'begin\nbetween\t\nend ';
withSpace.replace(reg3, "") // ' begin\n between\nend';

观察锚点的位置:

1
2
3
var str = '666\n\n\t\n\n';
str.replace(/^/mg, '😊'); // '😊666\n😊\n😊\t\n😊\n😊'
str.replace(/$/mg, '😊'); // '666😊\n😊\n\t😊\n😊\n😊'

可以看到^被替换的位置,是紧跟每一个行终止符后的位置,或在字符串的开头。$被替换的位置,是紧靠每一个行终止符前的位置,或在字符串的末尾。

提示:锚点放在[]方括号(character class)中是无效的:

1
2
3
/[$^]/.test(''); // false
/[$]/.test(''); // false
/^$/.test(''); // true

环视

在环视中匹配字符:

1
2
3
'1234567'.match(/\d(?=\d(?!\d))/g); // ['6']
'1234567'.match(/\d(?=\d(?!\d\d))/g) // (2) ['5', '6']
'1234567'.match(/\d(?=\d(?!\d\d\d))/g) // (3) ['5', '6', '7']

上面的代码第一行的环视部分表示,要匹配的位置后面必须是一个数字字符,这个数字字符后面不能紧跟一个数字字符。这个例子的环视里又包了一层环视,但内部的环视是作用在外部环视匹配过的字符之后的。

为什么要区分前瞻断言和后瞻断言:

1
2
3
var reg = /<(?!\/)[^>]+(?<!\/)>/;
reg.test("<a>"); // true
reg.test("<a/>"); // false

上面的代码块里,可以看到第二个断言是后瞻断言,如果替换成前瞻断言,会导致成功匹配<a/>,这是因为[^>]+会匹配到/,接下来的(?<!\/)检测当前位置的后面不能是/,而当前位置的后面是>,所以匹配成功,所以这里要注意环视是和两边的字符相关的,下面是都使用前瞻断言的代码块:

1
2
var reg = /<(?!\/)[^>]+(?!\/)>/;
reg.test("<a/>"); // true

有的语言中(非 javascript),环视会导致回溯失败,有时候会引起问题,例如/(?<=(\d+))\w\1/可能不能匹配123ab12。javascript 可以使用非固定宽度的环视,ruby 会报错“invalid pattern in look-behind: /(?<=(\d+))\w\1/”,python 同样报错“re.error: look-behind requires fixed-width pattern”。

利用环视,可以实现其它语言中正则字符组的运算,下面的例子表示从字母中减去元音字母:

1
var reg = /(?![aeiou])[a-z]/;

RegExp.lastIndex

lastIndex 是正则表达式的一个可读可写的整型属性,用来指定下一次匹配的起始索引。 —— MDN

regexp.testregexp.exec每执行一次,都会重新赋值 lastIndex,这会导致每次执行testexec进行校验的时候,得到的结果不一致。另外还有一个条件是,正则表达式需要包含y或者g修饰符。

相关链接:

修饰符

下面是粘性修饰符y的例子:

1
2
3
4
5
6
7
var str = "So, get away.";
var reg = /\w+/y;
reg.lastIndex; // 0
reg.exec(str); // ['So', index: 0, input: 'So, get away.', groups: undefined]
reg.lastIndex; // 2
reg.exec(str); // null
reg.lastIndex; // 0

y的正则,在形式上很像每执行一次就把已经匹配的部分截去,并且给正则添加锚点^。体现出的效果就像是被 lastIndex 粘住了。

修饰符s:修饰符s让字符类.可以匹配包括换行\n的所有字符,否则.只能表示除换行外的所有字符。

关于修饰符u:添加了修饰符u之后,正则可以正确处理 4 字节长度的字符,也可以使用\p{...}表示 unicode 属性。

1
2
3
4
'😄'.match(/./); // 不用修饰符 u,可以看到获取到的是 4 字节字符代理对的左半部分“\uD83D”,而不是期望的“😄”
// ['\uD83D', index: 0, input: '😄', groups: undefined]
'😄'.match(/./u); // 使用修饰符 u,可以看到获取到了期望的“😄”
// ['😄', index: 0, input: '😄', groups

关于修饰符m:在多行模式m的正则里,\n$是有区别的,\n会匹配除最后一行之外的每行末尾的字符\n,而$表示每一行末尾的位置,包括最后一行,且$匹配的是无宽度的位置,而不是字符。

1
2
3
4
5
6
7
var str = `- Naruto
- Sasuke
- Sakura`;
str.match(/\n/g); // 匹配第一、第二行的末尾换行符
// ['\n', '\n']
str.match(/$/gm); // 匹配每一行的末尾位置
// ['', '', '']

相关链接:

字符类

字符类 来源 解释
\d digit
\w word 字母、数字、下划线
\s space 空格、制表符\t、换行\n
. 点表示除换行\n之外的任意字符
反向类 小写的字符类的大写形式,表示和对应的字符类互补的字符

类似[\d\D],包括大小写的字符类可以表示所有字符,[^]也可以表示所有字符,意思是“匹配除了什么都没有之外其他情况的字符”。

编码、字符串

正则表达式在 ES6 前不能处理 UTF-16 字符码点大于 65536 的部分,例如/\uD83D/.test('💩')会错误地得到true,这是因为“💩”字符被翻译成 UTF-16 的代理对是“\uD83D\uDCA9”,可以发现/\uD83D/.test('\uD83D\uDCA9')就匹配了,但“💩”在我们的视角中应该是 1 个字符才对,而语言内部不是这样理解字符的,因此我们需要告诉语言如何处理这样的字符,告诉它即将要处理的字符串是一段 UTF-16 编码方式的字符串,这就是修饰符u的作用。

加上u后,/\uD83D/u.test('💩')会正确地得到false,因为程序可以理解💩是单个字符,而不是分开的两个字符\uD83D\uDCA9

再看一个例子,“🚩”的代理对是“\uD83D”和“\uDEA9”,“💩”的代理对是“\uD83D”和“\uDCA9”:

1
2
3
4
/[🚩]/.test('💩'); // true
/[🚩]/u.test('💩'); // false
/[🚩]/.test('\uD83D'); // true
/[🚩]/u.test('\uD83D'); // false

修饰符u让 js 能正确理解 UTF-16,因为理解了编码,就导致了正则的其它功能也变得有预期了,例如.和量词{n}都能正常工作了,可以使用 unicode 属性了:

1
2
3
4
5
/^.$/.test('💩'); // false
/^.$/u.test('💩'); // true
/💩{2}/.test('💩💩'); // false
/💩{2}/u.test('💩💩'); // true
/\p{sc=Han}/u.test('好'); // true

为什么'💩'.length === 2

  • js 对于编码的处理曾经是 UCS-2,因为在 js 被创造的时候,编码方式只有 UCS-2,作为 UCS-2 的超集 UTF-16 尚未诞生,ES6 之后使用 UTF-16;
  • ES6 对于非 BMP(码点大于 65536 的字符)中的字符使用代理对的方式表示,BMP 中的字符只要两字节(16 比特)表示,非 BMP 的字符需要四字节(32 比特)表示,BMP 中保留了两个部分用于表示所有的非 BMP 字符,UCS-2 固定长,而 UTF-16 克变长;
  • 💩”就是非 BMP 中的字符,它的码点是 128169,已经超出了 BMP 的范围,js 会使用代理对表示,它的代理对是“\uD83D\uDCA9”,这可以通过 128169 计算,也可以通过'💩'.split('')得到;
  • 基于前面的事实,推测 js 的单元长度(1)是固定的 2 字节,即 UCS-2 单个字符的大小,由于 UTF-16 使用 4 字节(代理对)来表示范围更大的字符,所以长度被计算为 2。

JavaScript 中一定程度上正确获取字符串长度的方法:

1
2
3
var string = "😄😂😣😭🥵😘";
Array.from(string).length;
[...string].length;

仍然有很多 emoji 不能通过以上的方式正确获取长度,例如“👶🏻👦🏻👧🏻👨🏻👩🏻👱🏻‍♀️👱🏻👴🏻👵🏻👲🏻👳🏻‍♀️👳🏻👮🏻‍♀️👮🏻👷🏻‍♀️👷🏻💂🏻‍♀️💂🏻🕵🏻‍♀️👩🏻‍⚕️👨🏻‍⚕️👩🏻‍🌾👨🏻‍🌾👨🏻‍🌾👨🏻‍🌾👨🏻‍🌾👨🏻‍🌾👨🏻‍🌾👨🏻‍🌾👨🏻‍🌾👨🏻‍🌾👨🏻‍🌾👨🏻‍🌾👨🏻‍🌾👨🏻‍🌾👨🏻‍🌾👨🏻‍🌾”的长度应该是 33,但是不能通过以上的方法得到正确结果。

一些 js 代码示例:

1
2
3
/^.$/.test('💩'); // false
/^.$/u.test('💩'); // true
/[正则]/.test('遭遇'); // false

一些 python 代码示例:

1
2
3
4
import re
print re.search(r"[正则]", "遭遇") != None // true
print re.search(ur"[正则]", "遭遇") != None // false
print re.search(r"(正|则)", "遭遇") != None // false

码位(code points)和代理对(surrogate pairs)的转换:

1
2
3
4
5
// 码位 C 转换成代理对<H, L>
H = Math.floor((C - 0x10000) / 0x400) + 0xD800
L = (C - 0x10000) % 0x400 + 0xDC00
// 逆向转换
C = (H - 0xD800) * 0x400 + L - 0xDC00 + 0x10000

如何证明是浏览器在渲染的时候组合了 unicode 字符?

1
2
3
4
// 首先执行,屏幕出现“问号”符号
document.write('\uD834');
// 再次执行,屏幕的问号变成`\uD834\uDF06`符号(四条横杠)
document.write('\uDF06');

关于编码的两个方法:

  • 字符串变码点,codePointAt
  • 码点变字符串,fromCodePoint

检测正则支持 u 修饰符吗:

1
2
3
4
5
6
7
8
function hasRegExpU() {
try {
var pat = new RegExp(".", "u");
return true;
} catch(e) {
return false;
}
}

相关链接:

性能

由于 JavaScript 不支持占有型量词(Possessive Quantifiers),因此使用前瞻变换(lookahead transform)来模拟。

1
2
3
var reg = /^((?=(\w+))\2\s?)*$/; // 提高可读性:/^((?=(?<word>\w+))\k<word>\s?)*$/;
var str = "An input string that takes a long time or even makes this regex hang!";
reg.test(str); // false

常见导致回溯的代码:

1
2
3
// (x+x+)+y
// (a+b+|c+d+)+y // 这是安全的,因为它没有 mutually exclusive
// ^(.*?,){11}P

如果要匹配字符串开头,锚点的性能低于语言提供的“startsWith”之类方法:

1
2
/^starts/.test("starts with sugar"); // true
"starts with sugar".startsWith("starts"); // true

相关链接:

可读性和性能

提取一个多选正则会提高性能,但是降低了可读性:

1
2
var reg1 = /(this|that|these|those)/;
var reg2 = /th(is|at|ese|ose)/;

捕获组会保存匹配内容,使用非捕获组会提高性能,但是降低了可读性:

1
2
var reg1 = /(content)/;
var reg2 = /(?:content)/;

特殊的地方

如果要删除多行字符串的头尾空格,不能使用(^\s|\s$)

匹配 URL

  • 检查合法性;
  • 合并参数;
  • 匹配参数。

检查合法性:

1
var reg = 

获取 url 的每个部分:

1
2
3
4
5
6
7
8
9
10
11
12
var reg = /(([^:\/?#]+):)?(\/\/([^\/?#]+))?([^?#]*)?(\?([^#]*))?(#(.*))?/;
// 12 3 4 5 6 7 8 9
// 1 协议:
// 2 协议
// 3 //域名
// 4 域名
// 5 路径
// 6 ?参数
// 7 参数
// 8 #hash
// 9 hash
// 来源:https://www.rfc-editor.org/rfc/rfc3986#appendix-B

获取 url 的参数(query string):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 使用正则
function qs(url, key) {
var reg = new RegExp(`[?&]${key}=([^&#]*)`);
return url.match(reg)[1];
}

// 不使用正则
function qs(url, key) {
var qIndex = url.indexOf('?');
var qs = url.slice(qIndex + 1);
var pairs = qs.split('&');
var obj = {};
for (pair of pairs) {
var [key, val] = pair.split('=');
obj[key] = val;
}
return obj[key];
}

URL 的各种形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 用 HTTP 协议访问 Web 服务器
var url = "http://user:password@www.a.com:90/dir/file1.htm";

// 用 FTP 协议下载和上传文件
var url = "ftp://user:password@ftp.a.com:21/dir/file1.html";

// 读取客户端计算机本地文件
var url = "file://localhost/C:/path/file1.zip";

// 发送电子邮件
var url = "mailto:tone@a.com";

// 阅读新闻组的文章
var url = "news:comp.protocols.tcp-ip";

相关链接:

示例

相关链接:

练习

检查是否质数(质数是除了 1 和本身,不能被其它数整除的数(没有 1 和本身以外的其它因数)):

1
2
3
4
5
function numberSticks(n) {
return new Array(n).fill(1).join(''); // 5->11111, 3->111
}
var reg = /^(11+)\1+$/;
reg.test(numberSticks(69)); // 69 是质数吗,返回 true 则不是

24 小时制:

1
2
3
var reg1 = /[0-1]\d|2[0-3]/; // 方法 1
reg1.test('9'); // false
var reg2 = /[0-1][0-9]|2[0-3]/; // 方法 2

千分位:

1
2
3
var reg = /(?!^)(?=(\d{3})+$)/g; // (?!) 表示否定前瞻断言,(?=)表示前瞻断言
"123456789".replace(reg, ',')
// 123,456,789

URL query 参数获取:

1
2
3
4
5
6
7
8
function query(name)
{
const search = location.search.substr(1) // 跳过头部符号“?”
const reg = new RegExp(`(^|&)${name}=([^&]*)(&|$)`, 'i')
const res = search.match(reg)
if (res === null) { return null }
return res[2] // res 是类数组,有可枚举属性 groups, index, input 以及数字下标,某个数字下标 i 的值是第 i 对括号匹配的字符串,数字下标 0 正则匹配的字符串
}

删除头尾空格:

1
2
function zTrim(z)
{ return z.replace(/^\s+|\s+$/gm, '') } // 多行全局匹配 gm

补全非自闭合标签:

1
2
3
4
5
6
7
const tags = /^(area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)$/i; // 这些是自闭合标签
function convert(html)
{
return html.replace(/(<(\w+)[^>]*?)\/>/g,
(all, front, tag) => (tags.test(tag) ? all : front + "></" + tag + ">")
); // 这里的“*?”,表示遇到“>”匹配失败,遇到其它则忽略
}

中线转驼峰:

1
2
3
4
5
6
7
8
9
10
function style(element, name, value)
{
name = name.replace(/-([a-z])/ig,
(all, letter) => (letter.toUpperCase())
);
if (typeof value !== 'undefined') {
element.style[name] = value;
}
return element.style[name];
}

把带符号的号码转换成数字:

1
2
3
4
var str = "+7(903)-123-45-67";
str.replace(/\D/g, '');
// 或者
str.match(/\d/g).join('');

提取字符串里的中文:

1
2
var str = "你好吗,David?";
str.match(/\p{sc=Han}/gu); // ['你', '好', '吗']

匹配空字符串:

1
2
3
// 锚点表示位置,对于空字符串,开头就是结尾,结尾就是开头
/^$/.test(''); // true
/$^/.test(''); // true

查找时间,从给定的字符串中找到时间:

1
2
3
4
// https://zh.javascript.info/task/find-time-hh-mm
var str = "Breakfast at 09:00 in the room 123:456.";
str.match(/\b(?:[01]\d|2[0-4]):[0-5]\d\b/g); // 注意前后的词边界,用于确保时和分是两位的格式,“?:”表示非捕获组
// ['09:00']

URL 转对象(对象转 URL,压缩 URL)

  • 检查正则正确性;
  • 默认对象;
  • 重复参数的数组对象。

TODO

1
2
3
4
5
6
7
// var X = '[a-z0-9]'
// var y = '[a-z0-9-]{0,61}'
// var Z = 'a-z_\/0-9\-#'

// var a = /(https?:\/\/(?:X(?:YX)?\.)+XYX)(:?\d*)\/?([Z.]*)\??([Z=&]*)/g

var a = /^((http[s]?|ftp):\/)?\/?([^:\/\s]+)((\/\w+)*\/)([\w\-\.]+[^#?\s]+)(.*)?(#[\w\-]+)?$/

url 参数转对象

非贪婪匹配,?,例如/.+?\s/.exec("Your time is limited.")匹配“Your ”

其它

关于回车换行:历史上使用机械打字机打字,添加字符就向后移动打印头(carriage),到了行末就必须把打印头移到开头,于是进行“回车”,回车后纸张也要向上移动,防止新字重叠,于是“换行”。

双引号会消耗斜杠,因为双引号中斜杠是转义符号,是有特殊意义的,同样在正则字面量中的斜杠也是有特殊意义的,因此从正则字面量/.../转向用new RegExp("...")来表示正则,其中的斜杠要转义两次。

1
2
3
4
5
6
var reg = new RegExp("\\\\"); // 相当于正则字面量 /\\/,也就是一个斜杠字符
var str = "\\";
reg.test(str);
// true
str.length;
// 1

正则相关的方法:

  • 字符串——search、match、replace、split;
  • 正则——exec、test。

引用

资料:

书籍:

相关链接:

页阅读量:  ・  站访问量:  ・  站访客数: