正则表达式核心知识体系交互讲解

字符与字符类

字面量字符直接匹配自身,元字符有特殊含义需转义。字符类 [] 定义字符集合,预定义简写 \d \w \s 覆盖常见场景。

普通字符直接匹配自身。元字符有特殊含义:. * + ? ^ $ { } [ ] | ( ) \,匹配字面量时需 \ 转义。点号 . 匹配除换行符外的任意单个字符(加 s 标志后包含换行)。

为什么「转义」这件事要转两层

  • 第一层是正则语法. 在正则里是元字符,要匹配真正的点必须写 \.
  • 第二层是宿主语言的字符串语法:在普通字符串里 \ 本身也是转义符,于是正则的 \d 得写成 "\\d"
  • 两层叠起来就是所谓「反斜杠地狱」——而原始字符串一次性消掉第二层:Python r"\d"、Go 反引号、C++ R"(\d)"、JS String.raw
  • 判据:只要正则里出现了反斜杠,就该用原始字符串写它。这不是风格问题,是少一层出错来源。

点号的边界(实测)

  • JS /a.b/"a\nb"false,加 s 标志后为 true
  • 所以拿 .* 去抓「整段内容」在多行文本上会静默少抓——症状是「本地测试通过,线上少了一半数据」
  • C++ std::regex 没有 dotAll,. 永远不含换行,这条没有开关可开。
# 普通字符直接匹配
cat        # 匹配 "cat"

# 转义元字符
3\.14      # 匹配 "3.14"(. 转义为字面点)
C\+\+      # 匹配 "C++"
\$price   # 匹配 "$price"

# 点号:除换行外任意单字符
c.t        # cat, cot, c9t, c t …
. 默认不匹配换行符。要让它连换行一起吃需开 dotAll——JS /s、Python re.S、模式内 (?s)(Go/Python 支持);C++ std::regex 无 dotAll,. 始终不含 \n
各语言源码字符串里反斜杠要再转义一层:正则 \d 在普通字符串里得写成 \\d。改用原始字符串可免掉这层——Python r"\d"、Go 反引号 `\d`、C++ R"(\d)"、JS String.raw`\d`
方括号内列出允许的字符(匹配其中之一)。^ 放在 [ 后表示否定。字符类内大多数元字符失去特殊含义,^ - ] 需转义或放在特定位置。

类内的转义规则反而更简单

  • 进了 [ ]. * + ? ( ) | 全部退化成普通字符,不需要转义——[.*+] 就是这三个符号本身;
  • 只剩四个还需要留意,而且各有「靠位置免转义」的写法:^ 只在紧跟 [ 时表否定(放中间就是字面量)、- 放首位或末位就是字面连字符、] 放首位即字面、\ 永远要转义;
  • 所以 []-] 是合法的「匹配 ]-」——看着别扭,但比到处加反斜杠更不容易写错。

否定类会吃掉换行(实测)

JS:     /"[^"]*"/.test('"a\nb"')   →  true
Python: re.search('"[^"]*"', '"a\nb"')  →  命中,且跨了行
  • [^"] 的字面意思是「除了引号之外的任意字符」,换行也在其中;
  • 抓「一行里引号内的内容」时这会一路吞到下一行的引号,把两条记录粘成一条。要限制在本行必须写 [^"\n]*
  • 同类问题也出现在 [^>]* 抓 HTML 属性、[^,]* 抓 CSV 字段上——凡是「按行」的语义,都要把 \n 一并排除
[abc]      # a、b 或 c 之一
[a-z]      # a-z 任意小写字母
[A-Za-z0-9_]  # 字母、数字、下划线

# 否定字符类
[^aeiou]  # 非元音
[^0-9]    # 非数字

# 类内特殊规则
[.]        # 字面点号(. 在类内不是通配符)
[\^\-\]]  # ^ - ] 需转义
[-az]      # - 放首尾无需转义
否定类 [^…] 是「除列出者外的任意字符」,默认也包含换行符——[^"]* 会跨行吞内容。多行文本里若不想跨行,得显式排除:[^"\n]*
字符类内绝大多数元字符失去特殊义(. * + ( | 都是字面量、无需转义);只需留意 \ ] ^ -^ 仅在紧跟 [ 时表否定,- 放首尾即字面连字符,] 放首位或转义才是字面。
\d \w \s 及其大写取反形式是最常用的简写。\p{} 匹配 Unicode 属性类(字母、数字、汉字等),各语言支持差异大。

同一个 \w,四门语言四种答案(实测)

\w 认中文吗\p{}怎么写汉字
Python 3(str)\w+ 匹配到「你好」)标准库不支持[\u4e00-\u9fa5] 或第三方 regex
JS不认(加 /u 也不认)/u\p{Script=Han}
Go(RE2)不认原生支持\p{Han}
C++ std::regex不认不支持只能列码点区间
  • Python 的「认」是默认行为,加 re.ASCII 才退回 ASCII(实测 re.findall(r"\w+", "你好 abc")['你好', 'abc'],加 re.ASCII 后只剩 ['abc']);
  • JS 的 \p{Han} 简写不存在——实测直接 SyntaxError: Invalid property name,必须写全 \p{Script=Han}
  • 这张表解释了一类真实事故:同一条「用户名只能是字母数字」的校验正则,Python 后端放行中文、JS 前端拦下中文,两边行为不一致。跨端共用正则时必须显式写死字符集,别依赖 \w
\d   # 数字 [0-9]
\D   # 非数字 [^0-9]
\w   # 单词字符 [a-zA-Z0-9_]
\W   # 非单词字符
\s   # 空白(空格 \t \n \r \f \v)
\S   # 非空白

# Unicode 属性类
\p{L}     # 任意 Unicode 字母
\p{N}     # 任意 Unicode 数字
\p{Han}   # 汉字
\P{L}     # 非字母(大写 P = 取反)
\d \w \s 的 Unicode 范围各语言不同。Python 3 对 str 默认 Unicode-aware:\w 认中文、\d 认非 ASCII 数字(加 re.ASCII 才退回 ASCII);而 JS(即便加 /u)、Go、C++ 的 \w 默认只认 ASCII,中文不算单词字符。
\p{} 支持:JS 需 /u 标志(脚本名要写 \p{Script=Han});Go(RE2) 原生支持(含 \p{Han});Python 标准 re 不支持,需第三方 regex 库;C++ std::regex 不支持,需 PCRE2。
常用转义序列,Unicode 码点写法因语言而异。

码点转义的四种写法,只有一种通用

要写的JSPythonGo / PCRE2
BMP 内字符\uFFFF四门通用,恰好 4 位十六进制)
星光面(如 😀)\u{1F600} + /u\U0001F6008 位\x{1F600}
  • 实测三条边界:Python 写 \x{1F600}incomplete escape \x;JS 不加 /u\u{1F600} 无效;\xFF 恰好两位,多一位少一位都会改变含义;
  • 位数固定是这里所有坑的根源\x41 之后再跟一个 2,得到的是「A」后跟字面「2」,而不是 \x412
  • 实务建议:非 ASCII 字符直接写字符本身,不要用码点转义。可读性高得多,也避开了这一整张表。
\n   # 换行 LF
\r   # 回车 CR
\t   # 制表符
\0   # 空字符 NUL
\xFF       # 2 位十六进制字节
\uFFFF     # 4 位 Unicode 码点(JS / C++ / Python)
\u{1F600}  # 扩展码点花括号:JS(需 /u)
\x{1F600}  # 扩展码点:Go(RE2) / PCRE2
\U0001F600 # 扩展码点:Python(8 位十六进制)
十六进制转义位数固定:\xFF 恰好 2 位、\uFFFF 恰好 4 位,位数不符会报错或改变含义(如 Python 写 \x{1F600} 直接报 incomplete escape)。JS 不加 /u\uFFFF 仅覆盖 BMP,星光面字符须用 \u{…}+/u 或代理对。
码点转义写法各异:\uFFFF(4 位十六进制,JS/Python/C++ 通用);星光面码点则 JS 写 \u{1F600}(需 /u)、Go/PCRE2 写 \x{1F600}、Python 写 \U0001F600(8 位十六进制)。

