Stack Overflow上262,715个正则表达式问题尚未解答的问题(第二部分)
摘要
深入探讨正则表达式解析HTML的局限性,灵感来源于Stack Overflow的著名回答,讨论了形式语言理论和工业级正则表达式引擎的能力。
<p><a href="https://lobste.rs/s/9xlahj/what_262_715_regex_questions_on_stack">评论</a></p>
查看缓存全文
缓存时间: 2026/06/09 14:46
# Stack Overflow 上 262,715 个关于正则表达式的问题仍未解答(第二部分)| ian erik varatalu
来源:https://iev.ee/blog/what-262715-regex-questions-havent-answered-pt-2/
第一部分(https://iev.ee/blog/what-262715-regex-questions-havent-answered)主要讲了扩展正则运算符:补集和交集。这篇比第一篇短。我正在日夜赶工写论文,所以不再写长篇大论,而是谈谈那个著名的 HTML 问题,再吐槽一下回退逻辑,还有一些其他惊喜。
## Stack Overflow 上最著名的答案
Q1732348(https://stackoverflow.com/q/1732348)(410万次浏览):“RegEx match open tags except XHTML self-contained tags.” 提问者想要一个能匹配 `<img>` 但不能匹配 `<img />` 的正则表达式。
> 我需要匹配所有这些开始标签:`<img>`、`<br>`。但不能匹配自闭合标签:`<img />`、`<br />`。我想出了 `<([a-z]+) *[^/]*?>`。这样对吗?更重要的是,你怎么看?
被采纳的回答:XHTML
经典学术答案是它不可能。但还有更多内容:要了解如何以及为什么,让我们请教西西弗斯。
西西弗斯推着正则球向上爬向 HTML 解析器
从足够远的地方看,HTML 显得很小:左尖括号、右尖括号、一些元素名、属性在它们之间。在处理像 `<img>` 或 `<br>` 这样的片段时,正则表达式确实有效。但让我们更有野心一些,将目标设定为真正的规范兼容 HTML。
引入一些形式语言理论来理解为什么,其思想是你可以根据解析语言所需的计算能力来对语言进行排序。这对速度和内存使用、实现复杂性以及分析它们(例如安全漏洞)的难易程度都有影响。以下是实际世界看起来的大致草图。
语言层级:从底部的正则到顶部的真正 HTML,五个山峰带
是的,顶峰。如果 HTML 是一种合理的数据格式,它会位于正则之上一个层级,和 JSON、XML 一起。但那会无趣得多。
WHATWG 解析规范(https://html.spec.whatwg.org/multipage/parsing.html#parse-errors)§13.2 定义了 HTML 文档的解析器行为,“无论它们语法上是否正确”,并带有明确的解析错误恢复规则:隐式标签闭合、寄养养育、收养机构算法、基于已解析内容切换的插入模式。没有语法能描述这些,而规范就是算法,这就是为什么 HTML 位于最顶层。
这让西西弗斯陷入了更加悲催的境地。你可以写一个几千行长的正则表达式,但它甚至无法带你离开正则带。HTML 顶峰遥不可及。
但正如现实世界中的 HTML 并非看起来那样,工业级别的正则表达式也是如此。像 PCRE、.NET 和 Perl 这样的引擎远比教科书定义的正则表达式强大得多。不知从何时起,模式语言内部长出了一个编程语言。这意味着我们小故事里的英雄从来就不是西西弗斯。
伊卡洛斯在空中,臂下夹着正则球,翅膀展开,飞向 HTML 顶峰
当学术问题陈述让我们永远在山坡上滚球时,工业正则表达式给了我们翅膀:反向引用、环视、平衡组、递归。仅反向引用就超越了上下文无关(https://arxiv.org/abs/2406.18918):`(.)+\1` 匹配像 `abcabc` 这样的逐字副本,任何 CFG 都无法识别。然后借助 .NET 的平衡组,我们拥有了无界状态化内存、重复和条件分支。综合起来就是**图灵完备**。我们**有**能力到达 HTML 顶峰。写一个“正则表达式”至少能够识别 HTML 并从中提取子串是可能的。
伊卡洛斯在 ReDoS 太阳中燃烧,翅膀着火,羽毛和正则球从 HTML 顶峰掉落
但当我们飞得离 HTML 顶峰越近,算法复杂度的太阳就越热:随着能力增加,代价也增加了。使用反向引用进行匹配已经是 NP-hard 问题,递归和平衡组只会使情况更糟。覆盖足够多的规范,在某个模式中一定会有一个输入需要指数时间甚至无限长时间才能被拒绝。
所以我们正好落在了 Stack Overflow 答案所落的地方。真正的正则表达式无法匹配 HTML,即使你有一个花哨的图灵完备引擎,你也不应该尝试。
### 但 HTML 打败的远不止正则表达式
将一种接受一切的语言标准化,其代价是实现的复杂性。跨站脚本(XSS)发生在攻击者将其 HTML 或 JS 注入到另一个用户加载的页面中时。规范对畸形输入的容忍给了攻击者许多绕过技巧:解析器接受的任何形式,而清理器没有预料到,就会溜过过滤器。想象一下,这是一扇安全门,你可以插入一些精心构造的无意义信息作为凭证,然后门会说“这毫无意义”,然后默认让你进去。
几乎所有流行的 Web 平台都提供 HTML 清理功能,而且几乎所有平台在某个时候都曾出现过绕过问题。专门的清理库也不是无懈可击的。在写这篇文章时,我遇到了一个几天前才修复的问题(https://github.com/cure53/DOMPurify/releases/tag/3.4.5):Chrome 发布了一个新的 HTML 元素,而 DOMPurify 还没来得及跟上。考虑到这样一个活标准,难怪 MITRE 和 CISA 将 XSS 列为 CWE Top 25(https://cwe.mitre.org/top25/archive/2025/2025_cwe_top25.html)的 #1,2024 年和 2025 年都是如此。
CWE-79 在 2024 年和 2025 年都排名第一,这正是我想讲的下一个点。HTML 充满了难以推理的回退行为,远非正则表达式所能描述。
## 回退
这里更广泛的模式是我经常被坑到的东西。很多软件被设计成不惜一切代价隐藏错误。畸形输入得到猜测,无效语法得到回退,关键错误被扫到地毯下。换句话说,玩碎玻璃比“妈妈走进来大喊大叫”更受欢迎,后者破坏了乐趣。
整个 Web 技术就是一个很好的例子。你可能知道 JavaScript 语言有很多怪癖。但让我们看看浏览器中的 JS 正则表达式。当其解析器不能按明显的方式读取一个模式时,它会尝试另一种解读,直到成功。你会得到一个匹配和一个值,没有任何错误来表明含义与你写的不一致。
一些可能让你措手不及的例子:
`` /a\1/.test("a\x01") // true; \1 是一个控制字符 /(a)\1/.test("aa") // true; \1 是一个反向引用 /(a)\8/.test("a8") // true; \8 是字面的 "8" /a{1,1}/.test("a") // true; "a",不出意料 /a{1,1.}/.test("a{1,1x}") // true; 字面的 "a{1,1" + 任意字符 + "}" /\z/.test("z") // true; 字面的 "z",在许多其他引擎中是 "输入结束" ``
`` /[a-\w]/.test("-") // true; a-\w 无效范围,"-" 是字面量 /[a-\p{L}]/.test("-") // false; 双重打击:\p 变成 "p" 然后变成 'a-p' 范围 /a{2,1}/.test("a") // 惊喜!这一个会抛出错误 ``
引擎会在无法按原样解析你的正则表达式时静默地重写它。这在答案中也出现了。
Q30225552(https://stackoverflow.com/q/30225552):提问者粘贴了 `/([.\p{L}])/g`,被采纳的答案(49 分)宣称*“JavaScript 不支持 `\p{L}`”*。实际上它被解析为 `[.pL{}]`,`\\` 被丢弃了。
Q3617797(https://stackoverflow.com/q/3617797):最高票答案给出了 `\p{L}`,没有提及除非设置 `/u` 标志,否则它会被解析为字面的 "p{L}"。`/u` 标志本身就可以修复大多数这些问题。人们主要用它来支持 Unicode,但它还有另一个作用:它使解析器更严格,因此这些破碎的模式会抛出错误而不是被静默重写。我认为那第二部分并不为人所知,这让我非常惊讶。但该标志本身只用于一小部分模式,因此在实际中这些静默回退很少被注意到。
## 幂等性
你会期望相同的模式和相同的输入每次给出相同的结果。但这并不总是真的。
Q1520800(https://stackoverflow.com/q/1520800)。提问者的总结:*“结果应为 [true, true]”*。`.test()` 看起来像是一个谓词,但加上 `/g` 它就是一个有状态迭代器,在同一个输入上交替返回 true 和 false。
`` const re = /^a$/g; for (let i = 0; i < 6; i++) { console.log(re.test("a")); } // true, false, true, false, true, false ``
一个带 `/g` 的正则表达式存储了 `lastIndex`,即下一次搜索开始的位置。匹配后它前进到匹配之后;下一次 `test()` 从那里开始,找不到内容,重置为 0,然后再次成功。同一个正则表达式在同一个输入上交替通过/失败。它也会破坏数组方法:
`` const re = /^\d+$/g; ["1", "abc", "2", "3"].filter(s => re.test(s)); // 期望:["1", "2", "3"] // 实际:["1", "2"] ``
`String.prototype.matchAll`(ES2020)会给你一个全新的状态,没有这个问题。我怀疑这个问题曾经引发过一些挫折,所以我查了一下,果然有。这是最高票评论(133 票):
> 这就像《银河系漫游指南》中的 API 设计。“你掉进去的那个陷阱已经在规范中完美记录了几年,如果你当初肯查阅一下的话。”
而在问题本身,总结了整个类型:
> 欢迎来到 JavaScript 中 RegExp 的众多陷阱之一。它拥有我见过的最糟糕的正则处理接口之一,充满了奇怪的副作用和晦涩的注意事项。
读到这里,我处于一个奇怪的境地,试图告诉你正则表达式并不复杂或难以使用,因为如果你那样看它,它绝对如此。必要的痛苦(比如学习 `.`、`|` 或 `*` 的含义)与人为膨胀的痛苦(通过对特定正则 API 的晦涩知识、“最左贪心”匹配和“哎呀你打错了,现在这要花一年处理”)之间存在巨大差距。这也适用于其他语言,不仅仅是 JS。
## 量化的捕获组只保留最后一次迭代
这一点比较为人所知。当一个捕获组匹配多次时,它应该做什么并不立即显而易见。
Q37003623(https://stackoverflow.com/q/37003623):提问者写 `^(?:([A-Z]+),?)+\$` 用于 `"HELLO,THERE,WORLD"`,并报告*“我的正则表达式实际上只捕获了最后一个,即 'WORLD'”*。
当你将一个捕获组放在 `+`、`*` 或 `{n,m}` 下面时,每次迭代都会用下一个值覆盖该组的值,只有最后一次迭代存活。
`` "abcabc".match(/(abc)+/) // ["abcabc", "abc"] 完整匹配两个,组1只保留最后一个 "123456".match(/(\d{2})+/) // ["123456", "56"] 匹配三对,捕获中只存活一个 ``
完整匹配包含所有迭代,但捕获被用于推进引擎然后被丢弃。.NET 是个例外,每个组暴露一个 `CaptureCollection`:
`` Regex.Match("123456", @"(\d{2})+").Groups[1].Captures // ["12", "34", "56"] ``
由于这是常见的非预期行为,regex101.com(下图)会自动将其标记为警告。
regex101 警告
我想不出任何情况下你实际上只想要最后一个组,所以这可以只是一个解析错误。但这会拒绝像 `(ab)+` 这样极其常见的模式,在这些模式中你一开始就不关心捕获。
## `\b` 本身不是单词开始/结束
Q1324676(https://stackoverflow.com/q/1324676):提问者写 `\b-?\d+\b` 来匹配像 `-12` 这样的有符号整数,但它不匹配 `-`。问题在于 `\b` 并不意味着“单词开始的地方”。它标记的是单词字符与非单词字符相遇的位置。像 `1` 这样的数字算作单词字符;短横线不算。所以在 `-12` 中,边界位于 `-` 和 `1` 之间,而不是在 `-` 之前,匹配从 `1` 开始,而负号被留在了后面。
同样的陷阱在 Q1751301(https://stackoverflow.com/q/1751301),用 `\b...\b` 匹配 `S.P.E.C.T.R.E.`。名字以 `.` 结尾,这不是单词字符,所以结尾的 `\b` 无处可放,匹配失败。
`\b` 和 `\B` 只有在其旁边有一个明确的单词或非单词字符时才按预期工作;将它们与同时包含单词和非单词字符的字符集(如 `[.a]\b`)组合可能会带来很多惊喜。或者,如果你严格意思是“下一个字符是空格、短横线或结尾”,使用像 `(?=[\s-]|\z)` 这样的显式表达会更安全,尽管它比 `\b` 的“边界”含义更不易读。
还有一个区别:`\w` 在边界处并不完全是 `\W` 的反义。Unicode 规范(https://www.unicode.org/reports/tr18/#RL1.4)指出一个正确的 `\b` 应该将两个不可见字符“零宽度连接符”和“零宽度非连接符”视为单词的一部分,尽管它们都不算 `\w`。因此边界规则和简单的 `\w`/`\W` 划分并不总是一致。
深入现实世界的规范总是令人惊讶。即使是“一个单词在哪里结束”也不总是看起来那样。
## 交替优先级最低
最后一点众所周知,但足够常见,应该放在这里。
`^cat|dog$` 不同于 `^(cat|dog)$`。`|` 的优先级是正则中最低的,低于连接、重复和锚点。所以 `^cat|dog$` 实际上意味着 `(^cat)|(dog$)`:“以 cat 开头”或“以 dog 结尾”,而不是“恰好是 cat 或 dog”。
`` /^cat|dog$/.test("hotdog") // true; 以 "dog" 结尾 /^cat|dog$/.test("catfish") // true; 以 "cat" 开头 /^cat|dog$/.test("fishcat") // false ``
解决方案是将选择项分组:`^(cat|dog)$`。
Q51959733(https://stackoverflow.com/q/51959733)就是一个例子。提问者希望 `/^a*|b*$/` 表示“只有 a 和 b”,然后惊讶它匹配了 `"c"`:
`` /^a*|b*$/.test("c") // true ``
它被解析为 `(^a*)|(b*$)`,并且 `^a*` 高兴地在任何字符串的开头匹配零个 a,所以所有内容都通过了。这个 bug 在错误的分割恰好与期望匹配的时隐藏起来。`/^error|warning|info:/` 解析为 `(^error)|(warning)|(info:)`,所以中间的 `warning` 是未锚定的,它会在字符串的任何位置匹配,而不管前缀如何。这样编写的日志过滤器和路由器会静默地过度匹配。
---
第一部分讲的是主流引擎没有的运算符。这一部分讲的是它们确实有的运算符,以及它们可能并不像你期望的那样。
最后一点:在几位贡献者的帮助下,RE#(https://github.com/ieviev/resharp)中的许多长期 bug 最近已被修复。如果你想帮忙,试试看,如果在正式 1.0 发布前遇到任何问题,请告诉我。
相似文章
Stack Overflow 上 262,715 个正则表达式问题尚未解答的谜团
作者分析了 Stack Overflow 上的 262,715 个问题,以找出正则表达式的常见痛点,并展示了其新的正则表达式引擎 RE# 如何借助补集和交集运算来解决这些问题。
Regex Chess: 一个使用84,688个正则表达式的2层minimax国际象棋引擎
Nicholas Carlini 的项目使用84,688个正则表达式实现了一个2层minimax国际象棋引擎,这些表达式被顺序执行以走出合法的国际象棋走法。文章解释了一个能够解读指令的正则表达式计算机的设计。
我基准测试了AI代理读取原始HTML有多糟糕。差距比我预想的要大。
一项实验比较了AI代理在读取原始HTML与结构化格式时的准确性和代币成本;原始HTML的代币成本是两倍,准确性更低。
Stack Overflow 的论坛已经衰落,但公司仍在运转
Stack Overflow 的论坛因 ChatGPT 等 AI 的兴起而问题数量下降,但该公司通过利用 AI 自身保持了韧性。文章讨论了这一趋势并提供了数据分析。
@trq212: https://x.com/trq212/status/2052809885763747935
该文章认为,与Markdown相比,HTML是AI智能体更优越的输出格式,因为它具有更丰富的信息密度、视觉清晰度、易于分享和双向交互,并分享了作者及Claude Code团队其他成员偏爱HTML的原因。