上手:真实场景与边界

什么时候不该用正则

它在解决什么

实战那篇讲的是「停在够用」—— 那是取舍问题,再往前走一步仍然走得动,只是不划算。

这一篇讲的是更硬的两条边界:

  1. 有些结构正则根本表达不了,写多少补丁都不行。
  2. 有些写法能跑,但会在特定输入上跑到天荒地老。

第一条是能力问题,第二条是安全问题。两条都不报错。

🚨 正则表达不了「嵌套」

从 '(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 在每次构建前实跑验证, 结果是现算的,不是抄进数据里的副本。正文里的代码块只用来演示匹配过程和写法对照,不进闸门; 凡是「这个模式配这个输入得到这个结果」的断言,只存在于这里。

  1. 嵌套括号(一):否定类在内层就停住了

    调用
    "(a(b)c)".match(/\([^)]*\)/)
    结果
    ["(a(b)"]
    换成 /\(.*\)/
    ["(a(b)c)"]

    正则没有「记住还欠几个右括号」的能力,它没有计数器,也没有栈。

  2. 嵌套括号(二):换个输入,贪婪版错,否定类对

    调用
    "(a) (b)".match(/\(.*\)/)
    结果
    ["(a) (b)"]
    换成 /\([^)]*\)/
    ["(a)"]

    ⭐ 和上一条合起来看:两种写法各自在对方的输入上出错,而且没有第三种能同时对。这就是能力边界。

  3. HTML:属性值里的 > 会骗过 <[^>]+>

    调用
    "<a title=\"a>b\">x</a>".match(/<[^>]+>/)
    结果
    ["<a title=\"a>"]
    换成 /<(?:[^>"]|"[^"]*")+>/
    ["<a title=\"a>b\">"]

    每加一块补丁就多一类边界情况:注释 <!-- -->、CDATA、没闭合的标签、大小写混写的实体…

  4. CSV:引号里的逗号不是分隔符

    调用
    [..."a,\"b,c\",d".matchAll(/[^,]+/g)]
    结果
    ["a","\"b","c\"","d"]
    换成 /("[^"]*"|[^,]+)/g
    ["a","\"b,c\"","d"]

    ⚠️ 危险的地方是它不报错:你会拿到一个字段数不对的数组,然后一路往下算。