量词

量词控制前一个元素重复的次数。默认贪婪(尽可能多匹配),加 ? 变懒惰(尽可能少),加 + 变独占(不回退)。

* 0+次,+ 1+次,? 0或1次,{n,m} 精确控制。量词作用于紧前面的元素(字符、字符类或分组)。

「量词只管紧前面那一个」的三个后果

  • ab+ 是「a 后面跟一个或多个 b」,不是「ab 重复」——要后者必须写 (?:ab)+
  • \d{3}-\d{4} 里两个 {n} 各管各的 \d,这是对的;但 \d-\d{3}{3} 只作用于第二个 \d,不是「整个 \d-\d 重复三次」;
  • 量词后面直接跟量词(如 a**)大多数引擎会报错,a*+ 在支持独占量词的引擎里是合法的「独占」语义——实测 JS 报 Nothing to repeat,Python 3.11+ 则正常匹配。同一串在两门语言里一个报错一个静默改变语义,这是移植时的隐蔽坑。

{n,m} 的三处写法差异

  • {n} 精确 n 次、{n,} 至少 n 次、{n,m} 闭区间;
  • {,m}(省略下界)不通用——有的引擎当字面量处理,有的当 {0,m}稳妥写法永远是补全 {0,m}
  • 不构成合法量词的 {,JS 与 Python 会宽松地当字面花括号;但这属于「实现宽容」而非规范保证,要匹配字面花括号请老老实实写 \{
*      # 0 次或多次
+      # 1 次或多次
?      # 0 次或 1 次
{n}    # 恰好 n 次
{n,}   # 至少 n 次
{n,m}  # n 到 m 次

# 示例
colou?r      # color 或 colour
\d{4}        # 恰好 4 位数字
\s*,\s*      # 逗号前后允许任意空白
ab+ 是「a 后跟一个或多个 b」,不是「ab 整体重复」——初学者最常踩。另外单独的 { 若不构成合法量词,JS/Python 会宽松地当字面量,但依赖此行为不可移植;确要匹配字面花括号请转义 \{
量词只作用于紧邻的前一个元素(单字符、字符类或分组)。要重复多字符序列必须先分组:(?:ab)+,而不是 ab+。省略下界的 {,m} 并非各处通用,稳妥写 {0,m}
贪婪(默认):尽可能多匹配,匹配失败时回退。懒惰:量词后加 ?,尽可能少匹配。独占:量词后加 +,匹配后不回退,防止灾难性回溯。回溯指:正则先尽量多地匹配,若后续匹配失败,再逐步释放已匹配的字符重新尝试;懒惰量词则相反,先尽量少匹配、不足再补。

三者的差别就是「失败时怎么办」

先匹配多少失败时写法
贪婪(默认)尽可能多逐个吐回来重试a*
懒惰尽可能少逐个多吃一个重试a*?
独占尽可能多不回退,直接判失败a*+
  • 三者匹配到的结果可能完全相同,差别只在中间尝试了多少次——所以「换成懒惰就对了」这种说法多数时候是碰运气;
  • 独占量词的价值不在快,在于把指数级的回溯直接砍掉:它让引擎没有退路可走,从而消灭一整类 ReDoS;
  • 支持度实测:Python 3.11+ 原生支持a*+b"aaab" 正常匹配);JS 报 Nothing to repeat、Go 与 C++ std::regex 也没有。

比懒惰更该先想到的是否定字符类

  • 抓引号内容时,"[^"]*"".*?" 又快又准:前者每一步都是确定的,后者要一步步试探回溯;
  • 更重要的是语义:".*?""a" 和 "b" 这种输入上仍会正确停在第一个引号,但把 . 换成能跨行之后行为就变了;而 [^"\n]* 的边界是写死的,读代码的人一眼能确认;
  • 经验法则:能用「排除法」描述边界,就别用懒惰量词。
# 文本:"<a>text</a>"
<.+>     # 贪婪 → 整个 "<a>text</a>"
<.+?>    # 懒惰 → "<a>"
<[^>]+> # 否定字符类 → "<a>"(推荐,更高效)

# 懒惰量词
*?  +?  ??  {n,m}?

# 独占量词(不回退)
*+  ++  ?+  {n,m}+
否定字符类通常优于懒惰量词。匹配引号内容:"[^"]*"".*?" 更快更准确。
独占量词(*+ ++ ?+)支持:Python 3.11+(re 已原生支持);JS、Go、C++(std::regex) 均不支持——需要时改用 PCRE2。

锚点与边界

锚点匹配的是位置而非字符(零宽)。^ $ 匹配行/串首尾,\b 匹配单词边界。

^$ 匹配行或字符串的首尾(取决于多行模式)。\A \Z \z 始终匹配字符串绝对首尾,不受多行模式影响。\G 匹配上次匹配结束位置。

$ 的行尾语义:一个真实的校验绕过(实测)

Python:  re.match(r"^\d+$",  "123\n")  →  匹配成功
Python:  re.match(r"^\d+\Z", "123\n")  →  不匹配
Python:  re.match(r"^\d+\z", "123\n")  →  不匹配(3.14 起可用)
JS:      /^\d+$/.test("123\n")         →  false
  • Python / PCRE 的 $无多行模式下也会匹配「末尾换行之前」的位置——于是 ^\d+$ 会接受 "123\n"
  • 这不是学术问题:用它做「只允许数字」的输入校验,攻击者在末尾加一个换行就能带进额外字节(后续按行处理时可能被拆成两条);
  • 正解是用绝对末尾锚点:Python 用 \Z(或 3.14+ 的 \z)、Go 用 \z。JS 的 $ 本来就是绝对末尾,没有这个坑;
  • 反过来,开了多行(/m / re.M)之后 ^ $ 才变成每一行的首尾——而 \A \Z \z 不受多行影响,这正是它们存在的意义。

支持度(实测)

  • JS 与 C++ std::regex 完全没有 \A \Z \z——实测 new RegExp("\Ax") 编译通过但当字面量 A 处理,会静默失效,这比报错更危险;
  • Go(RE2)有 \A\z,没有 \Z
  • \G(接续上次匹配位置)四门都没有。
^    # 行/字符串开头
$    # 行/字符串结尾
\A   # 字符串绝对开头(忽略多行模式)
\Z   # 字符串末尾(允许末尾有一个换行)
\z   # 字符串绝对末尾(不允许末尾换行)

# 示例
^\d{4}$         # 整个字符串恰好 4 位数字
^https?://      # 以 http(s):// 开头
\.(jpg|png)$   # 以 .jpg 或 .png 结尾
$ 的行尾语义有坑:Python/PCRE 下 $(无多行)默认还匹配末尾换行前的位置——^\d+$ 会接受 "123\n",校验易被绕过;要严格到串尾请用 \z(Go、Python 3.14+)或 \Z(Python)。JS 的 $(无 /m)只在绝对末尾、不含换行前。开 m/re.M^ $ 才变每行首尾。
\A \Z \z 支持:Go(RE2) 支持 \A \z(无 \Z);Python 支持 \A \Z\Z 即绝对末尾;Python 3.14 起也支持 \z);JS 与 C++ std::regex 都不支持这些,只能用 ^ $(非多行下等效绝对首尾)。\G:这四种语言均不支持。
\b 匹配 \w\W 之间的位置(或字符串首尾),用于精确匹配整个单词。\B 是其取反。

\b 处理中文时直接失效(实测)

Python:  re.findall(r"\b\w+\b", "你好,世界")  →  ['你好', '世界']
JS:      "你好,世界".match(/\b\w+\b/g)          →  null
  • 原因不在 \b 本身,而在它依赖的 \w\b 的定义是「\w 与非 \w 之间的位置」\w 不认中文,边界自然无从谈起;
  • 所以上一张卡那张 \w 对照表在这里直接结账:Python 能用、JS/Go/C++ 完全失效
  • 在 JS 里要做中文「词」边界,只能自己界定:(?<!\p{L})…(?!\p{L})/u,即「前后都不是字母」。但中文本来就不用空格分词,「词边界」这个概念在中文文本上多数时候是伪需求——真要分词该用分词库;
  • \b零宽的:它不消耗字符,所以 \bcat\b 的长度就是 cat,替换时不会误删两侧的字符。
\bcat\b    # 匹配 "cat" 但不匹配 "concatenate" 中的 cat

# 文本:"cat concatenate scat"
cat        # 匹配 3 处
\bcat\b   # 只匹配第 1 处

\bprice\b # "the price." → 匹配(. 是 \W)
Unicode 局限:\b 的「单词字符」定义随语言而异——Python 3(对 str)默认 Unicode-aware,中文会算作 \w;而 JS、Go(RE2)、C++ std::regex\w 默认只认 ASCII,中文算单词字符。因此处理中文等文字的「词边界」时行为不同,常需改用 \p{L} 结合断言来实现。
\b 是零宽位置,典型用法 \bword\b 匹配整词。跨语言差异大:JS、Go、C++ 的 \b 基于 ASCII \w,对中文等无效,需改用 \p{L} 配环视自行界定边界;Python 3 的 \b 默认 Unicode-aware(加 re.ASCII 退回 ASCII)。

分组与捕获

圆括号创建捕获组,支持反向引用和替换引用。非捕获组 (?:) 只分组不记录。命名组提高可读性。

括号内的匹配被编号记住(从 1 开始),可在模式内 \1 反向引用,或在替换中 $1(JS)/ \1(Python)引用。

模式内引用与替换串引用是两套语法

模式内引用第 1 组替换串里引用第 1 组替换串里引用整体
JS\1$1$&
Python\1\1\g<1>\g<0>
Go(RE2)不支持$1 / 1$0
C++\1$1$&
  • 最容易搞混的一点:同一个「第 1 组」,在模式里和在替换串里写法不同,而且不同语言的替换语法还各不相同。跨语言搬正则时,模式往往能直接用,替换串几乎总要改
  • Go 的 1 带花括号版本在「引用后紧跟数字或字母」时是必需的——$1x 会被当成组名 1x
  • 实测 JS:"abc".replace(/b/, "[$&]")a[b]c

Go 为什么没有反向引用

  • RE2 保证匹配时间与输入长度成线性关系,代价就是放弃反向引用与环视——这两样都需要回溯,而回溯正是指数级爆炸的来源
  • 所以这不是「Go 的正则弱」,是一个明确的工程取舍:用能力换「任何输入都不会把服务卡死」的保证
  • 在 Go 里需要反向引用时,标准做法是先用正则粗匹配、再在代码里比较——通常也更好读。
# 捕获与替换引用
(\d{4})-(\d{2})-(\d{2})
# "2024-01-15" → 组1:"2024", 组2:"01", 组3:"15"

# JS 替换
"2024-01-15".replace(/(\d{4})-(\d{2})-(\d{2})/, '$3/$2/$1')
# → "15/01/2024"

# 反向引用:模式内匹配之前捕获的内容
(['"]).*?\1   # 匹配配对引号:'hello' 或 "world"
(\w+) \1    # 匹配重复单词:"the the"

# HTML 标签配对
<(\w+)>.*?</\1>
# 匹配 <div>content</div>
Go(RE2) 不支持反向引用——线性时间引擎为保证性能放弃了它。JS、Python、C++ std::regex 都支持 \1。在 Go 里需要反向引用时只能在代码层面处理,或换用 PCRE2。
模式内引用之前捕获统一用 \1(JS/Python/C++ 一致,Go(RE2) 不支持);替换串里的引用语法则各异:JS 用 $1、Python 用 \1\g<1>、Go 用 $1/${1}、C++ 用 $1;整体匹配各家不同——JS/C++ 用 $&、Go 用 $0${0}、Python 用 \g<0>\0
命名组用名称代替编号,提升可读性。语法因语言而异:JS 用 (?<name>),Python 用 (?P<name>),Go 两种都支持(1.22 起也认 (?<name>));C++ std::regex 不支持命名组(要用需 PCRE2)。

重名组:ES2025 放宽了,但你的运行时未必(实测)

Node 22:  new RegExp("(?<n>a)|(?<n>b)")
          → SyntaxError: Duplicate capture group name
Node 22:  new RegExp("(?:(?<n>a)|(?<n>b))")   ← 互斥分支
          → 同样报错
Python:   re.compile(r"(?P<n>a)|(?P<n>b)")
          → PatternError: redefinition of group name 'n'
  • ES2025 的放宽是「允许出现在互斥的交替分支里」,但实测 Node 22 两种写法都报错——需要 Node 24+ / 较新的 V8
  • 所以「网上说 JS 现在允许重名组了」这条要配上运行时版本一起看,照抄进 Node 22 的项目会直接编译失败
  • 组名还必须是合法标识符:不能以数字开头、不能含连字符。

为什么命名组反而更难跨语言

  • 编号引用 \1 四门里有三门通用;而命名组是声明与引用两处都不一样:JS (?<n>)+\k<n>、Python (?P<n>)+(?P=n)、Go 两种声明都认、C++ std::regex 两种都不认;
  • 所以可读性的收益是本地的,可移植性的损失是全局的
  • 务实的做法:正则只在一门语言里用时尽管命名(可读性提升非常实在);要跨端共用的正则就老实用编号,在代码里给组编号起个常量名。
# JS 命名捕获
const m = "2024-01-15".match(
  /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/
);
m.groups.year;  // "2024"

# JS 替换中引用命名组
"2024-01-15".replace(
  /(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/,
  '$<d>/$<m>/$<y>'
)

# Python 命名组
m = re.search(r'(?P<year>\d{4})', s)
m.group('year')

# 命名反向引用
(?<q>['"]).*?\k<q>     # JS
(?P<q>['"]).*?(?P=q)   # Python
组名须是合法标识符且通常不可重名——Python 重名直接报错,JS 在 Node 22 等主流在用引擎里同样一律报错(ES2025 放宽为「允许出现在互斥的交替分支」,但需较新的 V8/Node 24+)。加之命名反向引用写法不通用(\k<n>(?P=n)),含命名组的正则难以跨语言照搬。
命名组语法矩阵:JS (?<n>)、反向引用 \k<n>;Python (?P<n>)、反向引用 (?P=n);Go 自 1.22 起 (?<n>)(?P<n>) 皆可;C++ std::regex 两种都不支持(需 PCRE2)。
(?:) 只分组不记录,性能更好。| 优先级最低,通常配合分组使用。原子组 (?>) 匹配后禁止回溯。

| 的优先级低到超出直觉

^cat|dog$    实际含义是  (^cat) | (dog$)
                        不是  ^(?:cat|dog)$

「abc」 匹配 ^cat|dog$ ?  否
「cat123」 匹配?          (^cat 命中)
「123dog」 匹配?          (dog$ 命中)
  • |整个正则里优先级最低的运算符,两侧会一路延伸到最近的分组边界或字符串边界;
  • 规则很简单:只要用了 |,就把它包进 (?:…)——哪怕看起来不需要;
  • 另一半是顺序:回溯引擎的交替是「最左优先」而不是「最长优先」。(?:a|ab)"ab" 只会吃到 a更长的分支必须放前面。这与 POSIX 引擎的「最长匹配」语义相反,是从 awk/grep 迁过来的人最容易出错的一处。

(?:) 与 (?>) 别混

  • (?:…) 只是不记录捕获,回溯行为与普通分组完全一样——它省的是内存和编号,不是回溯;
  • (?>…)原子组:一旦匹配完成就丢弃组内所有回溯点,是真正能砍掉回溯的那个;
  • 支持度实测:Python 3.11+ 原生支持 (?>…);JS 实测报 Invalid group,Go 与 C++ std::regex 也不支持。
# 非捕获组:不记录
(?:red|blue|green)  # 匹配三色之一,不捕获
(?:https?://)?      # 可选的协议前缀
gr(?:a|e)y           # gray 或 grey

# 交替
cat|dog              # "cat" 或 "dog"
(?:cat|dog)s?        # cat/cats/dog/dogs

# 原子组:匹配后禁止回溯
(?>abc|ab)   # 匹配到 abc 就不再尝试 ab
| 优先级最低,会一路吃到两侧的边界:^cat|dog$ 意思是「^cat」或「dog$」,而非 ^(?:cat|dog)$——限定范围必须分组。且交替是有序、最左优先:回溯引擎里 (?:a|ab)"ab" 只会先吃到 a,应把更长的分支放前面。
原子组 (?>…) 支持:Python 3.11+(re 已原生支持);JS、Go、C++(std::regex) 不支持——需要时改用 PCRE2。

断言(环视)

零宽断言只检查位置,不消耗字符。分为先行(Lookahead)和后行(Lookbehind),各有正向和负向。

先行断言检查当前位置后面的内容:(?=p) 后面必须匹配 p,(?!p) 后面不能匹配 p。后行断言检查前面的内容:(?<=p) 前面必须匹配 p,(?<!p) 前面不能匹配 p。断言只在字符之间的位置检查条件是否成立,本身不消耗任何字符(零宽),因此后续匹配仍从原位置继续。

四种断言的记法

写法朝向要求
(?=p)后面后面必须能匹配 p
(?!p)看后面后面不能匹配 p
(?<=p)前面前面必须能匹配 p
(?<!p)看前面前面不能匹配 p
  • 记法:带 < 的向后看(lookbehind),不带的向前看。 < 这个尖角就指向「前文」的方向;
  • 四种全是零宽:断言成立后位置不前移,所以同一个位置可以叠加任意多个断言——这正是密码强度校验那类「多条件同时满足」的实现方式。

支持度:这是四门差距最大的一处(实测)

先行 (?=)(?!)后行 (?<=)(?<!)后行变长
JS支持支持支持(实测 (?<=a+)c 可用)
Python支持支持不支持(实测报 look-behind requires fixed-width pattern
Go(RE2)完全不支持完全不支持
C++ std::regex支持不支持
  • JS 的后行断言反而是四门里最强的(ES2018 起支持变长),这与「JS 正则弱」的刻板印象正好相反;
  • Go 的「完全不支持」和上一章反向引用是同一个原因:RE2 用能力换线性时间保证
  • 结论很实际:只要这条正则可能要跑在 Go 上,就别用环视;要跨四门通用,能用的只有「先行断言 + 定长后行」这个交集。
# 正向先行 (?=...)
\d+(?= dollars)
# "100 dollars, 50 euros" → "100"

# 负向先行 (?!...)
\bJava(?!Script)\b
# 匹配 "Java" 但不匹配 "JavaScript"

# 正向后行 (?<=...)
(?<=\$)\d+
# "$42" → "42"(不含 "$")

# 负向后行 (?<!...)
(?<!\d)\d{3}(?!\d)
# 仅匹配独立的 3 位数
Go(RE2) 完全不支持环视断言(线性引擎限制),需在代码层面处理或换用 PCRE2。JS(ES2018+)、Python 的先行 / 后行都支持(Python 后行须定长);C++ std::regex 只支持先行 (?=) (?!)不支持后行
环视是零宽的:匹配成功后位置不前移,所以同一处可叠加多个断言做多条件校验。判断朝向看有没有 <——带 <(?<=)/(?<!) 向「后」看前文,不带的 (?=)/(?!) 向「前」看后文。
多个先行断言可叠加在同一位置,实现「同时满足多条件」的校验。后行断言可用于提取特定前缀后的内容。

密码校验:逐个断言各查一件事

^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$
  │ └─ 有小写   └─ 有大写   └─ 有数字   └─ 长度 ≥ 8
  └─ 从头开始,三个断言都在同一个位置检查,彼此不消耗字符
  • 拆开写的价值不只是可读:加一个规则就多一个 (?=…),去掉一个就删一行,不用重排整条正则;
  • 而且报错时能定位:想告诉用户「缺少数字」,就把这几个断言拆成几条独立正则分别测——一条大正则只能给出「不合格」;
  • 推论:密码校验其实不该塞进一条正则。这个例子最好的用处是理解「零宽 + 可叠加」,而不是照抄进生产代码。

千位分隔:一个纯位置匹配

JS:  "1234567".replace(/\B(?=(\d{3})+(?!\d))/g, ",")  →  1,234,567
        └─ 不在词边界      └─ 后面剩的数字个数是 3 的整数倍
  • 它匹配的是位置而不是字符——所以 replace 是在往位置里「插入」逗号,原数字一个都没被改;
  • 这条正则依赖后行断言吗?不依赖,只用了先行——所以它是少数几个四门里都能跑的环视用法之一(Go 除外,Go 连先行都没有);
  • Python 里同样写法可用,但更地道的是 f"{n:,}"——有内置格式化就别写正则
# 密码强度验证(多先行叠加)
^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[@$!]).{8,}$
# 必须含大写、小写、数字、特殊符号,至少 8 位

# 数字千位分隔符
(?<=\d)(?=(\d{3})+$)
"1234567".replace(/(?<=\d)(?=(\d{3})+$)/g, ',')
// "1,234,567"
后行断言在 Python 里必须定长(?<=ab) 行,(?<=a+) 报错;JS 无此限制。而 Go(RE2)、C++ std::regex 连后行都没有,别把这类校验全塞进一条正则、指望四门通用。
密码强度校验是经典用法:^(?=.*[a-z])(?=.*[0-9]).{8,}$ 用多个先行断言各查一个条件,彼此不消耗字符、位置不动。

修饰符 / 标志

标志改变正则引擎的整体行为:大小写、多行、dotAll、自由间距等。可全局设置或内联开关。

各标志的含义与语法。g 是 JS 专属概念,其他语言通过 API 控制全局匹配。

标志在四门里的表达方式完全不同

怎么写有没有「全局」
JS字面量后缀 /gimsuvydg 是标志
Pythonre.I/re.M/re.S/re.X 或内联 (?i)findall/finditer
Go内联 (?i) 等,只有 i m s UFindAll*
C++构造参数 std::regex::icaseregex_iterator
  • 只有 JS 把「全局」做成了正则的属性,其余三门都把它做成了 API 的选择——这个设计差异直接导致了下面那个坑。

lastIndex:JS 独有的状态陷阱(实测)

const r = /a/g;
r.test("aa")  →  true   lastIndex = 1
r.test("aa")  →  true   lastIndex = 2
r.test("aa")  →  false  ← 同一个字符串,第三次就 false 了
  • g(或 y)的正则对象是有状态的test/exec 会从 lastIndex 继续找,并把它往后推;
  • 于是把正则提成模块级常量再反复 test结果会随调用次数「跳着变」——这是 JS 里最经典、也最难查的一个正则 bug,因为单测里通常只调一次;
  • 三种解法:①校验用途别加 g(最简单);②每次现造一个正则;③手动 r.lastIndex = 0
  • String.prototype.match/replace/matchAll 会自己处理这件事,只有 testexec 会踩
i   # 忽略大小写          /pat/i  re.I
g   # 全局匹配(JS 专属)  /pat/g
m   # 多行:^ $ 匹配每行   /pat/m  re.M
s   # dotAll:. 匹配换行   /pat/s  re.S
x   # 自由间距:空格+注释   re.X(JS 不支持)
u   # Unicode 模式(JS)   /pat/u
v   # Unicode Sets(ES2024)/pat/v
y   # 粘性匹配(JS)       /pat/y
d   # 子串索引(ES2022)   /pat/d
g(全局)是 JS 独有——Python 用 re.findall/finditer,Go 用 FindAll,C++ 用 regex_iterator。JS 里带 g 的正则对象有 lastIndex 状态,复用同一对象反复 test() 会「跳着匹配」,是高频坑。
标志名各语言不同:JS 用字面量后缀 /gimsuy;Python 用 re.I/M/S/X 常量或内联 (?i);Go 只有 i m s U x g);C++ 用 std::regex::icase 等构造标志。
x 模式忽略空白并允许 # 注释,极大提升长正则的可读性。Python 支持(re.X);JS、Go、C++(std::regex) 均不支持。

开了 x 之后,空格的含义变了(实测)

Python:  re.match(r"a b", "ab", re.X)   →  匹配(模式里的空格被忽略)
Python:  re.match(r"[ a]", " ", re.X)   →  匹配字符类内的空格仍是字面量)
  • 规则是「类外忽略、类内保留」——所以开了 x 之后要匹配真正的空格,写 [ ]\s 或转义的 \
  • # 之后到行尾算注释,要匹配字面 # 得转义
  • JS 没有 x 标志(实测 new RegExp("a b", "x")Invalid flags),只能用字符串拼接来模拟——但拼接会把注释留在 JS 代码里而不是正则里,效果其实差不多。

什么时候值得开

  • 判据很直接:这条正则超过一行,或者三个月后你还要改它
  • 没有注释的长正则是一次性代码:出问题时多数人的做法是重写而不是读懂它——这本身就说明它没起到「代码」的作用;
  • 更进一步的做法是拆成几条短正则 + 代码里的逻辑。一条能塞进 x 模式写满二十行的正则,通常也能拆成三条各自可测的短正则。
# Python 示例
email_pat = re.compile(r"""
  ^                   # 字符串开头
  [\w.+-]+            # 用户名
  @                   # @ 符号
  [\w-]+              # 域名主体
  (?:\.[\w-]+)*       # 可选子域
  \.                  # 最后一个点
  [a-zA-Z]{2,}        # 顶级域名
  $                   # 字符串结尾
""", re.VERBOSE)
开了 x 模式后模式里的空格被忽略,想匹配真正的空格得写 \ (转义)、[ ]\s;但字符类 [...] 内部的空格不受影响、仍是字面量。
复杂正则一定要加注释。没有注释的长正则是「一次性代码」,一个月后连自己都看不懂。JS 中可用字符串拼接或 XRegExp 库模拟。
在模式内部局部开启/关闭标志。Go(RE2)、Python 完整支持 (?i)(?i:…);JS 传统上不支持内联标志(全局 (?i) 与分组 (?i:…) 皆无),ES2025 虽新增 (?i:…) 分组修饰符但需较新引擎(Node 24+,Node 22 尚不支持);C++ std::regex 不支持内联标志(改用 std::regex::icase 等常量)。

四门的支持度差到无法通用(实测)

全局 (?i)分组 (?i:…)
Go(RE2)支持支持
Python支持,但必须在模式最开头支持
JS(Node 22)Invalid groupInvalid group
C++ std::regex不认(静默失效或报错)不认
  • Python 的位置限制是实测出来的:Hello(?i)world 直接报 global flags not at the start of the expression——而这个写法在 Perl/PCRE 里是合法的,从那边搬过来就会炸;
  • ES2025 给 JS 加了 (?i:…),但实测 Node 22 仍不支持,需要更新的引擎;
  • C++ 的情况最危险:(?i)abc 可能既不报错也不生效,只能靠构造时传 std::regex::icase——移植时这类「静默失效」比报错难查得多
(?i)      # 从此处开始忽略大小写
(?-i)     # 关闭忽略大小写
(?i:pat)  # 仅在此分组内忽略大小写

# 示例
Hello(?i)world
# Hello 区分大小写,world 不区分
C++ std::regex 完全不认内联标志:(?i)abc 不会开启忽略大小写,只能在构造时传 std::regex::icase。把 (?i) 直接从别的语言搬进 C++ 正则会静默失效或报错,是移植时的隐蔽坑。
Python 限制:Python 3.11+ 要求「全局」内联标志(如 (?i))必须置于模式最开头,写在中间(如 Hello(?i)world)会直接报错;需局部作用时改用分组形式 (?i:…)

高级特性

Unicode Sets、递归模式、条件分组、\K 重置、ReDoS 安全——超越日常使用的进阶能力。

/v 标志(ES2024)在字符类内支持集合差集 -- 和交集 &&,可以精确组合 Unicode 属性。

它不是 /u 的超集,是替代品(实测)

new RegExp("a", "uv")   →  SyntaxError: Invalid flags   ← 只能二选一
new RegExp("[[]", "u")  →  允许(类内未转义的 [ 当字面量)
new RegExp("[[]", "v")  →  SyntaxError: Unterminated character class
  • /v 换来的是类内的集合运算:差集 --、交集 &&、以及多字符元素——这让「所有字母但排除 ASCII」这种需求能直接写出来;
  • 代价是语法更严格:一批在 /u 下宽容通过的写法会直接报错。所以从 /u 切到 /v 不是「加个字母」,要重测
  • 这是 JS 独有的特性,Python / Go / C++ 都没有对应物——跨语言共用的正则里别出现它。
# /u 标志:正确处理 Unicode(ES2015)
/^\p{L}+$/u    # 纯字母字符串
/\p{Emoji}/u   # 匹配 emoji
/\p{Script=Han}/u # 匹配汉字(JS 里 script 名须写 Script=)

# /v 标志:集合运算(ES2024)
/[\p{Letter}--\p{ASCII}]/v  # 非 ASCII 字母
/[\p{Letter}&&\p{ASCII}]/v  # ASCII 字母
/[[a-z]&&[^aeiou]]/v        # 小写辅音字母
/v/u 互斥、只能二选一;/v 下字符类语义更严格,某些在 /u 能用的写法(如类内未转义的 [)会直接报错。此特性 JS 独有,Python/Go/C++ 无对应物,跨语言正则别依赖它。
浏览器支持:Chrome 112+、Node 20+、Safari 17+。
递归模式用正则匹配嵌套结构(如括号)。JS / Go / Python 标准库 / C++ std::regex 都没有递归,需借助 PCRE2(C++ 可链接):(?R)(?0) 递归整个模式,(?N) 递归第 N 组。

该停手的信号

  • 需要递归,通常意味着你在匹配的是一门语言而不是一个模式:嵌套括号、JSON、HTML、配置文件——这些都该用解析器
  • 经典结论「正则不能解析 HTML」的准确说法是:正则(正规语言)表达不了任意深度的嵌套结构(上下文无关文法)。PCRE 的递归扩展本身已经超出了「正则」的定义;
  • 四门标准库(JS、Python re、Go、C++ std::regex全都不支持递归;真需要时用 PCRE2 或 Python 的第三方 regex 库;
  • 即便能用,递归正则性能开销大、嵌套深时会栈溢出,而且几乎不可维护——它在工程里的正确用途是「一次性脚本」,不是产品代码
# PCRE2:(?R) 递归整个模式
\((?:[^()]*|(?R))*\)
# 匹配任意深度嵌套括号:((a+b)*c)

# 递归第 1 组(等价写法)
(\((?:[^()]*|(?1))*\))
递归正则性能开销大,嵌套深时容易栈溢出。工程中通常用解析器(Parser)代替。JS、Go、Python 标准 re、C++ std::regex 都不支持递归——需要时用 PCRE2 或 Python 的 regex 库。
四门标准库(JS、Python re、Go、C++ std::regex都不支持递归——遇到嵌套括号、JSON、HTML 这类结构别硬用正则,用真正的解析器。确需正则递归时上 PCRE2 或 Python 第三方 regex 库的 (?R)
根据某个捕获组是否匹配成功,选择不同的子模式。Python(re) 支持;JS、Go、C++(std::regex) 不支持(要用需 PCRE2)。

四门里只有 Python 有(实测)

Python:  re.match(r"(a)?(?(1)b|c)", "ab")  →  匹配
Python:  re.match(r"(?(9)b|c)", "c")       →  PatternError: invalid group reference 9
JS:      new RegExp("(a)?(?(1)b|c)")       →  SyntaxError: Invalid group
  • 语义是「若第 1 组匹配成功走 yes 分支,否则走 no 分支」——用来表达「有开括号就必须有闭括号」这类成对约束;
  • 引用的组必须真实存在,Python 会直接报错而不是当条件为假
  • 可读性极差是它最大的问题(?(1)b|c) 这串符号里没有任何一个字面上提示「这是条件」。多数场景写两条独立正则、或在代码里分支,都比它清楚得多。
# 语法:(?(condition)yes|no)
(\()?\d+(?(1)\))
# 如果有左括号(组1匹配),则要求右括号
# 匹配: "123" 或 "(123)"
# 不匹配: "(123" 或 "123)"
条件引用的组号 / 组名必须真实存在,在 Python 里引用不存在的组会直接报错。这类写法可读性极差,多数场景用两条独立正则或代码里的分支更清晰。
条件分组 (?(1)yes|no) 意为「若第 1 组匹配成功走 yes 分支,否则走 no」。四门里只有 Python 标准库 re 支持(实测可用);JS、Go、C++ std::regex 都不支持,需 PCRE2。
\K 丢弃已匹配的部分,重置匹配起点。比后行断言更灵活(允许变长前缀)。

它在四门标准库里根本不存在(实测)

Python:  re.search(r"ab\Kc", "abc")   →  PatternError: bad escape \K
JS:      new RegExp("ab\Kc")          →  编译通过,但 \K 被当成字面字符 K
  • JS 的行为比 Python 危险:不报错、静默当字面量——照抄 PCRE/Perl 的 \K 过来会得到一条永远匹配不上的正则,而且没有任何提示;
  • \K 的价值在于「变长后行」前缀\K目标 等价于 (?<=前缀)目标 但前缀可以是任意长度。而 Python 的后行必须定长(实测 (?<=a+) 直接报错);
  • 替代方案:用捕获组取子串。 把「前缀」和「目标」都放进模式,只取目标那一组——多一行代码,但四门通用。
\bfoo\K\s*bar\b
# "foo bar" → 匹配 " bar"(foo 被 \K 重置掉)
# 等效于 (?<=\bfoo)\s*bar\b 但 \K 更灵活
别把 \K 当后行断言的等价替换:它在四门标准库(JS、Python re、Go、C++ std::regex)里根本不存在,照抄 PCRE/Perl 的 \K 过来会报错或被当字面量。需要「变长后行」效果时改用捕获组取子串。
支持:需 PCRE2 或 Python 的 regex 库。JS、Go、Python 标准 re、C++ std::regex 均不支持。
ReDoS(正则拒绝服务攻击):有缺陷的正则在恶意输入下产生指数级回溯。核心危险模式是嵌套量词

爆炸的形状:嵌套量词 + 可重叠的分支

危险:  (a+)+$        (a|a)*$       (\s*\S*)+$
        └─ 量词套量词,同一段输入有指数级多种拆分方式

对 "aaaaaaaaaaaaaaaaaaaaaaaaaaX" 这类输入,
引擎要把每一种拆分都试一遍才能确定失败
  • 触发条件有三个,缺一不可:①回溯型引擎;②嵌套量词或可重叠的交替;③一个「差一点就匹配上」的输入
  • 第三条决定了它难以在测试中发现——正常输入都很快,只有精心构造的输入才炸;
  • 三条防线,按可靠性排序:①用 RE2(Go 的标准库、以及各语言的 re2 绑定)——线性时间是引擎级保证;②用独占量词/原子组砍掉回溯(Python 3.11+);③给正则匹配加超时;
  • 不要靠「把正则写好一点」当防线——能不能看出一条正则有指数回溯,本身就是很难的判断。这也正是 Go 当初选择 RE2、放弃反向引用与环视的理由。
# 危险模式(嵌套量词)
(a+)+      # "aaaaab" → 灾难性回溯
(a|a)+     # 同样危险
(\d+)*$    # 非数字结尾 → 灾难性回溯

# 修复策略
# 1. 用否定字符类代替嵌套量词
"[^"]*"     # 代替 ".*?"
# 2. 使用独占量词(如语言支持)
\d++
# 3. 限制输入长度
# 4. 设置超时(某些语言/库支持)
# 5. 用 RE2 引擎(线性时间保证)
任何从用户输入构建正则的场景都必须防范 ReDoS。JS、Python re、C++ std::regex 都是回溯引擎,没有时间保证;Go(regexp) 基于 RE2、线性时间,天然免疫。
防 ReDoS 实用招:避开嵌套量词与重叠可选分支(如 (a+)+(a|a)*);能锚定就加 ^…$;对不可信输入设超时。或直接选线性引擎——Go 的 regexp(RE2)线性时间、天然免疫。

常用实战模式

工程中最常被复制的模式:邮箱、URL、数字、日期、IP、中文场景。先记住一点:邮箱、URL 等的「完全严谨」规则极其复杂,实战通常用「足够好」的宽松版,再交给业务逻辑二次校验。下方每条都可复制到「实时测试台」试跑。

日常够用的宽松版。真正的邮箱标准(RFC 5322)几乎无法用可读正则覆盖,别追求「一条正则搞定一切」。

邮箱:正则校验是个陷阱

  • 完整符合 RFC 5322 的邮箱正则有几千个字符,而且即便完全正确,也证明不了这个地址真的存在;
  • 业界共识是「只做最粗的形状检查 + 发验证邮件」:形状上只要求「有 @、@ 前后非空、后面有点」就够了;
  • 严格的正则反而会误伤合法地址:加号别名(a+tag@b.com)、新顶级域(.museum)、国际化域名,都被大量流传的「严谨邮箱正则」拒之门外;
  • 结论:这类正则的正确用途是「拦住明显的手滑」,不是「保证有效」。

URL:先找有没有现成的解析器

  • JS 有 new URL()、Python 有 urllib.parse、Go 有 net/url——它们都比正则准确,而且直接给你拆好的各个部分
  • 正则该出场的场合是「从一段自由文本里找出 URL」(解析器做不到),而不是「校验这个 URL 合不合法」;
  • 即便是提取,边界也很难:URL 末尾的句号、中文括号、Markdown 语法都会干扰。做完提取再交给解析器验一遍是最稳的组合。
# 邮箱(日常够用版)
[\w.+-]+@[\w-]+\.[\w.-]+
# user.name+tag@sub.example.com

# URL(http / https)
https?://[^\s/$.?#].[^\s]*
# http(s):// 开头、后接域名与路径

# 域名
(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}
# example.com、a.b.co.uk
别在正则里硬抠邮箱合法性——真要严格校验,发一封验证邮件比任何正则都靠谱。
这三条都不含环视或反向引用,JS·Python·Go·C++ 四门可原样编译并匹配(已实测)。抽取 URL 时注意 [^\s]* 是贪婪的,实测 https://ex.com/a). 会把结尾的 ). 也一并吞入,按需再裁掉尾部标点。
注意锚点:单独提取用 \b,整串校验加 ^...$

校验数字时最容易漏的四种输入

  • 前导零007 该不该收?\d+ 会收;
  • 正负号与小数点-1.5.51. 各算不算?
  • 科学计数法1e10\d+ 下会被拆成 110
  • 末尾换行:Python/PCRE 下 ^\d+$ 会接受 "123\n"(见「位置锚点」那一卡的实测),校验场景必须用 \z/\Z
  • 金额还要多想一层:别用浮点存钱。正则只负责「形状对不对」,值该用定点数或整数分来存。
# 整数(可带正负号)
-?\d+

# 小数 / 浮点
-?\d+(?:\.\d+)?          # 42、3.14、-0.5

# 金额千分位:1,234,567.89
\d{1,3}(?:,\d{3})*(?:\.\d+)?

# 给纯数字插入千分位(配合替换)
\B(?=(?:\d{3})+(?!\d))     # 每 3 位前的位置 → 替换为 ,

# 十六进制颜色
#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b
金额正则只认「格式像」而非「值合法」:不加锚点时 \d{1,3}(?:,\d{3})* 会从错误分组的串里截取片段,实测 1,23,456 只匹配到 1。整串校验务必加 ^…$,别在长串里裸抽。
千分位插入靠零宽前瞻 \B(?=(?:\d{3})+(?!\d)) 定位,配全局替换插入逗号,实测 12345671,234,567。但它用了前瞻,Go(RE2) 不支持环视(实测报 unsupported Perl syntax: (?=),Go 里需改用循环手写。
格式匹配 ≠ 合法性校验:2 月 30 日、13 月这类正则很难排除,真正校验交给日期库。

正则只能验形状,验不了「这天存在吗」

  • \d{4}-\d{2}-\d{2} 会痛快地接受 2026-02-312026-13-45
  • 把月份和天数的合法组合写进正则是可能的((?:0[1-9]|1[0-2]) 之类),但闰年规则写不进去——写得进去也没人愿意维护;
  • 正确分工:正则粗筛形状,然后交给日期库真正解析。 解析失败就是无效日期,这一步顺便还替你处理了时区与格式变体;
  • 更好的做法是从一开始就用 ISO 86012026-07-26T12:00:00Z),格式唯一、可直接字典序排序、各语言都有现成解析。
# 日期 YYYY-MM-DD(宽松)
\d{4}-\d{2}-\d{2}

# 月、日限定范围(更严一点)
\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])

# 时间 HH:MM(:SS),24 小时制
(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?

# ISO 8601 时间戳
\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?
缺锚点时 \d{4}-\d{2}-\d{2} 会在更长串里截取子串:实测 2024-01-155 仍匹配出 2024-01-15。另外 ISO 那条的 Z? 只是可选,实测遇到 +08:00 偏移时只匹配到秒、把时区丢掉。
这几条日期时间正则都不含环视/反向引用,四门语言可原样移植。范围收紧靠非捕获选择支 (?:0[1-9]|1[0-2]) 之类,但仍只管格式——2 月 30 日这类非法日期正则难以排除,交给日期库。
IPv4 要精确到 0–255 得按段限定;简版只管「点分四段数字」。

\d{1,3} 四段是错的

  • \d{1,3}(\.\d{1,3}){3} 会接受 999.999.999.999——每段的上界是 255,正确写法要按位数分支:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)
  • 端口同理:上界 65535\d{1,5} 会放进 99999
  • IPv6 用正则基本不现实:压缩写法(::)、内嵌 IPv4、区域标识符加起来的组合太多;
  • 结论和日期一样:有解析函数就用解析函数。 Python ipaddress、Go net.ParseIP、JS 侧的成熟库——它们连「这是不是私有地址」都能一并告诉你。
# IPv4(简版:点分四段)
(?:\d{1,3}\.){3}\d{1,3}

# IPv4(精确 0–255)
(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)

# 端口号(常用简版)
:\d{1,5}\b
简版 (?:\d{1,3}\.){3}\d{1,3} 会放行非法 IP,实测 999.999.999.999 也判真;精确版 (?:25[0-5]|2[0-4]\d|1?\d?\d) 不加锚点又会在长数字里截取,实测 1234.5.6.78 被抽出 234.5.6.78。要校验就用精确版并加 ^…$
完整 IPv6 正则极长,实战建议用语言内置的 IP 解析函数,而非手写正则。
匹配汉字优先用 Unicode 属性:JS 要写 \p{Script=Han}(配 u 标志),Go(RE2) 与 PCRE2 支持简写 \p{Han},Python 标准 re 与 C++ std::regex 则不支持;最通用的退路是码点区间 [\u4e00-\u9fa5]

匹配汉字:四门四种写法

写法
通用(四门皆可)[\u4e00-\u9fa5]基本区,不含扩展区与生僻字)
JS\p{Script=Han} + /u不能简写成 \p{Han},实测报错)
Go\p{Han}(原生支持)
Python标准库没有 \p{},用码点区间或第三方 regex
  • \u4e00-\u9fa5 只覆盖基本区:姓名里的生僻字(如「𰻞」)在扩展区,会被这条正则判为非法——做姓名校验时这是真实的投诉来源
  • 要覆盖全,用 \p{Script=Han}(JS/Go);Python 侧只能列多个区间或换库。

手机号与身份证:正则做不到的那一半

  • 手机号:号段是会变的(工信部不断放号)。把号段写死进正则,意味着每次放新号段都要改代码——更稳的做法是只校验「1 开头的 11 位数字」,真实性交给短信验证码
  • 身份证:18 位的最后一位是校验码,用前 17 位加权求和算出来。正则完全表达不了这个计算——只写正则的校验会放进大量伪造号;
  • 两者的共同结论:正则负责形状,业务规则负责有效性。 把业务规则往正则里塞,得到的是又长又错、还要不断修改的东西。
# 汉字(JS:需 u 标志)
\p{Script=Han}+
# 简写 \p{Han}:Go 支持;Python 标准库 / C++ 不支持
# 汉字(码点区间,处处可用)
[\u4e00-\u9fa5]+

# 中国大陆手机号(宽松初筛)
1[3-9]\d{9}

# 邮政编码(6 位)
[1-9]\d{5}

# 18 位身份证(含 X 结尾)
\d{17}[\dXx]
手机号 / 身份证正则只做「格式初筛」。号段会变、身份证末位是校验码——严格校验必须在代码里做(如身份证的 ISO 7064 校验)。
退路 [\u4e00-\u9fa5] 只覆盖基本汉字区(到 U+9FA5),会漏掉后续扩展字:实测 鿿(U+9FFF)用它匹配失败,改成 [\u4e00-\u9fff] 才命中;要连生僻字/Ext-B 一起收,仍以 \p{Script=Han}(JS 配 u)或 Go 的 \p{Han} 最全。

实时测试台

在浏览器中直接测试正则表达式,实时查看匹配结果和高亮。基于 JS 引擎。

输入正则和测试文本,实时查看匹配结果与高亮。点击标志按钮切换 g/i/m/s/u——开启 u 才能测试 \p{…}\u{…} 等 Unicode 写法

这个测试台跑的是 JS 引擎

  • 它直接调浏览器的 RegExp,所以看到的行为等于 JS 的行为:后行断言可以变长、\w 不认中文、没有 (?i) 内联标志、没有独占量词;
  • 要验证 Python/Go 的行为,必须去对应的运行时上试——本页每张卡的支持度矩阵就是这么逐条跑出来的;
  • 用它调正则时的一个习惯:先构造「应该匹配」和「不应该匹配」两组输入。只测前者的话,一条过宽的正则永远显得是对的。
//
gimsu
匹配结果
在上方输入正则表达式…
高亮预览
快速加载示例

各语言 API

JavaScript、Go、Python、C++ 四种语言的正则 API 速查。差异主要在构造方式、命名组语法与全局匹配方式;各引擎能力(环视、反向引用、原子组等)见前文各特性卡里的「支持」说明。

JS 正则是一等公民,有字面量语法和构造函数两种创建方式。核心方法在 RegExp 和 String 原型上。

JS 侧最该记住的三件事

  • g 的正则对象有状态lastIndex 会累加,反复 test() 同一个字符串会跳着给出 true/true/false(实测)。校验用途别加 g
  • \w\b 只认 ASCII,加 /u 也不改(实测 "你好".match(/\w+/gu)null)。中文要用 \p{Script=Han}
  • /u/v 互斥(实测传 "uv" 直接报 Invalid flags);
  • 好消息是 JS 的后行断言不要求定长,这一点强于 Python。
// 创建
const r1 = /\d+/g;
const r2 = new RegExp('\\d+', 'g'); // 动态模式

// 测试
/^\d+$/.test("123");           // true

// 匹配
"abc123".match(/\d+/);         // ["123", index:3, ...]
[..."a1b2".matchAll(/\d/g)];   // ES2020 迭代器

// 替换
"foo bar".replace(/(\w+)/g, '[$1]');
"foo".replace(/(\w+)/, (m, g1) => g1.toUpperCase());

// 拆分、搜索
"a1b2c3".split(/\d/);           // ["a","b","c",""]
"hello123".search(/\d+/);       // 5
g 的 RegExp 是有状态的:test()/exec() 会推进 lastIndex,同一个 /\d/g 连续 test() 实测交替返回 truefalse。循环里别复用同一个全局正则做 test(),否则漏配。
String.prototype.match 不带 g 时返回带捕获组和 index 的数组,带 g 则只返回所有整体匹配、丢掉分组与 index(实测 "a1b2".match(/(\d)/g)["1","2"])。既要分组又要位置改用 matchAll;替换里用 $1$&$<name> 引用。
Go 标准库基于 RE2 引擎,保证线性时间,但不支持环视断言和反向引用。原始字符串用反引号免转义。

RE2 的取舍是本页最值得理解的一处

  • Go 标准库用 RE2,保证匹配时间与输入长度成线性关系——代价是砍掉了反向引用环视断言,因为这两样都需要回溯;
  • 这不是「功能少」,是把 ReDoS 这一整类安全问题从工程里消除掉:任何用户输入的正则、任何用户输入的文本,都不可能把服务卡死
  • 所以在 Go 里遇到「这条正则翻译不过来」时,正确反应不是找绕过办法,而是把那部分逻辑挪到代码里——通常也更好读、更好测;
  • Go 从 1.22 起 (?<name>)(?P<name>) 两种命名组语法都认,比以前好搬了一些。
import "regexp"

r := regexp.MustCompile(`\d+`)

r.MatchString("abc123")        // true
r.FindString("abc123")         // "123"
r.FindAllString("a1b2", -1)  // ["1","2"]
r.ReplaceAllString("a1b2", "X")

// 捕获组
r2 := regexp.MustCompile(`(\d+)-(\d+)`)
r2.FindStringSubmatch("12-34")
// ["12-34","12","34"]
Go 不支持:环视断言、反向引用、独占量词、原子组。需要这些时考虑用 C 绑定的 PCRE2 库。
MustCompile 编译失败直接 panic(适合包级常量),要处理错误改用 regexp.CompileMatchString/FindString 都是 search(任意位置)语义,要整串匹配得自己加 ^…$(RE2 无专门的锚定方法)。替换引用组用 $1${1}——实测 $1x 会被当成组名 1x 而替成空,紧跟字母数字时必须写 ${1}
标准库 re 覆盖大部分场景。需要 \p{}、原子组等高级特性时用第三方 regex 库。注意 match() 只匹配开头。

标准库 re 的强项与短板(实测)

  • 强项\w \b \d 默认 Unicode-aware(re.findall(r"\w+", "你好 abc")['你好', 'abc']);3.11+ 支持独占量词与原子组;四门里唯一支持条件分组;有 re.X 自由间距;
  • 短板没有 \p{}(实测 bad escape \p);后行断言必须定长(实测 (?<=a+)look-behind requires fixed-width pattern);没有递归、没有 \K
  • 两个坑re.match 只从开头匹配(要搜全串用 re.search);$ 会匹配末尾换行之前的位置,严格校验要用 \Z(3.14+ 亦可用 \z
  • 需要 \p{}、变长后行、递归时,装第三方 regex 库——API 与 re 兼容,可以直接换。
import re

# 编译(复用时推荐)
pat = re.compile(r'\d+', re.IGNORECASE)

# match 只匹配开头,search 搜索整个字符串
re.match(r'\d+', '123abc')    # Match 对象
m = re.search(r'\d+', 'abc123')
m.group()  # "123"
m.span()   # (3, 6)

# 所有匹配
re.findall(r'\d+', 'a1b22c3')   # ['1', '22', '3']
re.finditer(r'\d+', s)          # 迭代器

# 替换、拆分
re.sub(r'(\w+)', r'[\1]', 'foo bar')
re.split(r'\s+', '  a  b  c  ')

# 命名组用 ?P<name>
m = re.search(r'(?P<year>\d{4})', '2024-01')
m.group('year')  # "2024"
re.findall 在正则含捕获组时返回的是组内容而非整体匹配,多个组时返回元组列表:实测 re.findall(r'(a)(b)?','a ab')[('a',''),('a','b')]。想要整体匹配请用 re.finditer 再取 m.group()
re.match 只从开头锚定、re.search 搜任意位置、re.fullmatch 要求整串匹配:实测 re.match(r'\d+','abc123')None,换成 '123abc' 才命中。替换反向引用用 \1 或消歧义的 \g<1>(如 \g<1>0 避免和 \10 混淆)。
C++11 起标准库 <regex> 内置正则,默认 ECMAScript 语法(近似 JS)。功能较基础,要用全功能正则(后行断言、命名组、\p{} 等)主流做法是链接 PCRE2 库。

std::regex 是四门里最弱的一个

  • 不支持:命名组、后行断言、\p{}、内联标志、独占量词、原子组、递归、条件分组;
  • 而且性能长期被诟病std::regex 的构造与匹配开销都很大,热路径上常常比手写字符串处理慢一个数量级;
  • 内联标志的失效方式最危险(?i)abc 从别的语言搬过来时可能既不报错也不生效,只能靠构造时传 std::regex::icase
  • 结论:C++ 里但凡正则稍微复杂一点,主流做法就是链接 PCRE2——它支持本页提到的几乎全部特性,包括递归与 \K
// 创建(原始字符串 R"()" 免转义反斜杠)
#include <regex>
std::regex re(R"(\d+)");

std::regex_search(s, re);      // 是否含匹配
std::regex_match(s, re);       // 整串是否匹配

// 取捕获组
std::smatch m;
if (std::regex_search(s, m, re))
  m[0].str();           // 整体匹配;m[1] 为组 1

// 遍历全部匹配
auto it = std::sregex_iterator(s.begin(), s.end(), re);
for (; it != std::sregex_iterator(); ++it)
  it->str();

// 替换($& = 整体,$1 = 组 1)
std::regex_replace(s, re, "[$&]");

// 忽略大小写等标志用 regex_constants
std::regex re2(R"(abc)", std::regex::icase);
std::regex 局限多且慢:不支持后行断言、命名捕获组、原子组、独占量词、内联标志 (?i)\p{};且是回溯引擎,易受 ReDoS 影响。要全功能 + 高性能,改用 PCRE2
std::regex_search 命中子串即真、std::regex_match 要求整串匹配:实测同一 \d+"abc123" search 为真、match 为假。捕获走 std::smatchm[0] 整体、m[1] 起为分组),替换用 $1/$&。注意 smatch 存的是指向源串的迭代器,不能把临时 std::string 传给带 smatchregex_search——实测编译即报 deleted function,必须用生命周期够长的具名字符串。