上手:真实场景与边界
什么时候不该用正则
它在解决什么
实战那篇讲的是「停在够用」—— 那是取舍问题,再往前走一步仍然走得动,只是不划算。
这一篇讲的是更硬的两条边界:
- 有些结构正则根本表达不了,写多少补丁都不行。
- 有些写法能跑,但会在特定输入上跑到天荒地老。
第一条是能力问题,第二条是安全问题。两条都不报错。
🚨 正则表达不了「嵌套」
从 '(a(b)c)' 里取出完整的括号内容。两种自然的写法:
/\([^)]*\)/ 取到 '(a(b)'——「不是 ) 的字符」
碰到内层那个 ) 就收工了,丢掉了「外层还欠一个」这件事。
那用贪婪的 /\(.*\)/ 呢?在这个输入上它是对的。但换一个输入:
/\(.*\)/ 对 '(a) (b)' 取到 '(a) (b)'——
它把两组互不相干的括号并成了一个。而这个输入上,否定类版本才是对的。
⭐ 两种写法各自在对方的输入上出错,而且没有第三种能同时对。 这不是「我没写对」,是写不出来——正则没有计数器,也没有栈, 它记不住「还欠几个右括号」。
能被正则描述的东西有一个明确的范围。括号配对、HTML 标签嵌套、 JSON 的对象层级,全都在这个范围之外。
📌 有些引擎(PCRE、.NET)提供了递归或平衡组的扩展来突破这一点, 但JavaScript 的正则没有。在 JS 里遇到嵌套,答案就是别用正则。
HTML 是同一个问题的最著名版本
/<[^>]+>/ 对 '<a title="a>b">x</a>' 取到 '<a title="a>'——
属性值里那个 > 被当成了标签结束。
你可以打个补丁,让它认识引号(那条示例的对照行就是),
这一个输入就修好了。然后你会遇到:注释 <!-- <a> --> 里的假标签、
CDATA、没闭合的 <br>、大小写混写、属性值用单引号的、实体编码的……
每块补丁修一类,而边界情况的数量不收敛。用 DOM 解析器,一行就够了:
new DOMParser().parseFromString(html, 'text/html').querySelectorAll('a')
⚠️ 正则在 HTML 上有一个正当用途:在你完全控制输入格式的场景下 做粗加工,比如处理自己生成的、格式固定的模板片段。 判据是「这份 HTML 的产生方式我说了算吗」——不是,就别用。
CSV:能打补丁,但补不完
/[^,]+/g 切 'a,"b,c",d' 得到 4 项,
而正确答案是 3 项——引号里那个逗号不是分隔符。
这个也能打补丁(("[^"]*"|[^,]+) 对这一行就是对的),然后你会遇到
字段内含换行、双写引号 "" 表示一个引号、BOM、不同的分隔符……
⚠️ CSV 这类失败特别阴,因为它不报错:你拿到一个字段数不对的数组, 然后一路往下算,错误在三个函数之后才以某种完全无关的形式冒出来。
🚨 灾难性回溯:一条正则能让服务停摆
这是第二类边界,也是更危险的那一类——因为模式本身看起来完全正常。
/^(a+)+$/
对合法输入(全是 a)它瞬间返回。但喂给它 aaa…aab(末尾一个不匹配的字符),
引擎必须把「这些 a 怎么分组」的所有可能都试一遍才能确认失败,
而那个可能性的数量是 2ⁿ。
成因是嵌套的量词:外层的 + 和内层的 + 对同一段文本有多种分法,
引擎不知道哪种对,只能穷举。同族的还有 (a|a)*、(\w+\s?)*——
共同点是同一段输入有多种被匹配的方式。
这在真实系统里是一类安全漏洞,叫 ReDoS(正则表达式拒绝服务): 一个用户提交的字符串,让你的服务器一个核跑满几秒钟。 Cloudflare 2019 年那次全球中断就是这么来的。
实测:断言增长的形状,不是秒数
本站的这条结论有一道单独的闸门在发布前跑(npm run test:redos),
它的判据值得说一说:
一个绝对耗时都不断言。
因为绝对耗时不可靠——同一份代码在不同机器上能差两个数量级,甚至给出相反结论。 闸门断言的是两个与机器无关的量:
| 判据 | 内容 |
|---|---|
| 增长的形状 | 输入从 24 个字符加到 28 个,耗时应该涨约 2⁴ = 16 倍 |
| 同机加速比 | 同一台机器上,安全改写版处理同一输入要快几个数量级 |
指数增长是算法性质,跑在什么机器上都成立;秒数是机器性质。 闸门还额外验一条:安全改写版和原版必须语义等价—— 否则「改写后更快」是句废话,一个什么都不匹配的模式当然飞快。
怎么避免回溯爆炸
1. 用否定字符类替掉 .。 实战那篇
推荐过 [^\]]+ 而不是 .+?,那里的理由是「更准」,这里是第二个理由:
它不需要回溯。「不是 ] 的字符」是确定的,没有第二种分法。
2. 消除歧义。 问一句「这段输入有没有第二种被匹配的方式」。
(a+)+ 有无数种,a+ 只有一种。(\w+\s?)* 有多种,[\w\s]* 只有一种。
3. 限制输入长度。 指数增长的可怕之处在于它涨得快,
但反过来说 n 小就不可怕。校验前先 if (s.length > 200) return false,
成本一行,挡掉绝大多数攻击面。
4. 别把用户输入当模式。 new RegExp(userInput) 是直接把控制权交出去。
判断清单
动手写正则前过一遍:
| 问题 | 如果是 |
|---|---|
| 要处理的结构会嵌套吗? | 别用正则,用解析器 |
| 这是一种有标准的格式(HTML/JSON/CSV/URL)吗? | 用现成的解析库 |
模式里有嵌套的量词吗((x+)+、(x*)*)? |
重写,否则是 ReDoS |
| 同一段输入有多种匹配方式吗? | 消除歧义 |
| 输入长度有上限吗? | 没有就加一个 |
| 模式本身来自用户吗? | 绝对不行 |
| 我需要的是「找到它」还是「判断它对不对」? | 后者交给代码 |
最后一行是实战那篇的结论, 放在这里作收尾正合适:正则是一把好用的镊子,不是一台机床。 它擅长从一堆文本里精确地夹出你要的那一小块; 一旦你开始用它表达结构、状态或者判断逻辑,就是在用错工具。
下一步
《方言差异:JS / Python / grep》——全书最后一篇。 全书正文用的都是 JavaScript,而「从网上抄来的正则粘进别的语言就不工作」 是这套教程的目标读者最常撞的一堵墙。
本篇示例
下面每一条都由 npm run test:regex 在每次构建前实跑验证, 结果是现算的,不是抄进数据里的副本。正文里的代码块只用来演示匹配过程和写法对照,不进闸门; 凡是「这个模式配这个输入得到这个结果」的断言,只存在于这里。
嵌套括号(一):否定类在内层就停住了
- 调用
"(a(b)c)".match(/\([^)]*\)/)- 结果
["(a(b)"]- 换成
/\(.*\)/ ["(a(b)c)"]
正则没有「记住还欠几个右括号」的能力,它没有计数器,也没有栈。
嵌套括号(二):换个输入,贪婪版错,否定类对
- 调用
"(a) (b)".match(/\(.*\)/)- 结果
["(a) (b)"]- 换成
/\([^)]*\)/ ["(a)"]
⭐ 和上一条合起来看:两种写法各自在对方的输入上出错,而且没有第三种能同时对。这就是能力边界。
HTML:属性值里的
>会骗过<[^>]+>- 调用
"<a title=\"a>b\">x</a>".match(/<[^>]+>/)- 结果
["<a title=\"a>"]- 换成
/<(?:[^>"]|"[^"]*")+>/ ["<a title=\"a>b\">"]
每加一块补丁就多一类边界情况:注释
<!-- -->、CDATA、没闭合的标签、大小写混写的实体…CSV:引号里的逗号不是分隔符
- 调用
[..."a,\"b,c\",d".matchAll(/[^,]+/g)]- 结果
["a","\"b","c\"","d"]- 换成
/("[^"]*"|[^,]+)/g ["a","\"b,c\"","d"]
⚠️ 危险的地方是它不报错:你会拿到一个字段数不对的数组,然后一路往下算。