发布于 更新于
大多数出问题的正则,只被测过一次,用的还是那条早就知道能匹配的字符串。真正有用的习惯恰好相反:先攒齐必须被拒绝的输入,观察引擎实际从哪里开始、到哪里结束,并把每一个标志都视为对模式本身的修改。这篇指南讲的就是这套流程,外加两种只在上线之后才现形的故障 —— 回溯爆炸和方言漂移。
从「必须不匹配」的字符串开始
一个正则能匹配你的正例,几乎说明不了任何问题 —— 因为这个模式本来就是盯着那条正例写出来的。区分「能上线」和「不能上线」的,是那批差一点就合法的输入:只差一个字符、会被随手写下的量词一口吞掉、或者长得像目标但并不是目标的字符串。
在动手写模式之前先把语料备好。针对每条规则写三组:必须匹配的、必须不匹配的,以及结构性边界。第三组正是最容易被跳过、也最容易藏 bug 的一组 —— 空串、单个空格、带尾随换行的值、只有分隔符的一行、中间夹着非 ASCII 字符的串,还有一条长达一万字符的输入。
把语料和代码放在一起,而不是留在浏览器标签页里。正则就是一个小型解析器,小型解析器值得拥有一个测试文件。半年后有人因为漏掉一种情况而改动模式时,正是这份语料告诉他,他刚刚顺手弄坏了另外二十种情况中的哪几种。
- 必须匹配:典型样例,外加那些标点古怪但合法的样例。
- 必须不匹配:把上面那些串删掉一个字符、加上一个字符、或替换一个字符。
- 边界情况:空串、纯空白、首尾空白、内嵌换行、非 ASCII 字符,以及远超预期长度的输入。
锚点、边界,以及什么才算「匹配上了」
最常见的正则缺陷,是本想校验整个值、结果只匹配到了一个子串。把 \d{4} 用在字符串 version 20264 上,它会找到 2026 并报告成功。如果本意是「这个字符串是且仅是一个四位年份」,那就需要锚点:^\d{4}$。
锚点和边界不是一回事。脱字符和美元符断言的是主串中的位置 —— 默认是整串的开头和结尾,加上多行标志之后则是每一行的开头和结尾。而单词边界 \b 断言的是「单词字符与非单词字符之间的过渡」。从连续文本里抽取一个词元时你需要它,但它不能替代校验场景下的锚定。
要盯着测试器给出的匹配边界看,而不只是看通过与否。会高亮匹配区间的测试器,能立刻告诉你引擎吃掉的是不是你以为的那一段。两个模式可以同样「匹配成功」却选中完全不同的区域,而这个差别要等到匹配结果被用于替换或抽取时才会现形。
主串: "version 20264"
/\d{4}/ 匹配 "2026" 这是子串匹配,不是校验
/^\d{4}$/ 不匹配 正确地拒绝了
/\b\d{4}\b/ 不匹配 20264 是一个整词,内部没有边界
主串: "a cat." /\bcat\b/ 匹配
主串: "concat" /\bcat\b/ 不匹配
主串: "a\nb" /^\w/g 匹配 "a"
/^\w/gm 匹配 "a" 和 "b"分组:捕获、命名与替换
圆括号同时干两件事,把它们混为一谈就会出麻烦。它既把子表达式组合起来、好让量词或分支作用于整体,也把匹配到的文本捕获下来供后续引用。当你只需要前一件事时,请用非捕获分组 (?:...)。它让分组编号保持稳定 —— 这意味着在模式中间插入一个分组,不会再悄无声息地把后面所有反向引用和替换串里的 $2 全部错位。
命名分组,写作 (?<year>...),彻底消除了这种脆弱性,还顺带让模式自带文档。JavaScript 从 ES2018 起支持它,多数现代引擎也都支持。替换串里就可以写 $<year>,而不是一个谁都无法一眼核对的数字。
替换要和匹配分开测。一个模式完全可能选中了正确的区间,却产出错误的输出,因为替换串有自己的语法:美元符后跟数字是反向引用,$& 是整个匹配,而字面的美元符必须写两个。如果替换用的是函数而不是字符串,各分组的值会作为参数传进来,通常比一串密集的模板更容易推理。
const pattern = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
pattern.exec("2026-09-21").groups;
// { year: "2026", month: "09", day: "21" }
"2026-09-21".replace(pattern, "$<day>/$<month>/$<year>");
// "21/09/2026"
"2026-09-21".replace(pattern, (m, y, mo, d) => d + "/" + mo + "/" + y);
// "21/09/2026"
非捕获分组让编号保持稳定:
/(?:https?):\/\/(\S+)/ 分组 1 是主机,并且一直是分组 1
/(https?):\/\/(\S+)/ 分组 1 变成了协议,主机成了分组 2标志会在不改模式的前提下改掉答案
标志是正则表达式的一部分,不是显示选项。同一个模式加不加多行标志,回答的是不同的问题;而一个标志配置与运行时不一致的测试器,会信誓旦旦地给你错误结果。在相信测试器展示的任何东西之前,先把它的标志调成和代码里一致。
全局标志尤其值得警惕,因为在 JavaScript 里它会让正则对象带上状态。带 g 的正则持有一个 lastIndex 属性,每次 test 或 exec 成功后都会前移,所以用同一个正则对同一条字符串连调三次,结果是 true、true、false。把带 g 的正则跨调用共享 —— 比如写成模块级常量 —— 会产生看起来随机、实则依赖调用顺序的 bug。
另一个值得专门理解的是 unicode 标志。不加它时,模式作用在 UTF-16 码元上,于是一个基本多文种平面之外的字符占两个码元,点号只能匹配它的一半。加上它之后,\p{Script=Han} 这类 Unicode 属性转义才可用,转义序列也会被严格校验。但它不会做的一件事是:让 \d、\w、\b 变得 Unicode 感知 —— 在 JavaScript 里无论加什么标志,这三者永远只认 ASCII,这一点常常让写中文校验的人措手不及。
每个标志都会改变结果。右侧一列是大家最常「意外发现」的那部分行为。
| 标志 | 作用 | 容易踩的坑 |
|---|---|---|
| g | 查找全部匹配而非第一个 | 通过 lastIndex 让 test() 和 exec() 带上状态 |
| i | 忽略大小写 | 不加 u 或 v 时只做 ASCII 折叠 |
| m | ^ 和 $ 在每行边界生效 | 不会改变点号能匹配什么 |
| s | 点号也匹配换行 | 不加时 /a.b/ 遇到 a 与 b 之间的换行就失败 |
| u | 按码点语义,并启用 \p{...} | 不会让 \d 或 \w 变成 Unicode 感知 |
| v | u 的超集,支持集合运算 | 不能与 u 同时使用,仅较新运行时支持 |
| y | 匹配必须恰好从 lastIndex 开始 | 忘记重置 lastIndex 时会静默失败 |
| d | 为每个分组提供起止下标 | 只影响结果对象,不影响匹配本身 |
贪婪、懒惰,以及点星为什么总是吃过头
量词默认是贪婪的:它先尽可能多地吞,然后一个字符一个字符地往回吐,直到模式的其余部分能匹配成功为止。这就是为什么 /<(.+)>/ 用在 <a><b> 上捕获到的是 a><b 而不是 a —— 点星一路跑到字符串末尾,再回退到它能找到的最后一个右尖括号,而那是最后一个,不是第一个。
在量词后加一个问号就让它变懒:尽可能少吞,被逼无奈时才扩张。同一条主串上 /<(.+?)>/ 捕获到的是 a,这通常才是本意。但懒惰量词并非普遍更优 —— 它只是朝相反方向出错:当分隔符本身可能出现在内容里时,它会停得太早。
更稳健的做法,往往是用否定字符类替换掉那个点号,而不是在两种贪婪程度之间调参。/<([^>]+)>/ 说出了你真正的意思 —— 一直跑到下一个不是右尖括号的字符为止 —— 而且它根本不需要回溯,于是既正确又快。当你开始纠结贪婪程度时,先问问那个点号是不是本来就该是一个更窄的字符类。
主串: "<a><b>"
/<(.+)>/ 捕获 "a><b" 贪婪,从末尾往回退
/<(.+?)>/ 捕获 "a" 懒惰,停在第一个 ">"
/<([^>]+)>/ 捕获 "a" 意图明确,且无法回溯
主串: 'say "hi" and "bye"'
/".*"/ 匹配 '"hi" and "bye"'
/".*?"/ 匹配 '"hi"'
/"[^"]*"/ 匹配 '"hi"' 意图最清楚回溯灾难,以及如何提前发现它
回溯型引擎 —— JavaScript、Python、Java、.NET、PCRE 等绝大多数引擎都属于这一类 —— 会一直尝试各种可能,直到某一种成功或全部失败。当一个模式允许同一段子串在它的多个量词之间有多种切分方式时,需要探索的切分数量会随输入长度指数增长。模式最终仍会停机,只是可能不在今年之内。
经典形状是量词套量词、且内层能匹配到与外层相同的字符:(a+)+、(\s*\w+)*、(\d+|\w+)+。再加上一个锚点,配一条「差一点就匹配、却在最末尾失败」的主串,引擎就必须把所有切分都试完才敢断定失败。在一台普通笔记本上,/^(a+)+$/ 对付 24 个 a 加一个 X 就已经要几百毫秒;再加十个 a,量级就跳到分钟。
只要主串来自用户,这就是一条拒绝服务路径,它甚至有专门的名字 —— ReDoS。请主动测它:拿任何含嵌套或相邻量词的模式,喂给它一长串内层量词能匹配的字符,再用一个能让整体失败的字符收尾。如果测试器卡住了,线上服务也会卡住。修法有四种:让内层分组不再有歧义、用否定字符类替代过于宽松的写法、给重复次数加上明确上限,或者干脆别用正则去做一个解析器更擅长的活。
/^(a+)+$/.test("aaaaaaaaaaaaaaaaaaaaaaaaX")
// 24 个 "a" 加 "X":大约 300 毫秒
// 34 个 "a" 加 "X":大约 5 分钟
// 每多一个 "a",工作量翻一倍
原因:"aaaa" 在两个量词之间可以切成
(aaaa) (aaa)(a) (aa)(aa) (a)(aaa) (aa)(a)(a) ...
每一种切法都必须试过,才能确定失败。
不会回溯的重写:
/^a+$/ 如果内层分组本来就没用
/^(?:a{1,32})$/ 给重复次数加上限
/^[^b]+$/ 否定字符类,只有一条路可走JavaScript 不是 PCRE
正则语法看起来可移植,实际并不。从一段 Perl 单行命令、一份 nginx 配置、一个 PHP 校验器或某个问答帖里抄来的模式,可能用到 JavaScript 根本没有对应物的构造 —— 而且报错位置常常和那个不受支持的构造完全对不上。
最常见的三种导入失败是内联标志、原子分组和占有型量词。在 JavaScript 里写 (?i)abc 会抛出「Invalid group」,而不是开启忽略大小写,这个标志应该写在字面量后面。原子分组 (?>...) 和占有型量词 a++ 是 PCRE 内建的抗回溯爆炸手段,JavaScript 干脆没有 —— 所以在 JavaScript 里缓解 ReDoS 只能靠重写模式。
反方向也有差异。JavaScript 从 ES2018 起就支持变长逆序环视,而 PCRE 传统上要求逆序环视定长。字符类语义也分道扬镳:\d、\w、\b 在 JavaScript 里永远只认 ASCII,而 PCRE 可以在编译期或运行期切到 Unicode 模式,让它们覆盖其他文字系统。每当你把一个模式搬到新环境,请把整份语料在新环境里重跑一遍,别以为过了语法检查就是过了难关。
| 构造 | JavaScript | PCRE / Perl |
|---|---|---|
| 内联标志 (?i) | SyntaxError: Invalid group | 支持,可局部也可全局 |
| 原子分组 (?>...) | 不支持 | 支持 |
| 占有型量词 a++ | 不支持 | 支持 |
| 递归 (?R) 与子程序调用 | 不支持 | 支持 |
| 带注释的扩展模式 | 不支持 | 通过 x 修饰符支持 |
| 变长逆序环视 | ES2018 起支持 | 较老版本受限 |
| 命名分组写法 | (?<name>...) | (?<name>...) 与 (?P<name>...) |
| \d \w \b 的覆盖范围 | 永远只认 ASCII | 开启 UCP 后可 Unicode 感知 |
| 主串锚点 | 只有 ^ 和 $ | 还有 \A、\z 和 \Z |
要点回顾
- 先写「必须不匹配」和边界输入,再写模式,并把这份语料放进仓库,紧挨着使用它的代码。
- 校验场景一定要用 ^ 和 $ 锚定,\b 留给从连续文本中抽取词元的场景;子串匹配不等于校验。
- 把测试器的标志调成和运行时完全一致;在 JavaScript 里绝不要跨调用共享带 g 的正则,因为 lastIndex 让它带了状态。
- 优先用否定字符类替代贪婪或懒惰的点号,它同时说清了意图、消除了回溯。
- 任何含嵌套量词的模式上线前,都喂它一长串重复字符再接一个必然失败的字符,然后盯着秒表看。