2026年8月9日
正则表达式实践指南:常用模式、捕获组与回溯陷阱
每个开发者都写过正则,但几乎没人正经学过。结果可想而知:模式从旧代码里抄来,贴进生产环境,跑得好好的——直到某天输入数据换了个长相,要么全部匹配失败,要么更糟,页面彻底失去响应:一个嵌套量词就能把正则变成针对你自己服务的拒绝服务武器。
这篇指南走实战路线。我们把日常开发真正需要的那些模式搭一遍,学会读捕获组而不是只盯着”匹配/不匹配”,搞清楚回溯到底在干什么(用实测数据说话,不空谈),最后落在一个”先测试再上线”的工作流上。文中所有示例都跑在 JavaScript 的正则引擎上——也就是你浏览器和 Node 里用的那一个。
从结构入手,而不是背速查表#
正则本质上是一门极其紧凑的语言写就的小程序。任何模式都由四类零件构成,能叫出它们的名字,任何模式都能读得懂:
- 字面量:匹配它自己,
error就匹配error。 - 字符类:从一组字符里匹配一个。
[0-9]是数字,\d是它的简写,\w是单词字符,\s是空白;加脱字符表示取反,[^"]是”除引号外的任意字符”。 - 量词:重复前一项。
*零次或多次,+一次或多次,?可选,{2,4}两到四次。 - 锚点与分组:管”在哪匹配”和”匹配什么”。
^和$把匹配钉在字符串的开头和结尾,(...)把内容分组并记住匹配了什么,(?:...)只分组不记忆。
关于锚点有两个高频翻车点。第一,^ 不是”非”——放在字符类里才是取反([^a]),在外面表示”行首”。第二,不加锚点时正则在任意位置匹配:/\d{4}/ 找到第一段连续四位数字就收工,而 /^\d{4}$/ 要求整个字符串恰好是四个数字。大部分”我的正则匹配太多了”是缺锚点;大部分”怎么都匹配不上”是锚点加多了。
还有一个值得尽早内化的概念:量词默认贪婪——<(\w+)>.*<\/\1> 里的 .* 会先吞掉尽可能多的字符,再一个一个往回吐,直到模式剩余部分能匹配上。在量词后加 ?(.*?、.+?)变成懒惰模式:先匹配尽可能少,不够再扩。对 <a>one</a> <b>two</b> 这个字符串,贪婪版从第一个 <a> 一路吃到最后一个形似 </a> 的闭合标签,懒惰版则分别停在每个标签对上。贪婪没有好坏,它只是一个方向——你要始终知道自己写的模式朝哪个方向走。
真正会反复用到的模式#
你不需要五十个模式,你大概需要六个,而且要理解到能在现实不配合时动手修改的程度。
**够用的邮箱校验。**完全符合 RFC 5322 的邮箱校验出了名地庞大到离谱,而且毫无意义——检验地址的唯一真实手段是给它发邮件。你要的是一个能拦住低级笔误的廉价筛查:
^[\w.+-]+@[\w-]+\.[\w.]+$
它接受 [email protected](+tag 后缀是合法地址,大量用于收件过滤),拒绝 user@example。但注意它做不到的事:它不能证明域名存在。用它拦截手滑,别用它做权限门槛。
**日期等定宽字段。**这个形态的好处是分组直接把零件拆给你:
(\d{4})-(\d{2})-(\d{2})
对 deploy 2026-08-09 ok 匹配到 2026-08-09,第 1 组是 2026,第 2 组是 08,第 3 组是 09。做替换时用 $3/$2/$1 就能把 ISO 格式转成”日/月/年”。锚点要有意识地取舍:解析日志要裸模式,校验表单输入要 ^...$。
从查询串里抠键值对。
(\w+)=(\S+)
配 g 标志跑 page=3&size=20&sort=name,得到三个匹配,键在第 1 组、值在第 2 组。它故意写得很宽松——真要解析查询串,语言自带的 URL 解析库永远比正则靠谱;但要从一行日志里快速抠数据,这个严谨度刚刚好。
**有边界的 URL 匹配。**网上流传的 URL 正则要么太宽,要么复杂到灾难。对纯文本里格式规整的链接,一个合理的中间态是:
https?:\/\/[\w.-]+(?:\/[\w./?%&=-]*)?
对 see https://example.com/a/b?x=1 end 测试,它捕获到 URL 并在空格处停住。它不可能覆盖规范里所有合法 URL——除了真正的解析器没有东西能做到——所以把”能匹配我的系统实际产出的 URL”当作验收标准,并拿真实数据验证这个说法。
**首尾空白。**经典的 ^\s+|\s+$(配 g)依然是最清晰的意图表达;不过在 JavaScript 里 String.prototype.trim() 干同样的事完全不需要正则——能用方法就用方法,模式只在它是更大拼图的一块时才出场。
顺带说说 IP 地址。(\d{1,3}\.){3}\d{1,3} 能从文本里抠出点分四段(ip 10.0.13.207 host 干净命中,展开简写后有四个组)。但它对 999.999.999.999 同样照单全收——每段的取值范围没人管。真要校验,要么捕获后对每段做数值检查(0 <= n <= 255),要么承认正则不是干这个的工具,老实解析字符串。知道什么时候收手,也是正则功夫的一部分。
捕获组:正则真正的价值所在#
“匹配没匹配上”是二值的,捕获才是可用的信息。分组按左括号出现顺序从 1 开始编号,第 0 组永远是整个匹配。
日常三个机制就够用了:
编号分组与替换引用。"2026-08-09".replace(/(\d{4})-(\d{2})-(\d{2})/, "$3/$2/$1") 得到 09/08/2026。$1 这类引用让你重组文本而不用写解析循环。
非捕获组。(?:...) 只负责把内容拢在一起施加量词或分支,不占用组号。上面 URL 模式里的 (?:\/[\w./?%&=-]*)?——整个路径部分整体可选,但我们从不打算按编号引用它。当模式超过三个组,请有意识地混用捕获组与非捕获组,让组号保持有含义。
反向引用。\1 匹配第 1 组捕获过的同样文本。这一条让正则超越了”高级通配符”:/\b(\w+)\s+\1\b/g 抓重复单词,在 the the quick quick brown 里命中 the the 和 quick quick;同一个技巧还能配对开闭标签:/<(\w+)>[^<]*<\/\1>/ 在 <a>one</a> 和 <b>two</b> 上分别命中两个独立匹配,因为 \1 强制闭合标签等于开始标签。(这不构成生产级 HTML 解析——任何正则方案都不构成——但对快速日志抓取无价。)
命名分组((?<year>\d{4})-(?<month>\d{2}))自 ES2018 起在 JavaScript 里可用,模式一超过两个组就值得换:m.groups.year 能挺过重构,而 m[1] 会在重构时悄悄错位。
回溯陷阱,用实测数据说话#
每个开发者迟早会写出这个模式:/(a+)+b/。它看起来人畜无害——“一个或多个 a,包在一个也会重复的组里,然后一个 b”。在能匹配的字符串上跑,瞬间返回。在 28 个 a 后面跟个 X 的字符串上跑——一个匹配注定不存在的输入——引擎就开始了它的漫长旅程:
整体匹配失败时,正则引擎回溯:吐回一个字符换内层 + 的另一种分法,再换外层 + 的分法,把同一段 a 用一切可能的切分方式重试一遍才肯认输。代价随输入长度指数增长。在一台笔记本上实测,JavaScript 引擎处理 26 个不匹配的 a 大约要 0.5 秒,28 个超过 2 秒。照此推算,34 个字符是分钟级,40 个字符比你职业生涯的耐心还长——而这只是个 7 字符模式对 40 字符输入。(本文成稿时那次完整测试——一串后面没有 b 的 a——在准备阶段跑了 19 秒。)
这就是灾难性回溯(catastrophic backtracking),而且它是一个货真价实的攻击类别:ReDoS,正则拒绝服务。如果你的服务用有漏洞的模式去跑用户提交或用户可影响的文本,攻击者用一条比这段话还短的请求就能把你的 CPU 钉在 100%。
防御规则不复杂:
- 避免嵌套量词——组的重复本身又被加量词,如
(a+)+、(\w*)*、(.*)?。这种冗余几乎总是无意写出的。 - 优先用互斥的字符类。
(\d+|[a-z]+)在不匹配的输入上快速失败,因为数字和字母不可能同时吃掉同一个字符;(a|a)*则是灾难的形状,两个分支想要同一份文本。 - 能锚点就锚点。
^a+b是线性的:引擎一次提交一次失败,而不是在每个位置重启搜索。 - **给无界加上界。**标签模式里的
[^<]*走不过<,所以即使最坏情况回溯空间也有限。把.*换成不会越过结构边界的取反字符类,是性价比最高的一处重构。 - **限制输入长度。**哪怕只是平方级的模式,撞上多兆字节输入照样难受。匹配之前先拒绝离谱的尺寸。
- **测试失败用例,而不只是成功用例。**回溯爆炸发生在”部分成功之后匹配失败”的时刻。你的测试集里必须放进那些”差一点就匹配上”的输入。
如果你的运行时支持,占有量词(a*+)和原子分组((?>...))通过拒绝吐回字符直接消灭回溯——但注意 JavaScript 的标准引擎两者都不支持,所以在 JS 里的解法是重构模式,而不是给模式加修饰。
用在线测试器养成”先测后写”的习惯#
专业的做法是别再把正则当”只写不读”的东西。本站的正则测试器完全在浏览器里运行——模式和样例文本从不离开页面——一次规范的工作过程应该是这样的:
- 粘贴真实的样例文本,不是整洁的两行演示。解析日志就贴真实(脱敏后)的日志行,包括那些丑的:空字段、Unicode、写到一半被截断的行。
- **输入模式并谨慎加标志。**常用的四个:
g(全部匹配——不加它 JavaScript 只取第一个,这直接回答了最常见的”为什么只匹配一次?”);i(忽略大小写);m(^/$变成按行而不是按整串);s(.也匹配换行)。 - **读捕获列表,别只看高亮。**测试器把每个组的索引和偏移都列出来。正是在这里你会发现第 2 组不小心吃掉了分隔符,或者模式匹配的是第一个路径段而你想要的是最后一个。
- 加一个反例。在测试文本里放一行不该匹配的内容。如果它亮了,说明你趁 bug 还免费的时候抓到了它。
有一个路由模式特别值得这样测,因为它太容易在细节上出错:
^\/(api|v\d+)\/([a-z-]+)(?:\/(\d+))?
对 /api/users/42,三个组分别是 api、users、42;对 /api/users,第三组是 undefined;对 /v2/items/17,第一组是 v2。你的下游代码是否优雅处理了 undefined,正是你想在测试器里而不是凌晨三点的告警里发现的事。测试器还会在你输入的瞬间对形状上疑似 ReDoS 的模式(嵌套量词、重叠分支)给出提示,把上一节的建议从”要记住的习惯”变成”自动检查”。
最后一条值得养成的习惯:正则一旦证明了自己有用,就把测试文本留在它旁边。一个带着三条示例匹配注释的模式是可维护的;裸躺在常量文件里的同一个模式只是考古素材。
什么时候不该用正则#
那个老笑话——“你有一个问题,用正则解决它,现在你有两个问题”——其实是一条关于边界的法则。正则是一门字符扫描语言,它没有嵌套、状态和语法的概念。具体地说:
- 结构化数据配得上解析器。JSON、带多个参数的 URL、嵌套的 HTML、涉及时区规则的 ISO 日期——先用平台解析器,需要的话再对字段用小正则。用正则解析 JSON 行不通;从已解析的对象里取字段又用不着它。
- 固定字符串替换交给字符串方法。
str.replaceAll()就够了,别写一个还得转义元字符的正则。 - **标识符转换不是匹配问题。**把标题变成 slug 是一条流水线(规范化重音、分词、用分隔符拼接)——slug 生成器把它自动化了,连那些击溃朴素
[a-z]字符类的非 ASCII 情况也一并处理。同样,在camelCase、snake_case、kebab-case之间转换是个分词任务——文本大小写转换工具不用一个脆弱的模式就能搞定。
最强的正则功力不是写出更大的模式,而是知道模式长到多大就该换成解析器——在那之前,让每个模式都小到可以测试。
常见问题#
这些示例用的是哪种正则方言?#
JavaScript 引擎(ECMAScript),也就是浏览器和 Node 里 RegExp 背后那个。日常构造上它接近 PCRE,但不支持占有量词和原子分组,旧运行时里也没有逆向后行断言——现代引擎则同时支持命名分组和后行断言。从 PHP 或 Go 代码里抄模式,先在 JS 引擎上测过再信;正则测试器就是那个引擎。
为什么我的模式只匹配一次?#
缺 g 标志。不加它,JavaScript 正则找到第一个匹配就停。加上 g 才能遍历所有不重叠的匹配。
(a) 和 (?:a) 有什么区别?#
(a) 把匹配内容捕获进第 1 组,可用 m[1] 或替换里的 $1 取回。(?:a) 只做结构分组——给一段序列施加 ? 或 *、限定分支范围——不移动任何组号。凡是不打算引用内容的分组,都用非捕获的。
怎么匹配”包括换行在内的任意字符”?#
点号 . 默认排除换行符。要么加 s(dotall)标志让 . 匹配一切,要么用取反类:[^](任意字符,JS 特有写法)或 [\s\S](空白或非空白——可移植的惯用法)。
能用正则校验邮箱吗?#
只能近似。RFC 5322 的完整语法允许(本地部分带引号、注释等)实际模式都会拒绝的地址,而任何正则都无法确认信箱真实存在。用简单的形状检查拦笔误,再用确认邮件兜底。写得超过一行的模式都是力气用错了地方。
输入长度有安全上限吗?#
线性模式(有锚点、无嵌套量词、无重叠分支)处理兆字节也没问题;回溯模式几十个字符就可能有危险。正则测试器把输入限制在 25 万字符,正是出于这个原因——但任何上限都替代不了把模式改对。