逐步解释字符串匹配正则表达式
我敢肯定,如果 Stack Overflow 的调查问开发者最害怕什么,排在第一位的肯定是正则表达式。虽然有些简单的正则表达式实现起来并不复杂,但有一个正则表达式,我可是刻意回避了十多年才最终理解的……匹配字符串文字!
字符串字面量是一种将字符串作为字符串对象加载到编程语言的方式。基本上:
const foo = "bar";
这里的字符串文字是"bar"
。
虽然通常语言会处理这个问题,但出于某些原因,你可能需要自己解析该字符串,最有可能的情况是,当你用一种语言分析另一种语言时。我上次做类似的事情是在编写一个修补 WordPress SQL 转储的工具时。
这很简单,直到你需要处理"bar \" baz"
或"bar\xa0!"
。在本文中,我们将介绍解析字符串文字不同部分的方法。
-笔记-
本文主要针对 JSON 格式的字符串进行编写,但也会探讨各种解析问题及其解决方案。当然,这并不是一份权威指南,因为存在许多不同的选项,以上只是其中的几种。
正则表达式语法是 JavaScript 中的语法。
本文中的所有正则表达式均链接到 Regex101,以帮助您解码和测试表达式。别犹豫,点击链接吧!
最简单的情况
现在我们先尝试解析一个简单的字符串,不加任何复杂的操作。我们考虑以下测试用例:
"bar"
const foo = "bar";
foo("bar", "baz");
我想写的第一件事是/".*"/
。
如你所见,.
也匹配"
,导致匹配"bar", "baz"
一次性完成。为了避免这种情况,你可以简单地使用*?
(惰性)量词来代替*
。我们来试试/".*?"/
好多了!但还不够好,原因你会在下一部分明白。想想我们真正的意图:由于我们没有定义任何转义机制,字符串实际上可以包含任何字符,除了 "
标记字符串终止的字符。
任何字符都是点.
,但你也可以使用语法创建黑名单[^]
。在这种情况下[^"]
,将匹配除 之外的任何字符"
。因此最终表达式将是:
/"[^"]*"/
你仍然会得到这个:
转义引号
转义引号有两种方法。一种是使用双引号"say ""foo"""
,另一种是使用反斜杠"say \"foo\""
。具体方法因语言而异。大多数语言都使用反斜杠,但你也能找到其他方法。我们将学习这两种方法。
双倍的
处理引号转义最简单的方法可能是将其加倍。因为这很容易想到。在字符串中,你将允许:
- 不是引号 —
[^"]
- 两句引言并列——
""
放在一起你就得到了/"([^"]|"")*"/
。
令人惊奇的是,第一次尝试就成功了!
反斜杠
让我们尝试在测试短语上运行我们之前的简单表达式。
正如您所见,它不太关心反斜杠并且它检测到两个不同的字符串。
让我们考虑一下我们希望在两个引号之间允许什么样的内容:
- “不是引号”,至少不是空引号。就像上面一样。
[^"]
- 转义引号,因此
\"
。如果将其转换为正则表达式语法,则会得到\\"
。
通常,你可以通过将不同的选项放入匹配组中来实现这一点。我们来试试吧"([^"]|\\")*"
。
哦不,它坏了。因为反斜杠确实符合[^"]
规范。所以我们实际上需要反过来写:/"(\\"|[^"])*"/
现在我们终于有所进展了。但是依赖顺序有点繁琐,而且不太安全。让我们修改一下之前的说法:
- 既没有引号,也没有反斜杠——
[^"\\]
- 转义引号 —
\\"
- 反斜杠后跟除引号之外的任何内容 —
\\[^"]
让我们尝试一下/"([^"\\]|\\"|\\[^"])*"/
这看起来不错!但是等等,这个表达式是不是有点傻?我们来分解一下:
[^"\\]
|\\"
|\\[^"]
— 这三个中的任何一个[^"\\]
|\\("|[^"])
— 分组"
并[^"]
一起[^"\\]
|\\.
— 因为"
和[^"]
一起将匹配“引号或非引号”,这意味着它们将匹配任何字符,因此它们可以被替换为.
我们的最终表达式是"([^"\\]|\\.)*"/
。
我们现在有一个功能齐全的字符串提取正则表达式!
内部语法
上面我们看到的代码保证即使字符串"
中有一些转义字符也能解析。然而,它并不能保证字符串内部的内容是有意义的。大多数字符串解析器会查找它们能识别的模式,而忽略其余部分。假设我们只处理常规的\n
,\r
或\t
:
1 — 字面意思
"say \"foo\"\nsay \"bar\!\""
2 — 使用上面的正则表达式取消引用
say \"foo\"\nsay \"bar\!\"
3 — 替换转义字符
say "foo"
say "bar\!"
请注意 是如何\!
保留 的\!
。这是 Python 的行为。如果你在 JavaScript 中这样做,它会将其替换为!
。这取决于定义:
- 你可以说
\X
除非X
找到一个模式(JavaScript 就是这样做的) - 或者
\X
不匹配任何模式,因此保持原样(Python 的逻辑) - 或者
\X
不匹配任何模式,因此是语法错误(例如 JSON 中发生的情况)
JSON 转义字符
所有语言都有一套各自的转义字符,有些非常通用,例如\0
或 ,\n
而有些则只在某些情况下存在,甚至在不同语言中含义不同。既然我们需要选择一种语言,那就先来看看JSON 提供了什么。
单个字符
很多转义字符模式实际上只有一个字符。例如,\n
which 只是到新行的映射。对于这些,你只需要存储映射并检测它。匹配它们的正则表达式是/\\(["\\\/bnrt])/
which,它允许你查看第 1 组中捕获的是哪个字符。
你可能知道,JavaScript 的String.replace()函数允许使用一个函数作为替换。它将接收匹配的组作为参数,并将其返回值用作替换。
我们将使用它来创建一个执行这些字符替换的函数。
function subSingle(string) {
const re = /\\(["\\\/bnrt])/g;
const map = {
'"': '"',
'\\': '\\',
'/': '/',
b: '\b',
n: '\n',
r: '\r',
t: '\t',
};
return string.replace(re, (_, char) => map[char]);
}
Unicode
JSON 还允许您输入转义的 Unicode 字符,例如\uf00f
。它\u
由 和 4 个十六进制字符组成。简而言之,就是/\\u([a-fA-F0-9]{4})/
。
虽然我们可以费力地将此字符串编码为 UTF-8 或 UTF-16,然后将其转换为你正在使用的语言的内部字符串对象,但标准库中可能已经有一个函数可以做到这一点。在 JavaScript 中是String.fromCodePoint(),在 Python 中是内置的chr(),而在 PHP 中则相对简单。
再次,我们将使用替换函数和正则表达式来执行此操作。
function subUnicode(string) {
const re = /\\u([a-fA-F0-9]{4})/g;
return string.replace(re, (_, hexCodePoint) => (
String.fromCodePoint(parseInt(hexCodePoint, 16))
));
}
完整的 JSON 字符串解析器
我们已经了解了解析字符串及其组成部分的不同方法,现在让我们将其应用于解析 JSON 字符串文字。
代码将分为两部分:
- 查找输入文本中的不同字符串
- 替换提取的字符串中的引用字符
这将是一个简单的 Vue 应用程序,它从 a 获取输入textarea
并输出它可以在输入中找到的所有字符串的列表。
找到字符串
JSON 字符串的一个重要变体是它们不允许使用控制字符,因此基本上\x00-\x19
禁止使用范围。这包括换行符 ( \n
) 等。让我们稍微修改一下字符串查找表达式,使其变为/"(([^\0-\x19"\\]|\\[^\0-\x19])*)"/
。它匹配:
- 非控制字符 (
\0-\x19
)、非引号 ("
) 和非反斜杠 (\\
) - 或者反斜杠 (
\\
) 后跟非控制字符 (\0-\x19
)
让我们将其转换成 JavaScript 代码:
function findStrings(string) {
const re = /"(([^\0-\x19"\\]|\\[^\0-\x19])*)"/g;
const out = [];
while ((m = re.exec(string)) !== null) {
if (m.index === re.lastIndex) {
re.lastIndex++;
}
out.push(m[1]);
}
return out;
}
该函数将简单地提取所有字符串并将它们放入数组中。
替换字符
现在该替换转义字符了。之前我们已经用两个函数实现了这个功能,但这样做很危险。例如:
- 字符串是
"\\ud83e\\udd37"
- 不加引号则变为
\\ud83e\\udd37
- 替换单个字符
\ud83e\udd37
🤷
按照预期替换 Unicode\ud83e\udd37
因此,Unicode 和单个字符必须同时替换。为了做到这一点,我们只需将之前的两个表达式合并为/\\(["\\\/bnrt]|u([a-fA-F0-9]{4}))/
。
它匹配反斜杠\\
后跟:
- 其中一个
\/bnrt
角色 - Unicode 代码点,例如
\uf00f
我们还合并 JS 代码:
function subEscapes(string) {
const re = /\\(["\\\/bnrt]|u([a-fA-F0-9]{4}))/g;
const map = {
'"': '"',
'\\': '\\',
'/': '/',
b: '\b',
n: '\n',
r: '\r',
t: '\t',
};
return string.replace(re, (_, char, hexCodePoint) => {
if (char[0] === 'u') {
return String.fromCodePoint(parseInt(hexCodePoint, 16));
} else {
return map[char];
}
})
}
您会注意到我们选择不验证转义字符。确实,如上所示,如果您\!
使用 JSON 编写,应该会得到语法错误。但是在这里,您只会得到\!
。这是为了简化代码。所有有效的 JSON 字符串都将被此代码正确解析,但无效的 JSON 字符串仍将被解析而不会出现错误。
整合
现在剩下要做的就是编写一些代码来解析输入并将其转换为输出。我们可以使用 Vue 应用轻松完成此操作。
const app = new Vue({
el: '#app',
data() {
return {
input: `const foo = "say \\"foo\\""`,
};
},
computed: {
output() {
return findStrings(this.input).map(subEscapes);
},
},
});
观看实际操作:
结论
我们从最简单的字符串匹配正则表达式开始,将其发展成为一个功能齐全的 JSON 字符串解析器。虽然过程中存在许多陷阱,但最终的代码相当简洁(大约 40 行)。这里应用的方法不仅可以构建字符串解析器,还可以用于构建任何基于正则表达式的代码,希望您能够将其应用到您的项目中!
鏂囩珷鏉ユ簮锛�https://dev.to/xowap/the-string-matching-regex-explained-step-by-step-4lkp