正则表达式怎么写?测试、贪婪匹配与速查表
正则写起来像密码,调起来靠反复试。其实大部分失败集中在三个原因上:模式没有锚定、量词贪婪、以及正则在坏输入上耗尽了 CPU。理解了这三点,剩下的只是查表。
- 不加锚定符的正则会在字符串任意位置匹配,所以用来「校验」时必须写 ^ 和 $,否则只校验了整个字符串的一小段
- 量词默认是贪婪的:.* 会一直吃到最后一个能匹配的位置,改为 .*? 才是最短匹配
- 点号(.)默认不匹配换行符,跨行提取要显式开启 dotall 或改用 [\s\S]
- 嵌套量词如 (a+)+ 遇到不匹配的长输入会出现灾难性回溯,耗时呈指数增长,能把服务卡死
- 不同语言的正则实现有细节差异,例如 \d 在 Python 中会匹配全角数字,在 JavaScript 中只匹配 0-9
#三个坑,占了正则调试的绝大多数
正则调试失败的常见原因就那么几个,逐个排查比反复修改模式有效得多。
| 现象 | 原因 | 改法 |
|---|---|---|
| 校验通过了,但明显是非法输入 | 没有锚定,正则只匹配到一小段 | 首尾加 ^ 和 $ |
| 只想取第一个词,结果取回整行 | 量词贪婪 | 把 .* 改成 .*? |
| 跨行提取拿不到内容 | 点号默认不匹配换行符 | 用 [\s\S]+? 或开启 dotall |
| 测试数据一跑就卡住 | 嵌套量词触发灾难性回溯 | 改写量词结构,加长度上限 |
| 同样正则在不同语言结果不同 | 方言差异(Unicode、转义规则) | 确认目标语言的引擎行为 |
#锚定:为什么「校验」必须加 ^ 和 $
正则默认的行为是「在字符串中找一段匹配的内容」,不是「整串必须完全符合」。 这两件事差别很大:
模式: \d{4}
输入: abc1234def
结果: 匹配成功(命中了 1234)如果你把这个正则当作「校验输入是 4 位数字」,那么 abc1234def 会被错误地判为合法。加上锚定符才是真正的整串校验:
模式: ^\d{4}$
输入: abc1234def
结果: 不匹配 ✓用正则做「格式校验」时,^ 和 $ 不是可选项。需要匹配内容时(比如从日志里抽取 IP)才不加锚定 —— 这两种用途必须分清,混用是校验逻辑失效的头号原因。
#贪婪与懒惰:一次说清
**量词(* + {n,m})默认尽可能多地匹配,这就是「贪婪」;在量词后加 ? 变成「懒惰」,改为尽可能少地匹配。**
用提取 HTML 标签之间的内容来对比,区别非常直观:
输入: <b>第一段</b> 中间 <b>第二段</b>
贪婪: <b>.*</b> → 命中从第一个 <b> 到最后一个 </b>,把中间内容全吃掉
懒惰: <b>.*?</b> → 命中第一个 <b>第一段</b>,符合预期判断方法:结果比你预期的「长」,就说明该换成懒惰量词。
不过要注意一个现实:用正则解析 HTML 是脆弱做法。. 不匹配换行、属性顺序变化、嵌套标签都会让模式失效。结构化格式请用专门的解析器,正则只适合抽取结构稳定的片段。
.* 在长文本上还有性能问题:它会先贪婪地吃到最后,再逐步回退(回溯)寻找匹配。文本越长,试错次数越多。对几十 KB 以上的输入,优先用更具体的字符类(如 [^"<]+)代替 .*。
#分组、捕获与反向引用
圆括号同时承担两个职责:分组和捕获。 (abc)+ 表示把 abc 作为一个整体重复;同时它也会在匹配结果里生成一个捕获组。
| 写法 | 名称 | 作用 |
|---|---|---|
(abc) | 捕获组 | 会被记录到结果里,可用 $1 或 \1 引用 |
(?:abc) | 非捕获组 | 只分组、不记录,少量提升性能、结果更干净 |
(?<name>abc) | 具名捕获 | 用名字取,比数字下标更可读 |
(?=abc) | 前瞻 | 后面必须是 abc,但不消耗字符 |
(?!abc) | 负前瞻 | 后面不能是 abc |
(?<=abc) | 后顾 | 前面必须是 abc |
一个实用的例子:把 key: value 格式的 key 换成大写,用前瞻避免吃掉分隔符:
模式: ^(\w+)(?=\s*:)
替换: ${1} 的大写形式前瞻和后顾只做「条件判断」,不消耗字符,所以在需要「只改一部分、保留另一部分」时特别有用。
#常用模式速查表
以下模式可直接复制,注意都是整串校验(带锚定符)。用 正则表达式测试 验证一遍再放进代码。
| 用途 | 模式 | 备注 | |||
|---|---|---|---|---|---|
| 中国大陆手机号 | ^1[3-9]\d{9}$ | 号段会变,宽松些可放宽为 ^1\d{10}$ | |||
| 邮箱(宽松) | ^[\w.+-]+@[\w-]+(\.[\w-]+)+$ | 邮箱的完整规范极复杂,实践中用宽松版 | |||
| IPv4 | ^(?:\d{1,3}\.){3}\d{1,3}$ | 会把 999.1.1.1 判为合法,需额外范围校验 | |||
| 日期 YYYY-MM-DD | `^\d{4}-(0[1-9]\ | 1[0-2])-(0[1-9]\ | [12]\d\ | 3[01])$` | 不校验月份天数(如 2 月 30 日会通过) |
| 时间 HH:MM | `^([01]\d\ | 2[0-3]):[0-5]\d$` | 24 小时制 | ||
| 18 位身份证 | ^\d{17}[\dXx]$ | 末位校验码需单独算法验证 | |||
| 十六进制颜色 | `^#(?:[0-9a-fA-F]{3}\ | [0-9a-fA-F]{6})$` | 支持 3 位与 6 位 | ||
| URL | ^https?://[^\s]+$ | 严格校验 URL 建议用专门解析器 |
用 查找替换 的正则模式可以批量改文本。先在测试工具里把模式调对、确认高亮范围无误,再拿去替换 —— 直接在大量文本上试错,改错了不容易回退。
有一类常见需求不必自己写正则:解析 User-Agent 字符串。UA 格式变体极多,直接用 UA 解析 会按浏览器、系统、设备类型拆好,比自己维护一套匹配规则可靠。
#性能陷阱:灾难性回溯
嵌套量词是最危险的写法,(a+)+ 这类模式在长输入上的耗时呈指数增长。 触发条件很具体:
模式: ^(a+)+$
输入: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX ← 末尾一个不匹配的字符引擎会尝试所有可能的分组切分方式,30 个 a 就能产生上亿次尝试,表现为接口卡死、CPU 打满。这个问题的学术名称是 ReDoS。
规避方法:
- 不要嵌套量词。
(a+)+改用a+即可。 - 用更具体的字符类代替宽泛的
.和\w。 - 加锚定符。不锚定的模式会尝试所有起点,代价成倍增加。
- 对输入长度设上限。用户输入超过一定长度时直接拒绝,比事后补救简单。
- 设置匹配超时。部分语言的库支持超时参数,超时即放弃。
#常见问题
#为什么同样的正则在 Python 能匹配,在 JavaScript 不行?
引擎方言不同。最典型的是 \d:JavaScript 中它只匹配 ASCII 的 0-9;Python 3 处理字符串时 \d 会匹配包括全角数字在内的 Unicode 数字。其他差异还有转义规则、\b 的边界定义、具名分组语法((?<name>) 与 (?P<name>))。写跨语言的正则时,用显式字符类([0-9])替代简写最稳。
#怎么匹配换行?
. 默认不匹配换行符。三种做法:用 [\s\S](匹配任意字符,含换行)、用 [\d\D]、或者开启 dotall 标志(各语言名称不同,Python 是 re.S,JavaScript 是 /s)。第三种最简洁,但要注意它会同时改变整个模式里所有 . 的行为。
#贪婪和懒惰能混用吗?
能,而且经常需要。典型场景是前面贪婪、后面懒惰:^.*?(\d+)$ 表示「尽量少地跳过前缀,然后抓到结尾的数字」。原则是在你能确定目标位置的地方用懒惰,在需要稳定边界的地方用贪婪。
#正则能解析 HTML 或 JSON 吗?
不能可靠地解析,只能抽取结构稳定的片段。嵌套结构(标签套标签、对象套对象)超出了正则的能力范围 —— 正则没有栈,无法匹配任意深度嵌套。抓 JSON 里的某个字段用字符串查找配合专门的解析器更稳,JSON 格式化校验 可以直接把结构展开看清层级。至于文本的行数、字数这类统计,用 文本统计 比写正则快得多。
#正向预查和普通匹配到底差在哪?
差在「是否消耗字符」。abc(?=def) 匹配 abc,但匹配结束后位置停在 def 之前;abcdef 则把 def 一起吃掉了。区别体现在替换操作上:用前瞻可以只替换 abc 而不动 def。
#怎么调试一个复杂正则?
拆开测,不要整体调。先单独验证每一段(字符类、量词、分组),用测试工具的高亮确认每段命中的范围,再拼起来。复杂模式可读性极差,用非捕获组和具名分组分段标注,比事后猜测高效得多。