开发者最常用的正则表达式写法

正则不是用来"匹配一切"的,是用来把不可能的输入挡在更前面。下面这些写法都遵循一个原则:先用 ^ 和 $ 锚定整个串,再谈内容。少了锚定,\d{11} 会在 20 位的字符串里也匹配成功。

常用模式速查

用途 正则 说明
中国大陆手机号 ^1[3-9]\d{9}$ 11 位,第二位 3–9
邮箱(宽松实用) ^[\w.+-]+@[\w-]+(\.[\w-]+)+$ 不追求 RFC 5322 全兼容
IPv4 ^((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$ 逐段限定 0–255
日期 YYYY-MM-DD ^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$ 只校验形状,不校验闰年
时间 HH:mm ^([01]\d|2[0-3]):[0-5]\d$
整数或小数 ^-?\d+(\.\d+)?$
中文字符 [\u4e00-\u9fa5] 常用汉字区;更完整用 \p{Script=Han} 加 u 标志
首尾空白 ^\s+|\s+$ 用于 trim
连续空行 \n{3,} 替换成 \n\n
HTTP(S) URL ^https?://[^\s]+$ 宽松判断,别指望它验证域名合法性

IPv4 那段看起来长,但拆开就是"0–255 的四段,用点连接"。写成 \d{1,3} 会把 999.999.999.999 也放过去。

贪婪和惰性

* 和 + 默认贪婪,会吃掉尽可能多的字符。从 HTML 里取标签内容时,贪婪版本会跨到最后一个标签:

'<a>甲</a><a>乙</a>'.match(/<a>(.*)<\/a>/)[1];   // '甲</a><a>乙'  贪婪
'<a>甲</a><a>乙</a>'.match(/<a>(.*?)<\/a>/)[1];  // '甲'           惰性

加一个 ? 就变成惰性。代价是回溯次数变多,长字符串上会慢。更稳的写法是"排除字符集":<a>([^<]*)</a> 根本不回溯。

三种括号的区别

写法 作用 是否进捕获结果
(...) 捕获组 是
(?:...) 非捕获组,只做分组 否
(?<name>...) 命名捕获组 是,groups.name
(?=...) / (?!...) 正向 / 负向先行断言 否
(?<=...) / (?<!...) 正向 / 负向后行断言 否

只是想让量词作用在一组字符上时,一律用 (?:...)。用 (...) 会白白污染捕获索引,尤其是嵌套多了以后 $3 到底是第几组很容易数错。

命名组在需要对多个字段取值时特别好用:

const m = '订单 2023-11-14 金额 ¥128.50'.match(/(?<date>\d{4}-\d{2}-\d{2}).*?¥(?<amount>\d+(\.\d{2})?)/);
m.groups.date;     // '2023-11-14'
m.groups.amount;   // '128.50'

断言用来表达"前后必须是某个东西,但不要把它取出来"。比如只匹配金额数字、不匹配人民币符号:

/(?<=¥)\d+(\.\d{2})?/.exec('合计 ¥128.50')[0];  // '128.50'

后行断言是 ES2018 才进 ECMAScript 的,跑在老浏览器或老 Node 上会直接抛语法错误。

两个容易搞混的标志

标志 影响
m ^ 和 $ 匹配每一行的行首行尾,而不只是整个串
s . 可以匹配换行符(默认不能)
i 忽略大小写
g 查找所有匹配;配合 String.replaceAll 或循环 exec
u 启用 Unicode 模式,\p{...} 才有意义

处理多行日志时,m 常常是必须的;处理跨行块(比如整段 SQL)时,需要 s。

ReDoS:正则也会把服务打挂

下面这个正则看着无害,但对特定输入会指数级回溯:

/^(a+)+$/.test('aaaaaaaaaaaaaaaaaaaaaaaaaaab'); // CPU 直接跑满

原因是嵌套量词 (a+)+ 在匹配失败时要枚举所有切分方式,长度每加 1,回溯次数翻倍。真实项目里更常见的触发形式是 ([\w]+)*、(\s*\w+)* 这类组合。

应对办法,按优先级:

  1. 消除嵌套量词。(\d+,)*\d+ 改成 \d+(,\d+)*。
  2. 限制输入长度。校验前先判断 str.length <= 64。
  3. 别让用户输入进复杂正则。尤其是把表单值拼进 new RegExp() 的场景。

JavaScript 不支持原子组和占有量词(PCRE、Java、.NET 支持),所以第 1 条在 JS 里是唯一根治手段。

两个该记住的原则

不要用一个正则解析嵌套结构。 HTML、JSON、带括号的数学表达式都不是正则语言,靠加 .*? 和断言硬凑出来的表达式一定会漏边界情况。用对应的解析器。

在字符串里写正则要小心双重转义。 new RegExp('\\d+') 才等价于字面量 /\d+/;写 new RegExp('\d+') 得到的是匹配字母 d 的正则。用 String.raw 可以避免这类错误:

const re = new RegExp(String.raw`\d{4}-\d{2}-\d{2}`);

跨语言的三个差异

同一个正则在不同引擎里可能行为不同:

差异点 说明
\d 的范围 JavaScript 的 \d 恒等于 [0-9];Python 3 的 \d 默认匹配 Unicode 十进制数字(含阿拉伯-印度数字等),加 re.ASCII 才退化为 [0-9]
\w 的范围 同理,JS 是 [A-Za-z0-9_],Python 3 默认含 Unicode 字母
后行断言 JavaScript 从 ES2018 起支持;Python 的 re 模块要求后行断言是定长,(?<=ab) 可以,(?<=a+) 会报错
标志写法 JS 用 /re/gi;Python 用 re.I、re.M;Go 用内联的 (?i)

校验类正则如果要跨语言复用,把 \d 显式写成 [0-9]、把 \w 显式写成 [A-Za-z0-9_],能省掉一整类"本地测得好好的,上线就开始漏"的问题。

调试正则的方法

从长到短拆。一个复杂正则匹配不上时,先砍掉后半段看前半段能不能匹配,逐段加回来,定位到第一个失配的位置。这比盯着整条表达式猜快得多。

另外三个值得养成的习惯:

  1. 先写用例再写正则。把"应该匹配的"和"不应该匹配的"各列五条,尤其要放边界输入:空串、超长串、带换行的串、前后带空格的串。
  2. 用带高亮和分组展示的测试工具。肉眼看每个捕获组取到了什么,比打日志快。
  3. 生产环境的正则加长度上限。前面说的 ReDoS,除了改写法,最省事的兜底就是在校验前先判断输入长度。

自己动手试试

相关工具