WebGL Tutorial
and more

ASCII转义字符

撰写时间:2024-03-01

修订时间:2024-03-03

正则表达式专门与字符打交道,但字符本身有时并非眼见为实。本章将深入探讨在使用正则表达式中一些不易觉察但又不得不知晓的问题。

具体来讲,就是ASCII转义字符会在多个方面给我们增添一些意料之外的麻烦。

创建RegExp实例的两种方式

有两种方式来创建RegExp的实例。

第一种方式是通过字符串作为RegExp的构造参数来创建。

let pattern = `\d`; let re = new RegExp(pattern, 'g');

第二种是通过RegExp字面符的方式来创建。

let re = /\d/g;

这两种方式有区别吗?

有。而且有一个大大的陷阱在等着我们。

第一种方式无法匹配数字。

let pattern = `\d`; let re = new RegExp(pattern, 'g'); console.log(re.test(`35`)); // false

而第二种方式可以匹配数字。

let re = /\d/g; console.log(re.test(`35`)); // true

问题出在哪里?

查看两种方式的RegExp实例内部细节

我们来看两种方式所创建的RegExp实例的内部细节。

第一种方式:

let pattern = `\d`; let re = new RegExp(pattern, 'g'); console.dir(re);

终端显示:

/d/g source: "d" ...

很幸运,我们一下子就发现了问题的根源所在。第一行,pattern的内容\d不知何故,变成了d。且第二行re的属性source的值也变成了d。这种模式,当然只能匹配字符d,却不能匹配数字。

继续看能匹配数字的第二种方式。

let re = /\d/g; console.dir(re);

终端显示:

/\d/g source: "\\d" ...

第一行,pattern的内容是\d,正确。第二行,re的属性source的值是\\d。有点奇怪。

问题所在的根源不在于正则表达式,而在于JavaScript的语法分析上,更确切地说,在于JavaScript对字符串字面值的语法分析上。

JavaScript对特殊ASCII字符的转义

ASCII码表中存在一些特殊字符,若要在字符串中使用它们,则须经过转义后才能使用。

最常见的是在字符串中使用单引号'及双引号"的问题。

let str = "He said: \"Hello\".";

上面,我们需要在字符串中使用双引号"。而字符串又是以双引号"围括起来为特征的。这样便给编译器带来了编译上的困难。为帮助编译器,我们需在字符串中的双引号"的前面插入转义符\,从而变成\"以对其进行转义。这样编译器就可知道,该双引号不是字符串边界的标志,从而就能正确地进行语法分析。

另外一种情况是在字符串中需要使用不可打印的字符。例如,如何在字符串表示换行符?当然我们可以使用Unicode的code point来唯一标识它,但太不方便了。为此,ASCII码表专门规定可用\n来代表换行符。这样我们就可很便捷地将其置于字符串中了:

let str = "First line\nSecond line";

JavaScript的语法能自动识别字符串字面符中的以下ASCII转义字符:

表达式含义
\0null字符
\'单引号
\"双引号
\\反斜线
\n新行
\r回车
\v垂直制表
\tTAB
\b回删
\fform feed

这意味着,如果某个字符串变量的字面符值包含了上面的字符,则可以安全地再次访问,或称,安全取回。

