跳到主内容
d.devtul.fun
EN
正则 · 2026-09-03

正则表达式入门:写给开发者的实战指南

正则表达式让人发怵,是因为语法乍看像一团乱码,但构成它的基本零件其实很少。掌握十来个原语,你就能读懂几乎任何模式。这是一篇实用导览,不是语法规范,并且把那些在真实代码里咬人的地方单独点出来。

元字符速查表

下面这些是你会最常碰到的符号。前十几回读模式时,把这张表开着:

符号含义例子
.除换行外任意字符(加 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 编码工具 也很顺手。

继续阅读