正则表达式让人发怵,是因为语法乍看像一团乱码,但构成它的基本零件其实很少。掌握十来个原语,你就能读懂几乎任何模式。这是一篇实用导览,不是语法规范,并且把那些在真实代码里咬人的地方单独点出来。
元字符速查表
下面这些是你会最常碰到的符号。前十几回读模式时,把这张表开着:
| 符号 | 含义 | 例子 |
|---|---|---|
. | 除换行外任意字符(加 s 标志含换行) | a.c 匹配 "abc" |
^ $ | 字符串/行首尾锚点 | ^a 以 a 开头 |
\b | 单词边界(零宽) | \bcat\b 整词 cat |
\d \D | 数字 / 非数字 | \d{3} 三位数字 |
\w \W | 单词字符 / 非单词字符 | \w+ 标识符 |
\s \S | 空白 / 非空白 | \s+ 连续空白 |
* + ? | 0+/1+/0或1 量词 | a+ 一个或多个 a |
{n,m} | n 到 m 次 | a{2,4} |
[ ] | 字符集合 | [a-z0-9] |
[^ ] | 取反集合 | [^0-9] 非数字 |
( ) | 分组并捕获 | (ab)+ |
(?: ) | 仅分组不捕获 | (?:ab)+ |
(?= ) (?! ) | 先行肯定/否定 | \d(?=px) |
(<= ) (<! ) | 后行肯定/否定 | (<=\$)\d+ |
| | 或 | cat|dog |
\ | 转义元字符 | \. 字面点 |
字符类
字符类匹配集合里的任意一个字符。[aeiou] 匹配任意元音,[^0-9] 匹配任意非数字字符。预定义简写:\d 数字,\w 单词字符(字母、数字、下划线),\s 空白符。大写表示取反:\D、\W、\S。
/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/
量词与贪婪
* 零次或多次,+ 一次或多次,? 零次或一次,{n,m} n 到 m 次。默认它们是贪婪的:能吞多长吞多长。在后面加 ? 变成懒惰。
const s = 'a x b y b';
s.match(/a.*b/); // "a x b y b" (贪婪:吃到最后一个 b)
s.match(/a.*?b/); // "a x b" (懒惰:碰到第一个 b 就停)
锚点
^ 匹配字符串开头(配合 m 标志则是行首),$ 匹配结尾。\b 是单词边界。锚点不消耗字符,只做位置断言。
/^\d{4}-\d{2}-\d{2}$/ // 一个严格格式的日期,整串匹配
分组与捕获
圆括号既分组又捕获。(\d{4})-(\d{2}) 把年和月分别捕获,可以在匹配结果里取,也可以在替换里用 $1、$2。只想分组不想捕获时用 (?:...),更省。
'2026-08-26'.replace(/(\d{4})-(\d{2})-(\d{2})/, '$3/$2/$1');
// "26/08/2026"
反向引用
在模式内部,\1 引用第一个捕获组。适合"同一个词出现两次"这类场景:
/(\w+)\s+\1/ // 匹配像 "the the" 这样的重复词
前后查找
这是关于"前面/后面是什么"的零宽断言,本身不消耗字符。(?=...) 正向先行,(?!...) 负向先行;(<=...) 和 (<!...) 是后行断言。它们让你能表达"后面跟着"或"前面不是",又不把这些文本算进匹配里。
/\d(?=\.)/ // 后面跟着小数点的数字
/(<=@)\w+/ // @ 紧后面的单词字符
/foo(?!bar)/ // "foo" 后面不是 "bar"
/(<!\$)\d+/ // 前面不是美元符号的数字
一个实用场景:校验密码必须含数字,但不把数字放进捕获结果。把锚点和先行断言结合起来:
/^(?=.*\d).{8,}$/ // 至少 8 位,且至少含一个数字
标志位
g—— 全局,找出所有匹配而不是停在第一个。i—— 忽略大小写。m—— 多行,让^和$匹配每行首尾,而不只是整串。s—— 让.也能匹配换行符。u—— 按 Unicode(UTF-16 码点)解析,处理非 ASCII 时一定要加。y—— 粘连,只在lastIndex处匹配。
回溯失控,复现一下
有些模式在特定输入下开销会爆炸。经典元凶是重叠原子的嵌套量词。拿 ^(a+)+b$ 去匹配一长串以 a 结尾却不以 b 结尾的字符串:
const re = /^(a+)+b$/;
console.time('re');
re.test('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); // 没有匹配,但是……
console.timeEnd('re');
// 再多几个 a,耗时从瞬时变成几秒
引擎会尝试把 a 们在内外两层加号之间切成无数种分法,等发现结尾没有 b 再整体回溯。每多一个 a,工作量就翻几倍。在服务器上这就是拒绝服务:攻击者发一个长字符串,你的正则把事件循环卡死。修复办法是让内外两层不要重叠——这里一个 a+ 加上锚点已经表达了结构,所以去掉外层加号:
// 更安全:同样的原子上不再嵌套量词
const re = /^a+b$/;
re.test('aaaa...a'); // 快速失败
如果你确实要分组,就加锚点,并避免让内层和外层重复同一类字符。在支持原子组的引擎里(PCRE、Java、.NET、Python 的 regex 模块),(?>a+)+ 能彻底阻止回溯。JavaScript 没有原子组,所以实操铁律是:绝不要把量词嵌套在"针对同一字符"的量词里面。
几个常见真实模式,以及教科书正则为什么不行
你在 Stack Overflow 上找到的"标准"正则,通常只是格式检查,不是校验。下面是它在生产环境里怎么翻车:
- 邮箱。上面那个实用模式能拦下绝大多数合法地址,但没有哪个正则能完整实现 RFC 5322 文法——它允许括号里的注释、带引号的本地部分等等。更糟的是,很多"邮箱正则"会拒绝完全合法的地址,比如
[email protected]或[email protected]。在真实的注册表单里,用一个浅层模式挡掉明显手滑,然后发一封确认邮件来证明地址可用。一个正则没法告诉你a@b是不是真邮箱。 - URL。正则能认出"长得像 URL"的字符串,但它会欣然接受
https://not a real host这种形状,又拒绝合法但少见的协议。关键是,一个"校验"URL 的正则对上一篇文章讲的开放重定向和 SSRF 风险毫无办法——那些需要解析加主机检查。把 URL 正则当"自由文本里这玩意像不像链接"的浅层过滤器,绝不当授权依据。 - 日期。
^\d{4}-\d{2}-\d{2}$只查格式不查合法性——2026-13-40也能过。就算给月份加上(0[1-9]|1[0-2]),仍然会放过2026-02-30。真正的日期校验要把它解析成日期对象、再检查能否往返。正则不是判断"这是不是一个真实日历日期"的合适工具。 - 手机号。国际号码格式天差地别;单个正则要么拒绝合法号码,要么接受不可能的号码。优先用专门的库(
libphonenumber),自己只检查"主要由数字加少量标点组成"即可。
什么时候该换真正的解析器
不要用正则解析 HTML 或 JSON。 两者都是嵌套、递归的结构,扁平的模式处理不了任意层级的嵌套,遇到第一个被引号包裹的属性或转义字符就破功。请用真正的解析器。一次性引入一个库的代价,远低于你的正则在某个边界情况失手后上线那个 bug。同理,CSV 用 CSV 解析器,日期用语言的日期库,路径用 URL/Path API。
命名捕获组
除了数字编号的捕获组,现代正则引擎(包括 JavaScript)支持命名组:(?<year>\d{4}),之后用 match.groups.year 或替换串里的 $<year> 来引用。命名组让替换和提取的可读性大幅提升,尤其当模式里有七八个组、数字编号早就数不清的时候。注意有些老环境(比如很旧的 Safari)不支持命名组,需要时才用。
const m = '2026-08-26'.match(/(?<y>\d{4})-(?<mo>\d{2})-(?<d>\d{2})/);
m.groups; // { y: "2026", mo: "08", d: "26" }
把它和前面"不要拿正则解析结构化格式"放在一起看:正则适合从一行日志、一段自由文本里抽取字段,抽完立刻用真正的类型去处理——日期就丢进日期库,数字就 parseInt。抽取是强项,校验和解析是弱项,分清楚这点就不会滥用。
小结
正则是把利刃:做匹配和提取很快,拿去解析结构化格式却很危险。先从元字符速查表起步,再到字符类、量词、锚点、分组;需要时才加前后查找和标志位;并对回溯悬崖保持敬畏——绝不在同一原子上嵌套量词。想动手试,正则测试工具 能实时显示匹配;前面的例子配合 URL 编码工具 也很顺手。