正则由一组记号组成:字符类匹配单个字符,量词控制重复,锚点固定位置,分组负责组合。用带示例文本的测试工具验证,区分贪婪与懒惰匹配,并警惕可能导致灾难性回溯的嵌套量词。
- 需要匹配字面量时,记得转义元字符。
- 贪婪量词尽可能多匹配,懒惰量词在第一次机会处停止。
- 锚点匹配的是位置,不是字符。
- 命名分组和环视能提高可读性,但不同引擎支持程度不同。
- 用合法、非法和对抗性输入测试,嵌套量词可能拖垮正则引擎。
正则表达式是一种描述文本模式的紧凑语言,也很容易写的时候明白、过几天就看不懂。本文整理了最常用的记号,以及避免无声错误的测试习惯。
字面量与元字符
大多数字符只匹配自身。以下字符有特殊含义,需要匹配字面量时必须转义:
. * + ? ^ $ { } [ ] ( ) | \
例如 . 匹配字面量点号,* 匹配字面量星号。不同语言的转义规则略有差异,尤其是在字符类和替换字符串内部。
转义问题常常发生在正则引擎之前的那一层。JavaScript 字符串里写 "\d",\d 不是合法字符串转义,结果只剩一个 d,必须写 "\d" 或使用正则字面量 /\d/。Python 的原始字符串 r"\d" 可以避开双反斜杠。字符类内部大多数字符失去特殊含义,但 ]、\、^ 和 - 仍需小心:[-a] 把连字符放在开头,[a-z] 才表示字母 a、连字符和 z,而不是范围。部分引擎支持 \Q...\E 引用一段字面量,JavaScript 和 Python 不支持,只能逐个转义。
字符类
| 模式 | 匹配内容 |
|---|---|
| . | 除换行外的任意字符,除非开启 dotAll |
| \d | 数字,通常是 [0-9] |
| \w | 单词字符:字母、数字和下划线 |
| \s | 空白字符 |
| [abc] | a、b、c 中的一个 |
| [^abc] | 除 a、b、c 外的任意字符 |
| [a-z] | a 到 z 范围内的字符 |
| [A-Za-z0-9_] | ASCII 下 \w 的显式写法 |
否定字符类往往比懒惰量词更清晰。"[^"]*" 匹配从引号开始到下一个引号结束的字符串。
这些简写字符类没有看起来那么通用。JavaScript 不开 u 标志时,\d 等于 [0-9],\w 等于 [A-Za-z0-9_],\s 只覆盖固定的 ASCII 空白;开启 u 后,\d 和 \w 仍只认 ASCII,\s 才扩展到 Unicode 空白。Python 3 的 str 模式默认按 Unicode 解释,\w+ 能匹配西里尔字母和中文,\d 能匹配阿拉伯-印度数字。需要只认 ASCII 时,应加 re.ASCII 或改用显式字符类。浏览器和服务器上表现不一致,常常就是这里造成的。
点号也比很多人以为的窄。默认它不匹配 \n;JavaScript 不开 s 时还不匹配 \r 以及 Unicode 的行分隔符和段分隔符。确实想匹配任意 UTF-16 码元时,JavaScript 可以写 [^],需要跨引擎可移植时写 [\s\S]。
量词
| 模式 | 含义 |
|---|---|
| * | 零次或多次 |
| + | 一次或多次 |
| ? | 零次或一次 |
| {3} | 恰好三次 |
| {2,5} | 两到五次 |
| {2,} | 两次或更多 |
| *? | 零次或多次,懒惰 |
| +? | 一次或多次,懒惰 |
贪婪量词尽可能多匹配,懒惰量词尽可能少匹配。"." 会从第一个引号一直匹配到最后一个引号,".?" 只匹配一个引号片段。
懒惰不等于高效。.*? 仍然是匹配、失败、前进一个字符、再匹配,面对很长的非匹配字符串可能退化成平方级。PCRE、Java 和 Perl 提供占有量词 *+、++、?+,消费后不回溯;原子分组 (?>...) 对子表达式做同样的事。确定量词不应该吐回字符时,它们才是正确工具。JavaScript 没有这两种语法,但可以用先行断言模拟一部分原子分组。
锚点与边界
| 模式 | 含义 |
|---|---|
| ^ | 字符串开头;开启 m 后也匹配行首 |
| $ | 字符串结尾;开启 m 后也匹配行尾 |
| \b | 单词边界 |
| \B | 非单词边界 |
| \A | 部分引擎中的字符串开头 |
| \z | 部分引擎中的字符串结尾 |
锚点匹配的是位置,不是字符。^$ 匹配空行,而不是包含空格的行。
末尾锚点在不同引擎里并不等价。Python 3.14 及以后,\A 和 \z 始终表示整个字符串的开头和结尾,\Z 允许末尾多一个换行,$ 即使不开多行模式也会匹配结尾换行之前的位置。JavaScript 不开 m 时,$ 只匹配真正的末尾,"abc\n" 无法通过 c$,除非先去掉换行。\b 是单词字符与非单词字符之间的位置,不是字符本身,所以单独一个 \b 匹配零个字符,只高亮边界。输入含中文时,\b 是否把汉字当作单词字符,取决于引擎和 u 标志的定义。
分组与分支
- 捕获分组:(abc) 会捕获匹配文本。
- 非捕获分组:(?:abc) 只分组,不捕获。
- 命名分组:(?<name>abc) 按名称捕获。
- 反向引用:\1 或 \k<name> 引用之前的捕获。
- 分支:cat|dog 匹配任意一个。
分支的优先级很低。^cat|dog$ 等价于 ^cat 或 dog$。如果锚点应同时作用于两个分支,应写成 ^(cat|dog)$。
分支是有顺序的,不是对称的。cat|category 会在 category 中匹配到 cat,第一支成功后引擎不会再尝试更长的分支。一个分支是另一个的前缀时,把长的放在前面,或者加锚点限定。捕获组编号按左括号出现的顺序分配,而不是按实际匹配顺序,所以在中间插入一个分组会让后面所有反向引用重新编号。命名分组能避免这种脆弱性,但写法不同:JavaScript 和 PCRE 用 (?<name>...),Python 用 (?P<name>...),反向引用分别是 \k<name> 和 (?P=name)。替换语法又是另一套,这也是为什么测试工具里正常的正则,写进代码后会失败。
环视
| 模式 | 含义 |
|---|---|
| (?=abc) | 后面跟着 abc |
| (?!abc) | 后面不是 abc |
| (?<=abc) | 前面是 abc |
| (?<!abc) | 前面不是 abc |
环视只断言上下文,不消费字符。\d+(?= dollars) 会匹配“25 dollars”中的数字,但不会匹配“25 euros”。不同引擎对环视的支持和长度限制不同,部分旧引擎不支持后行断言。
后行断言是最容易破坏可移植性的特性。Python 要求定宽:(?<=abc) 可以,(?<=a|bc) 不行,(?<=\d+) 因为长度无界会直接报错。JavaScript 的后行断言更宽松,允许变长,.NET 和许多情况下的 PCRE2 也允许。于是 Chrome 里测试通过的正则,到 Python 里可能连编译都过不了。必须同时运行时,应把后行断言换成捕获组并在替换时调整,或者在代码里先过滤候选。
标志
| 标志 | 作用 |
|---|---|
| i | 忽略大小写 |
| m | 多行模式,^ 和 $ 匹配行边界 |
| s | 点号匹配换行 |
| g | 全局查找所有匹配 |
| u | Unicode 模式 |
| y | 粘性匹配,从当前位置开始 |
标志会改变语义,不只是方便程度。没开 m 时测试通过的模式,开启 m 后可能表现不同。
有两个标志会制造状态 bug。JavaScript 的 g 正则会维护 lastIndex,对同一个正则对象连续调用两次 test(),同一个字符串可能先返回 true 再返回 false,因为第二次从上次停下的位置继续。要么每次重置 lastIndex,要么把正则创建在循环内部。开启 u 后,引擎按 Unicode 码点解释模式,点号会把一个 emoji 当作一个码点而不是两个代理项,也能使用 \p{Letter}。较新的 v 标志在 u 之上增加集合运算,例如 [\p{Letter}--[a-z]]。但 u 和 v 都不会让 JavaScript 的 \d 匹配非 ASCII 数字,那仍然需要显式的 Unicode 属性类。
常用模式
- 纯数字:^\d+$
- 带符号整数:^[+-]?\d+$
- 十六进制颜色:^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$
- ISO 日期形状:^\d{4}-\d{2}-\d{2}$
- URL slug:^[a-z0-9]+(?:-[a-z0-9]+)*$
- 简单邮箱形状:^[^\s@]+@[^\s@]+.[^\s@]+$
- 带协议的 URL:^https?://[^\s/$.?#].[^\s]*$
这些模式校验的是形状,不是语义。2026-99-99 可以通过日期形状校验。涉及真实有效性时,应配合日期解析器使用。简单邮箱模式也无法确认地址是否存在。
上面的 URL 模式故意写得很浅:它接受 http:// 后面几乎任何内容,却拒绝带端口、IPv6 字面量、用户信息或国际化域名的合法地址。要验证 URL,应使用平台的 URL 解析器并检查协议和主机,正则处理不了这套语法。邮箱模式一旦被加上嵌套量词,还很容易变成拒绝服务风险;保持简短,并用发信验证。日期应先用正则检查形状,再构造 Date 或交给日期库,因为 2026-02-30 和 2026-13-01 都能通过形状检查。颜色模式里 #abc 和 #aabbcc 合法,#abcd 不合法,上面的表达式已经覆盖这一点。
贪婪、懒惰与灾难性回溯
嵌套量词可能导致指数级爆炸。模式 (a+)+$ 面对一长串 a 后面跟一个 b 的输入时,可能尝试指数多种切分方式。
实用的防御措施:
- 避免对同一批字符使用嵌套量词。
- 用明确的字符类代替点号加重复。
- 引擎支持时,使用占有量词或原子分组。
- 引擎允许时,设置匹配超时。
- 不只测试能匹配的输入,也要测试对抗性输入。
“指数级”具体是什么样:对 (a+)+$ 来说,30 个 a 后面跟一个 b,可以切成 2^29 种分组,约 5.36 亿条路径。JavaScript 或 PCRE 这类回溯引擎可能要跑几秒到几分钟才报告失败;到 40 个 a,同样的输入能拖过任何请求超时。^(a|aa)+$ 和 ^(\w+\s?)*$ 在同样短的输入上表现相同。曲线非常陡,10 个字符的测试看起来没事,生产环境 50 个字符就会挂住。
引擎选择很关键。RE2、Go 的 regexp 和 Rust 的 regex 不回溯,时间复杂度是线性的,但也不支持反向引用和部分环视。模式确实需要这些特性时,应留在回溯引擎上,同时限制输入长度并设置超时。在 Node.js 里,灾难性回溯会阻塞整个事件循环,而不是只卡住一个请求,最终会拖垮无关流量。
替换字符串
JavaScript 用 $1 和 $<name> 插入捕获,Python 用 \1 和 \g<name>,部分工具用 $& 表示整个匹配。替换语法因语言和工具而异,使用前应确认目标方言。
还有几个替换记号值得记住。JavaScript 支持 $$ 表示字面量美元符号,$& 表示整个匹配,$` 表示匹配之前的文本,$' 表示匹配之后的文本。Python 的 \g<0> 表示整个匹配;\1 后面紧跟数字时会产生歧义,写 \g<1>0 更安全。两种语言里,未参与匹配的可选分组在替换时都会插入空字符串,而不是保留原文,这会在不知不觉中删掉内容。替换需要逻辑时,应传函数:JavaScript 支持 replacer 函数,Python 的 re.sub 第二个参数也可以传函数,两者拿到的都是匹配对象,而不是模板字符串。
正则测试工具可以在示例文本上高亮匹配和捕获分组,正则生成器可以根据自然语言需求生成模式,查找替换工具可以把模式应用到真实文本。生成的正则应先经过测试用例验证,再写入代码。
测试清单
- 至少测试一个合法输入。
- 测试一个不应匹配的非法输入。
- 测试空输入。
- 测试包含换行和 Unicode 字符的输入。
- 检查有锚点和无锚点的差异。
- 确认生产环境使用的引擎和标志。
- 用很长的非匹配字符串测试性能。
- 为复杂的正则保存注释和示例。
- 同样的用例要在生产所用引擎上再跑一遍,而不只是在测试工具里。
- 正则执行前先限制输入长度,即使模式看起来安全。
正则也是代码。保持可读,用真实示例测试,并把任何带嵌套量词的模式当作需要重点审查的对象。
有些任务根本不该用正则。用正则解析 HTML 会在嵌套标签、注释和含 > 的属性上出错,应使用 HTML 解析器。用正则校验 JSON、CSV 或 URL,等于重新实现一套已有解析器的语法,边界情况几乎必错。判断字符串是否包含固定子串、是否以某个前缀开头、按分隔符切分,用 includes、startsWith 或 split 更快也更清楚。只有当模式确实不规则、但仍然属于正则语言时才用正则;输入存在嵌套或正式语法时,交给解析器。
常见问题
点号匹配什么?
默认情况下,点号匹配除换行符以外的任意字符。开启 s 或 dotAll 标志后,它也会匹配换行。需要精确控制时,可以用 [^\n] 这样的否定字符类。
* 和 + 有什么区别?
星号匹配零次或多次,因此模式可以匹配空字符串。加号匹配一次或多次,至少需要出现一次。
为什么正则匹配了太多内容?
贪婪量词会尽可能多地消费文本,然后回溯。如果只想要最短匹配,可以使用 .*? 这样的懒惰量词,或让前后模式更具体。
用正则校邮箱靠谱吗?
简单正则可以拦截明显错误,但完整的邮箱校验非常复杂,而且规则会变化。可以用正则检查基本形状,再通过发送验证邮件确认地址真实存在。
什么是灾难性回溯?
某些模式,尤其是 (a+)+ 这样的嵌套量词,在不匹配的长输入上可能需要指数级时间。应减少嵌套,让分支彼此区分,并用对抗性字符串测试。