let pattern = 'a\nb'; for (let char of pattern) { console.log(char.codePointAt(0)); // 97 -> a // 10 -> \n // 98 -> b }

共3个字符,通过列举其Unicode的码位,或称ASCII值,我们确认,变量pattern的值确实如我所愿。

但这次,我们给它夹带了点私货:

let pattern = `\d`;

这下,编译器犯难了,这是需要转义的字符吗?只有上表中的才需要转义啊,你不懂查表的吗?算了,再跟你争,都有辱我的英名。你不是要转义吗?行,我就将转义符\之后的字符d当作转义结果,原封不动地返回给你。

现在,轮到我们验货了:

console.log(pattern.length); // 1 console.log(pattern); // d

变量pattern的内容只有1个,即d。而原来的\经编译器自行转义后消失得无影无踪了。

简而言之,在解析字符串的字面符时,JavaScript会自主地进行转义。而如果转义符不在上表之列,则转义符\会经转义后自动消失。

实际上,这不是JavaScript编译器单独存在的问题,而是所有编译器都面临的问题。

因此,如果我们使用pattern这个字符串来创建一个RegExp的实例,并用其进行匹配,则不会匹配到数字。

const src = `35`; let pattern = '\d'; // parsed by compiler to be 'd' console.log(new RegExp(pattern).test(src)); // false

解决的方法是,在使用字符串字面符时,需再加上一个转义符号\。如:

let pattern = `\\d`;

这样,JavaScript编译器先遇到了\\,一查表,在表中,立即将其转义为\,然后再与d合并,结果就成了\d,然后再将其结果返回给我们。

确认一下:

let pattern = `\\d`; console.log(pattern); // "\d"

从HTML页面中读取字符串创建

我们上面讨论了字符串字面符中的转义字符的情况。而如果字符串内容不是来源于字面符,而是来源于其他方面,例如,从网页的标签元素中读取。此时,又会发生什么情况?

未经解析的安全读取

在本网页的下面,有一个div,其HTML代码为:

let pattern=`\d`;

显示效果:

let pattern=`\d`;

如果读取其textContent属性值,内容是安全的。代码如下:

let div = document.getElementById('test-1'); console.log(div.textContent);

所得到的结果是整个字符串:

let pattern = `\d`;

而不仅仅是字符串的字面符。这样,JavaScript对其文本内容就不作任何解析,直接读取,从而在整个字符串保留了原来的转义字符。

另外的坑

发现问题

但是,如果要将从HTML页面上读取标签元素的值,作为文本输入源来使用,则又是另一番情景了。

在本页面的下面,有一个div,其HTML代码为:

let src = `First\nSecond`;

网页显示效果:

let src = `First\nSecond`;

其中有1个换行符\n。现在,我们需要提取上面的内容作为文件输入源。我们使用下面的正则表达式代码来达到目的:

let div = document.getElementById('test-2'); let textContent = div.textContent; console.log(textContent);

终端显示:

let src = `First\nSecond`;

发现问题了吗?在上面的字符串中,明明有一个换行符\n,但在终端中为何不换行?

我们另外声明一个变量,其内容完全来自上面的内容:

let tempStr = `let src = "First\nSecond"`; console.log(tempStr);

终端显示:

let src = "First Second"

上面果真换行了。说明textContenttempStr的内容还真不一样。

原因是,与上节中JavaScript会对字面符的值自动转义不一样,textContent属性虽会如实地取出文本,但由于文本内容中出现了转义字符\n,它就急痒痒了,但用户又没要求它进行解析,它必须如实地将\n视为\n这2个字符,而不是1个转义字符而予以转义。而要如实地显示这两个字符,它必须要将第1个字符\转义为普通字符,因此就在其前面插入转义字符\,从而将\n变成了\\n。这样,变量textContent确切内容为:

let src = `First\\nSecond`;

而当终端打印其值时,对于\\n,前面的\\ASCII的转义字符,经转义后得到\这个字符。此时虽然其后也跟着1个n,但已经变成2个字符,不再是ASCII的换行的转义符。因此在输出结果中不会换行。

连锁反应

我们为何要纠结这个问题?

原因是,如果我们不明就里,直接将其当作文本输入源,将导致莫名其妙的错误。

let div = document.getElementById('test-2'); let textContent = div.textContent; let re = /let src = `(.+)`/g; let result = re.exec(textContent); let str = result[1]; console.log(str); // First\nSecond let src = str; let newRe = /\n/g; let newResult = newRe.exec(src); console.dir(newResult); // Bang!

上面的代码,将导致newResult的值是null。原因是文本输入源中只有\n这两个字符,并没有\n这个换行符。而上面的代码却准备匹配换行符,当然不能匹配成功。

如何及早发现问题

在上面,我们有2个环节可以及时发现此问题。第一是在查看textContent的值时,不要使用console.log方法,而是使用console.dir方法:

let div = document.getElementById('test-2'); let textContent = div.textContent; console.dir(textContent);

终端显示:

"let src = `First\\nSecond`;"

第二个环节是在调用reexec方法后,可及时查看其结果内部状态:

let div = document.getElementById('test-2'); let textContent = div.textContent; let re = /let src = `(.+)`/g; let result = re.exec(textContent); console.dir(result);

终端显示:

Array (2) 0 "let src = `First\\nSecond`" 1 "First\\nSecond"

如何有效解决该类问题

解决此类问题的核心思路是,将2个字符替换为1个ASCII转义字符。由于我们需要根据第2个字符来决定单独的ASCII转义字符,因此,我们需要调用String类的有回调函数版本的replace方法。

let div = document.getElementById('test-2'); let textContent = div.textContent; let re = /let src = `(.+)`/g; let temp = re.exec(textContent)[1]; temp = temp.replace(/\\([a-z])/g, replacer); console.log(temp); function replacer(matched, p1) { switch (p1) { case 'n': return '\n'; case 'r': return '\r'; case 't': return '\t'; case 'v': return '\v'; case 'b': return '\b'; case 'f': return '\f'; } } let result = /\s/g.exec(temp); console.log(result);

上面,temp的值将变为:

First\nSecond

已经转义为真正的\n转义字符。因此,在其后的匹配空白符的代码中,就会匹配到该字符。

在终端中显示result的结果如下:

["↵"] (1)

匹配成功。

使用正则表达式字面符

从上面的分析得知,正则表达式的字面符可安全地直接使用各种转义字符。

let re = /\d/g; console.dir(re);

終端显示:

/\d/g source: "\\d" ...

这里,resource属性值为\\d,却不是上面所谈到的JavaScript语法解析的结果。其机制稍微不点不同。首先,它看到了正则表达式的字面符:

let re = /\d/g;

现在轮到专业人士RegExp来对此进行解析,它当然懂得pattern\d是需要匹配数字的转义字符。但它还有个职责,需要将此pattern的字符串表现形式写进其source属性中。虽然它并不知道调用者将如何使用该数值,但它知道若在使用时,则必定经过JavaScript的语法解析。因此,它很贴心,帮我们在其前面自动加上了一个转义符\而成为\\d。这样,不管以后如何使用该属性值,经JavaScript语法解析并进行转义后,其结果必定与我们原来所指定的\d一模一样。

RegExp字面符转义表

分为两部分,一是需要转义,二是不需要转义。

正则表达式元字符

语法元字符

^ $ \ . * + ? ( ) [ ] { } |

ASCII控制转义元字符

f n r t v

字符类型转义元字符

d D s S w W

界限转义元字符

b

字面符界限元字符

/

标志开关元字符

d g i m s u v y

需转义的字符

字符出处转义isChecked?
/正则表达式字面符的边界\/
?0或1、非捕获的分组、\?
(分组\(
)分组\)
|逻辑或\|
:非捕获的分组\:
[范围\[
]范围\]
-连续递进的范围\-
\转义符号\\
^行首\^
$行尾\$
=出现断言\=
!没有出现断言\!
*0或多\*
+1或多\+
{数量界限符\{
}数量界限符\}
,数量间隔符\,
.任意字符\.

无需转义的字符

字符出处isChecked?
<后方断言
'ASCII转义字符

本章小结

在创建RegExp实例时,尽量使用正则表达式的字面符,否则,应时刻提醒自己,我们所提供的字符串字面符的数值还要先经过JavaScript编译器的解析,才能送至正则表达式处理。但由于编译器可能要吃掉我们的转义字符\,我们必须对该字符先进行转义。

而如果字符串的值不是来自字符串字面符值,那么,编译器可能在后台已经悄悄帮我们对\这个特殊的字符进行了转义,其结果将只有纯粹的普通字符而没有我们原来所期待的转义字符。如果将该值作为文本输入源,我们可能需要将其重新转义为特定的转义字符之后,才能正确地应用正则表达式匹配。

在这里,我们发现了商机,即应有一个工具类,能全面、智能地帮我们自动应对这些复杂的情况。

参考资源

  1. ECMA 262: RegExp Objects
  2. MDN: Regular expressions