正则表达式是"既被广泛使用、又被广泛误用"的工具。大多数人会写 \d+,但碰到"匹配嵌套结构""提取捕获组""按位置断言"就懵了。这篇文章从基础语法开始,逐步走到反向引用、零宽断言、命名捕获组,最后给一组生产环境真实可用的模板。所有例子可在 JavaScript / Python / Go / PHP 里直接跑。
字符类:最基础的构件
. 任意字符(默认不含换行)
[abc] a 或 b 或 c
[^abc] 除了 a、b、c
[a-z] 小写字母,等价于 [a-zA-Z] 全体字母用 [a-zA-Z]
\d 数字,等价于 [0-9]
\D 非数字
\w 单词字符,等价于 [A-Za-z0-9_]
\W 非单词字符
\s 空白(空格、tab、换行)
\S 非空白
注意 \w 默认不含中文(POSIX 定义)。要匹配中英文混排,得用 [一-龥\w] 或在支持的引擎里开 Unicode 模式。
量词:控制重复次数
* 零次或多次,贪婪
+ 一次或多次,贪婪
? 零次或一次
{n} 恰好 n 次
{n,} 至少 n 次
{n,m} n 到 m 次
*?, +?, ?? 非贪婪(懒惰)
*+, ++, ?+ 占有(possessive,不回溯,部分引擎支持)
贪婪 vs 懒惰:必须分清
默认所有量词都是贪婪的,尽可能多匹配。这是 80% 正则 bug 的源头。
// 想匹配 HTML 里的第一个标签:
'<div>hello</div>world<p>'.match(/<.+>/)
// 结果:<div>hello</div>world<p> —— 贪婪!.+ 吞到最后一个 >
// 改成懒惰
'<div>hello</div>world<p>'.match(/<.+?>/)
// 结果:<div>
// 或者用"否定字符类"代替 . —— 更高效
'<div>hello</div>world<p>'.match(/<[^>]+>/)
// 结果:<div>
经验法则:需要匹配"到某个字符为止"时,优先用否定字符类 [^x]+,其次才用懒惰 .+?。否定字符类避免回溯,性能远好于懒惰量词,在大文本上差距非常明显。
分组与捕获
(abc) 捕获组,匹配 abc,可以在结果里取
(?:abc) 非捕获组,只分组不捕获
(?<name>abc) 命名捕获组,推荐
// JavaScript:命名捕获组
const m = '2026-05-15'.match(/(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/);
console.log(m.groups); // { year: '2026', month: '05', day: '15' }
// Python:同样支持
import re
m = re.match(r'(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})', '2026-05-15')
print(m.groupdict()) # {'year': '2026', 'month': '05', 'day': '15'}
非捕获组 (?:...) 看似多余,但它在用 | 分组时非常有用,且能避免污染捕获结果:
// 想匹配 jpg/png/gif,但不需要捕获后缀本身
const re = /\.(?:jpg|png|gif)$/i;
反向引用:在正则里"引用前面捕获的东西"
\1 / \2 表示"前面第 1/第 2 个捕获组匹配到的实际内容"。这能匹配"重复的内容":
// 匹配重复的单词:'the the dog' 中的 'the the'
'the the dog'.match(/\b(\w+)\s+\1\b/); // ['the the', 'the']
// 匹配 HTML 标签对:<b>...</b>
'<b>hi</b> <i>x</i>'.match(/<(\w+)>.*?<\/\1>/);
// ['<b>hi</b>', 'b']
命名版本:\k<name>(JS / .NET)或 (?P=name)(Python)。
零宽断言:匹配位置而不匹配字符
零宽断言是正则里最"现代"也最强力的工具,它检查某个位置"前后是不是某种模式",但不消耗字符。四种:
(?=...) 正向先行断言:右边必须满足 ...
(?!...) 负向先行断言:右边必须不满足 ...
(?<=...) 正向后行断言:左边必须满足 ...
(?<!...) 负向后行断言:左边必须不满足 ...
实用例子
// 匹配后面跟着"元"的数字,但"元"不进结果
'价格 100元 数量 5'.match(/\d+(?=元)/); // ['100']
// 匹配不是字母后面的数字
'abc123 456'.match(/(?<![a-z])\d+/g); // ['456']
// 匹配前面有 $ 的金额数字
'$100 EUR 200'.match(/(?<=\$)\d+/); // ['100']
// 强密码校验:8+ 位,必须含大小写、数字、特殊字符
const strong = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%]).{8,}$/;
strong.test('Abcd1234!'); // true
strong.test('abc123'); // false
密码校验是零宽断言最经典的应用:四个先行断言独立检查"含小写""含大写""含数字""含特殊符号",最后 .{8,} 才真正消耗字符。每个断言都不消耗位置,所以可以叠加。
实战模板库
下面这些都是从真实项目里抽出来的,可直接复用。
邮箱(够用版,不追求 RFC 完美)
const email = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/;
注意:不存在能完美校验邮箱的正则(RFC 5322 太复杂)。最稳妥的做法是粗校验 + 发验证邮件确认。上面这个版本对 99% 的真实邮箱都成立。
中国大陆手机号
const mobile = /^1[3-9]\d{9}$/;
中国身份证号(粗校验)
const idCard = /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/;
URL
const url = /^https?:\/\/(?:[\w-]+\.)+[a-z]{2,}(?::\d{1,5})?(?:\/[^\s?#]*)?(?:\?[^\s#]*)?(?:#\S*)?$/i;
IPv4(防止 999.999.999.999 通过)
const 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)$/;
日期 yyyy-mm-dd(粗校验,不区分大小月)
const date = /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/;
HTML 标签提取
// 提取所有 a 标签的 href,用懒惰量词 + 否定字符类
const html = '<a href="/a">A</a> <a href="/b" class="x">B</a>';
const re = /<a\s+[^>]*href="([^"]*)"[^>]*>/g;
let m;
while ((m = re.exec(html))) console.log(m[1]); // /a /b
提醒:用正则解析任意 HTML 是反模式。提取某个固定 pattern 的属性可以,真要解析任意 HTML 用专门的 DOM 解析器。
日志行解析(命名捕获组真香)
const log = '2026-05-15 10:23:45 [ERROR] api - timeout on /users/42';
const re = /^(?<ts>\S+ \S+) \[(?<level>\w+)\] (?<mod>\w+) - (?<msg>.+)$/;
const g = log.match(re).groups;
// { ts: '2026-05-15 10:23:45', level: 'ERROR', mod: 'api', msg: 'timeout on /users/42' }
替换里的高级用法
// 把 yyyy-mm-dd 换成 dd/mm/yyyy
'2026-05-15'.replace(/(\d{4})-(\d{2})-(\d{2})/, '$3/$2/$1');
// '15/05/2026'
// 命名捕获组的引用
'2026-05-15'.replace(/(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/, '$<d>/$<m>/$<y>');
// 替换里用函数,做条件转换
'1,2,3,4'.replace(/\d+/g, (n) => +n * 2);
// '2,4,6,8'
// 大小写转换:把所有单词首字母大写
'hello world'.replace(/\b\w/g, (c) => c.toUpperCase());
// 'Hello World'
性能与正则灾难
正则最危险的特性叫灾难性回溯(catastrophic backtracking)。当多个嵌套的可重复组都能匹配同一段字符时,引擎会尝试指数级的组合。
// 看似无害,但对 'aaaaaaaaaaaaaaaaaaaaaaaa!' 会卡死
const bad = /^(a+)+$/;
// 因为 a+ 内外都能"瓜分"那串 a,组合方式按 2^n 计
修复方式:
- 避免重复组里的可重复元素(
(a+)+改成a+)。 - 用占有量词或原子组(部分引擎支持)。
- 给输入设长度上限,从工程上兜底。
线上正则务必拿"对抗性输入"(超长字符串、特殊字符堆积)测一下,Node 里加超时:
// Node 22+ 可以用 RE2(google 的线性时间正则引擎)绕开回溯问题
const RE2 = require('re2');
const re = new RE2('^(a+)+$');
// RE2 不支持反向引用/后行断言等少数特性,但不会有灾难性回溯
调试技巧
- regex101.com:在线调试,有可视化、解释、各引擎方言对照。写正则前先在这调通。
- 分步构建:先匹配最外层结构,再细化里面。一次写一长串很容易卡死。
- 用 x 标志写多行注释(Python / PCRE):
(?x) 让正则可以分行写并加注释,可读性飙升。 - 能拆就拆:复杂场景把正则拆成多步处理,不要为了"一行解决"写超长正则。
各语言里的正则差异
正则方言之间小差异多到能开一本书,这里只列开发中最常踩的:
// JavaScript:默认不支持 \A \z(用 ^ $);后行断言 ES2018+ 才有
'abc'.match(/^abc$/);
// Python:re.MULTILINE 让 ^ $ 匹配每行的开头/结尾
import re
re.findall(r'^\d+', 'a1\nb2\nc3', re.MULTILINE) # ['1', '2', '3']
# 命名捕获语法不同:Python 用 (?P<name>...)
// Go:用 regexp 包,基于 RE2,不支持反向引用和后行断言
import "regexp"
re := regexp.MustCompile(`(?P<y>\d{4})-(?P<m>\d{2})`)
m := re.FindStringSubmatch("2026-05")
// 用 re.SubexpNames() 拿到命名组对应位置
// Java/Kotlin:命名捕获 (?<name>...),反向引用 \k<name>
// 必须开 (?d) 才让 $ 严格匹配行尾不含 \r
// PCRE (PHP / 多数 *nix 工具):特性最全,支持递归正则、原子组等
用 split 和 named group 一次完成解析
// JavaScript:用带捕获组的 split 保留分隔符
'a,b;c,d'.split(/([,;])/); // ['a', ',', 'b', ';', 'c', ',', 'd']
// Python:re.split 也支持,且 maxsplit 限制次数
import re
re.split(r'\s+', 'a b\tc', maxsplit=1) # ['a', 'b\tc']
// 边解析边收集:re.finditer 返回迭代器,可流式处理大文本
for m in re.finditer(r'(\w+)=(\d+)', 'a=1 b=2 c=3'):
print(m.group(1), m.group(2))
用 unicode 模式正确处理中文
默认的 \w 不含中文。要让正则正确处理中英文混排,开 Unicode 模式或显式列出范围:
// JavaScript:加 u 标志
'你好 abc 123'.match(/[\p{L}\p{N}]+/gu);
// ['你好', 'abc', '123']
// 匹配中文标点
'你好,世界。'.match(/[\p{P}]/gu); // [',', '。']
// 简单粗暴版本(主流汉字范围):
const chinese = /[一-龥]+/g;
正则之外:什么时候用专门的解析器
正则适合"局部规律性"的处理。下面这些场景请果断换工具:
- 解析 JSON:用
JSON.parse。"几百行正则解析 JSON"是经典反模式。 - 解析 HTML:用 DOMParser / cheerio / BeautifulSoup。HTML 不是规则语言。
- 解析嵌套结构(括号、表达式):正则在理论上无法匹配任意深度的嵌套(它是正则语言,嵌套需要上下文无关语法)。用 PEG.js / lark / yacc 这类解析器生成器。
- 语义级匹配(分词、词性):用 NLP 库(jieba / spaCy)。
正则不是越多越好。线上看到一段超过 5 行注释才能讲清楚的正则,99% 应该换实现。
正则在 Vim / 编辑器里的实战
除了写代码,正则在文本编辑场景里也是肌肉记忆级别的工具。掌握下面几个套路,日常处理日志、改批量配置会快很多。
# Vim 里 :s 命令做替换,记得 \v 开 "very magic" 模式,语法更接近 PCRE
:%s/\v(\w+)\s*=\s*(\d+)/\1 := \2/g
# VSCode / Sublime 的"在文件中查找替换"用同样语法,
# 把 foo(1, 2) 改成 bar(2, 1)
查找:foo\((\d+),\s*(\d+)\)
替换:bar($2, $1)
# 命令行 sed 做批量替换,-E 启用扩展正则
sed -E 's/(http):/https:/g' urls.txt > urls_new.txt
# ripgrep 查找:速度数十倍于 grep,默认就是 PCRE
rg -i 'TODO|FIXME' src/ # 大小写不敏感地找待办
rg -P '(?<=password=)\S+' . # 用 PCRE 后行断言提取值
调试一段卡住的正则
当一段正则在某个输入上明显变慢,大概率是回溯爆炸。诊断流程:
- 把输入打印出来,看长度和重复字符。
- 把正则拆成几段分别测试,定位是哪一段在卡。
- 找连续可重复元素
(...)+、(...)*,看里面是否能"切多种分法"匹配同一段字符。 - 把内部改成"不可重叠":用
[^x]替代.,用具体字符替代\w。 - 极端情况下换 RE2 引擎(Go、Python 的 re2 包、Node 的 re2 模块),它是线性时间,但不支持反向引用和后行断言。
// JS:用 AbortController 做正则匹配超时(节点 22+ 才能在 worker 里中断)
function safeMatch(re, str, ms = 100) {
return new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error('regex timeout')), ms);
// Worker 实际执行 re.test/re.exec,主线程可以中断 Worker
// 这里只演示思路
});
}
把正则交给生成器,自动构建
当一个正则要表达"一组关键词中的任何一个"时,手写 a|b|c|... 又长又容易漏。可以用代码生成:
// JS:把字符串数组转成"匹配任意一个、按长度优先"的正则
function buildAny(words) {
const escaped = words
.sort((a, b) => b.length - a.length) // 长的优先,避免短的先吃掉前缀
.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return new RegExp('(?:' + escaped.join('|') + ')', 'g');
}
const re = buildAny(['http://', 'https://', 'ftp://', 'ws://']);
'visit http://a.com or https://b.com'.match(re);
// ['http://', 'https://']
长度排序很关键 —— http 和 https 同时存在时,先放 https 否则会先匹配到 http 把 s 留在外面。这是 lexer 实现里的经典套路。
正则也能"递归":PCRE 的高级特性
PHP / nginx 的 PCRE 引擎支持递归正则,可以匹配嵌套结构(尽管前面说过"一般不建议",但偶尔有用):
# 匹配平衡的圆括号嵌套
\(([^()]|(?R))*\)
# (?R) 表示"递归整个正则"
# 例如能匹配 (a(b)c(d(e)f))
但这种正则可读性极差,维护负担大。除非你写的是一次性脚本,否则还是用专门的解析器。
写在最后
正则不是炫技,它是把"字符串里有规律的局部"用一种紧凑、可读、可移植的方式表达出来的工具。掌握它的关键不是背完所有元字符,而是:遇到字符串处理,先问"它有没有结构性的规律";有规律,再问"用否定字符类还是懒惰量词";写完,再问"对抗性输入会不会让它崩溃"。这三道关过了,你写的正则就既准确又安全。
下次再要"提取 / 校验 / 替换"字符串,先看上面的实战模板能不能直接套;套不上,在 regex101 上分步构建;真复杂到难以维护,就换专门的解析器 —— 别为了"一行正则"留下读不懂的代码债。
—— 别看了 · 2026