约 25 分钟 · 示例以 Python 3.11+ 的 re 为主,JavaScript 差异单独说明

读完你能: 读懂一条常见正则;按需求一步步写出表达式;知道在 Python 和 JavaScript 里怎么调用;避开最常见的几个坑。

怎么读: 第 1–6 节是语法,按顺序读;第 7–8 节讲在代码里使用;第 9–10 节是实战和排坑;最后有速查表和小练习。

正则表达式(Regular Expression,RegEx)用一段“模式”描述文本长什么样,用来查找、校验、提取、拆分和替换文本。

读任何一条正则,都可以问五个问题:

  1. 匹配什么字符? —— 字面字符、字符类

  2. 重复几次? —— 量词

  3. 在哪里? —— 边界

  4. 怎么组合? —— 分组与分支

  5. 前后要满足什么? —— 环视

本文就按这个顺序展开。

1. 一分钟上手

[A-Z]{2}-[0-9]{4} 的意思是:两个大写字母,一个连字符,四个数字。它能从 订单 AB-2026 已创建 中找出 AB-2026。

片段

回答的问题

含义

[A-Z]

匹配什么

一个大写英文字母

{2}

重复几次

恰好 2 次

-

匹配什么

连字符本身

[0-9]{4}

什么 × 几次

4 个数字

下面的演示框可以直接改表达式和文本,匹配结果会实时高亮。后面每学一个语法,都可以回到这里试。

交互演示加载中…(需要启用 JavaScript)

2. 匹配什么:字符与字符类

字面字符和“一个任意字符”

普通字母、数字按原样匹配:abc 匹配连续的 abc。

. 表示“一个任意字符”,但默认不包括换行。想匹配句点本身,要写 \.,例如 3\.14。

需要转义才能按字面匹配的符号有:. ^ $ * + ? ( ) [ ] { } | \。

字符类:从一组字符里选一个

写法

含义

[abc]

a、b、c 中的一个字符(不是整个 abc)

[^abc]

不在集合里的一个字符(注意:也可能是换行)

[a-z]

范围内的一个字符

[A-Za-z0-9_]

多个范围合在一起

常用简写:

简写

含义

反义

\d

数字

\D

\w

单词字符:字母、数字、下划线

\W

\s

空白:空格、制表符、换行等

\S

\t \n \r

制表符、换行、回车

—

简写比你想的宽

在 Python 中,\d 也匹配全角数字 123,\w 包括中文。只想要半角数字就写 [0-9];只想合并空格和制表符就写 [ \t]+,不要用会吞掉换行的 \s+。

import re

assert re.fullmatch(r"\d+", "123") is not None
assert re.fullmatch(r"[0-9]+", "123") is None
assert re.fullmatch(r"\d+", "123", re.ASCII) is None
assert re.sub(r"[ \t]+", " ", "a  b\tc\nd") == "a b c\nd"
assert re.sub(r"\s+", " ", "a  b\tc\nd") == "a b c d"

方括号里有几条特殊规则:

  • ^ 只有放在方括号开头才表示“取反”。

  • 想匹配连字符,把它放在末尾:[A-Z-]。

  • . 在方括号里就是句点:[.] 等价于 \.。

  • [A-z] 不等于“所有字母”:ASCII 里 Z 和 a 之间还夹着 [、]、^、_ 等符号,应写 [A-Za-z]。

  • 处理 Windows 换行(\r\n)时,“非换行”要写 [^\r\n]。

3. 重复几次:量词

量词只作用于紧挨在它前面的一个单位(一个字符、一个字符类或一个分组)。

写法

次数

示例

*

0 次或更多

ab* 匹配 a、ab、abb

+

1 次或更多

[0-9]+ 至少一个数字

?

0 或 1 次

https? 匹配 http 或 https

{n}

恰好 n 次

[0-9]{4}

{n,}

至少 n 次

[a-z]{2,}

{n,m}

n 到 m 次

[A-Za-z]{2,8}

ab{2} 匹配 abb,而 (?:ab){2} 匹配 abab——分组改变了量词作用的单位。

* 允许 0 次,所以 [0-9]* 能匹配空字符串。要求“至少一个数字”请用 +。

贪婪与懒惰

量词默认是贪婪的:先尽量多吃,后面对不上再一点点吐出来。在量词后加 ? 变成懒惰:先尽量少吃,不够再加。

在上面的演示框里切换“贪婪 vs 懒惰”两个示例,可以直接看到区别:

import re

text = '"red" and "blue"'
assert re.findall(r'".*"', text) == ['"red" and "blue"']        # 贪婪:一口吃到最后一个引号
assert re.findall(r'".*?"', text) == ['"red"', '"blue"']        # 懒惰:遇到引号就停
assert re.findall(r'"[^"\r\n]*"', text) == ['"red"', '"blue"']  # 更好:直接说明“引号内不能有引号”

优先写清楚“不能出现什么”

第三种写法最可靠:它不依赖贪婪/懒惰的策略,而是直接把规则写进字符类。

Python 3.11+ 还支持占有量词(*+、++)和原子组 (?>...):吃进去的字符不再吐出来。它们用来控制回溯(见第 10 节),但会改变匹配结果,不能随手把 + 换成 ++。

import re

assert re.fullmatch(r"a+a", "aaaa") is not None
assert re.fullmatch(r"a++a", "aaaa") is None
assert re.fullmatch(r"(?>a+)a", "aaaa") is None

4. 在哪里:边界

边界匹配的是位置,不消耗字符。把字符串想成一排字符,边界检查的是字符之间的“缝”。

符号

含义

^

字符串开头(多行模式下也匹配每一行开头)

$

字符串结尾(多行模式下也匹配每一行结尾)

\b

单词边界:单词字符和非单词字符的交界

\B

非单词边界

\A

整个字符串的开头,不受多行模式影响

\Z

Python:整个字符串的严格结尾

import re

assert re.findall(r"\bcat\b", "cat scatter cat_ cat!") == ["cat", "cat"]
assert re.search(r"\Bcat\B", "scattered").group() == "cat"
assert re.findall(r"^foo", "foo\nfoo", re.M) == ["foo", "foo"]

cat_ 后面没有单词边界,因为下划线也属于 \w。

校验整串时,用 fullmatch

在 Python 中,$ 还能匹配末尾换行之前的位置,所以 ^\d+$ 会放过 "123\n"。校验整个输入时,用 re.fullmatch() 最稳妥。

import re

assert re.search(r"foo$", "foo\n") is not None   # $ 放过了末尾换行
assert re.search(r"foo\Z", "foo\n") is None
assert re.fullmatch(r"foo", "foo\n") is None      # fullmatch 严格

5. 怎么组合:分组、分支和反向引用

写法

用途

(...)

捕获分组:把这一段的匹配内容记下来

(?:...)

只分组、不捕获

A|B

分支:A 或 B

(?P<name>...)

Python 命名分组(JavaScript 写 (?<name>...))

\1

反向引用:再匹配一次第 1 组的同样文本

(?P=name)

Python 命名反向引用(JavaScript 写 \k<name>)

分支 | 的优先级最低,范围要用分组圈住:

gr(a|e)y         gray 或 grey
^(?:cat|dog)$    整串是 cat 或 dog
^cat|dog$        以 cat 开头,或者以 dog 结尾(通常不是你想要的)
import re

m = re.fullmatch(r"(?P<year>[0-9]{4})-(?P<month>[0-9]{2})", "2026-09")
assert m.group(0) == "2026-09"          # 0 号组是整个匹配
assert m.group("year") == "2026"
assert m.groups() == ("2026", "09")
assert m.groupdict() == {"year": "2026", "month": "09"}

assert re.search(r"\b(\w+)\s+\1\b", "go go home").group() == "go go"

两点经验:

  • 分组按左括号出现的顺序编号,group(1) 才是第一个捕获组。

  • 只是为了改变优先级,就用 (?:...),避免打乱编号。

  • 分支从左到右尝试,不是自动选最长:re.search(r"a|ab", "ab") 得到的是 a。

import re

assert re.search(r"a|ab", "ab").group() == "a"
assert re.fullmatch(r"a|ab", "ab").group() == "ab"

6. 前后要满足什么:环视

环视只“看一眼”前后文,不把看到的内容放进匹配结果。

写法

含义

示例

(?=...)

后面必须是…

[0-9]+(?=元) 从 25元 取出 25

(?!...)

后面不能是…

foo(?!bar)

(?<=...)

前面必须是…

(?<=¥)[0-9]+ 从 ¥25 取出 25

(?<!...)

前面不能是…

(?<![0-9])[A-Z]

import re

assert re.search(r"[0-9]+(?=元)", "价格25元").group() == "25"
assert re.search(r"(?<=¥)[0-9]+", "¥25").group() == "25"
assert re.findall(r"foo(?!bar)", "foobar foo!") == ["foo"]
assert re.findall(r"(?<![0-9])[A-Z]", "A 1B _C") == ["A", "C"]
assert re.findall(r"(?=(ana))", "banana") == ["ana", "ana"]   # 环视可找出重叠的结果

负向环视的陷阱

[0-9]+(?!元) 并不能“排除带元的金额”:在 123元 里,量词会退一步,匹配出 12(因为 12 后面是 3,不是元)。需要同时限定数字的结束位置,例如 [0-9]+(?![0-9]|元)。

Python 的后行断言必须是固定长度:(?<=ab) 可以,(?<=a+) 不行。替代方案是先匹配前缀,再取捕获组:a+([0-9]+)。

7. 标志:改变匹配规则

需求

Python

JavaScript

忽略大小写

re.I

i

^ $ 作用于每一行

re.M

m

让 . 也匹配换行

re.S

s

允许在模式里写空白和注释

re.X

无

简写字符类只认 ASCII

re.A

默认就是

找出所有匹配

用 findall / finditer

g

最常混淆的是 m 和 s:**m 管 ^ $,s 管 .**,两者互不相干。

import re

assert re.search(r"a.b", "a\nb") is None
assert re.search(r"a.b", "a\nb", re.M) is None       # m 不影响 .
assert re.search(r"a.b", "a\nb", re.S) is not None
assert re.findall(r"^cat$", "Cat\ncat\ndog", re.I | re.M) == ["Cat", "cat"]

长表达式可以用 re.X 分行写注释,可读性会好很多:

import re

order = re.compile(r"""
    (?P<prefix>[A-Z]{2})   # 两个大写字母
    -                      # 连字符
    (?P<number>[0-9]{4})   # 四位数字
""", re.X)
assert order.fullmatch("AB-2026").group("number") == "2026"

re.X 模式下空格会被忽略,要匹配空格写 [ ],要匹配井号写 [#]。

8. 在代码里使用

Python 的常用函数

函数

作用

典型用途

re.search()

找第一个匹配,找不到返回 None

判断是否包含

re.match()

只从开头匹配

匹配前缀

re.fullmatch()

整个字符串都要符合

字段校验

re.findall()

返回所有匹配的列表

批量提取

re.finditer()

返回匹配对象的迭代器

需要位置和分组时

re.sub()

替换

清洗、重排文本

re.split()

按匹配拆分

拆字段

re.compile()

预编译,重复使用

固定规则

import re

text = "x=12, y=34"
assert re.search(r"[0-9]+", text).group() == "12"
assert re.match(r"[0-9]+", text) is None
assert re.findall(r"[0-9]+", text) == ["12", "34"]
assert re.findall(r"([a-z])=([0-9]+)", text) == [("x", "12"), ("y", "34")]   # 有分组时返回元组
assert [(m.group(), m.span()) for m in re.finditer(r"[0-9]+", text)] == [("12", (2, 4)), ("34", (8, 10))]
assert re.split(r"[,;]\s*", "a, b;c") == ["a", "b", "c"]
assert re.subn(r"[0-9]+", "#", text) == ("x=#, y=#", 2)

实际代码中,search/match 可能返回 None,先判断再调用 .group()。

替换:模板语法和模式语法不一样

想插入

Python re.sub

JavaScript replace

整个匹配

\g<0>

$&

第 1 组

\g<1>

$1

命名组

\g<year>

$<year>

计算出的值

传入函数

传入函数

import re

assert re.sub(r"([0-9]{4})-([0-9]{2})-([0-9]{2})", r"\g<3>/\g<2>/\g<1>", "2026-09-21") == "21/09/2026"
assert re.sub(r"[0-9]+", lambda m: str(int(m.group()) * 2), "x=12 y=3") == "x=24 y=6"

JavaScript:注意 g 标志带来的状态

const text = "x=12 y=34";
const pairs = [...text.matchAll(/(?<key>[a-z])=(?<value>[0-9]+)/g)]
  .map(m => [m.groups.key, m.groups.value]);
console.assert(JSON.stringify(pairs) === '[["x","12"],["y","34"]]');

// 带 g 的正则会记住 lastIndex,连续 test() 结果会“忽对忽错”
const stateful = /a/g;
console.assert(stateful.test("a") === true);
console.assert(stateful.test("a") === false);

校验用的正则不要加 g

判断单个输入是否合法时,用不带 g 的正则,例如 /^[A-Za-z][A-Za-z0-9_]{2,15}$/.test(name)。

转义:分清“正则层”和“字符串层”

Python 用原始字符串 r"..." 写正则,就不必为字符串再转义一次:r"\d+" 和 "\\d+" 是同一个模式。

把用户输入当作普通文本放进正则时,一定先转义:

import re

keyword = "a+b.txt"
assert re.search(re.escape(keyword), "打开 a+b.txt").group() == keyword

9. 实战:先写清规则,再写表达式

写正则前,先用一句话写下规则,再逐段翻译。下面的例子都使用 fullmatch() 做整串校验。

import re

# 用户名:3–16 个 ASCII 字符;字母开头,其后只能是字母、数字、下划线
username = re.compile(r"[A-Za-z][A-Za-z0-9_]{2,15}")
assert username.fullmatch("Janus_01")
assert not username.fullmatch("1janus")
assert not username.fullmatch("Janus_01\n")

# 整数:可带正负号;不接受 01、1.0、空串
integer = re.compile(r"[+-]?(?:0|[1-9][0-9]*)")
assert integer.fullmatch("-42")
assert not integer.fullmatch("01")

# 颜色:3 位或 6 位十六进制
color = re.compile(r"#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})")
assert color.fullmatch("#3aF") and color.fullmatch("#12ab34")
assert not color.fullmatch("#12345")

日期:格式交给正则,真假交给日期库

[0-9]{4}-[0-9]{2}-[0-9]{2} 连 2026-99-99 都能通过。用正则判断大小月和闰年非常痛苦,交给标准库就好:

import re
from datetime import date

def valid_date(text):
    if re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", text) is None:
        return False
    try:
        date.fromisoformat(text)
        return True
    except ValueError:
        return False

assert valid_date("2024-02-29")
assert not valid_date("2026-02-29")
assert not valid_date("2026-99-99")

日志提取

import re

log = re.compile(
    r"(?P<date>[0-9]{4}-[0-9]{2}-[0-9]{2})[ \t]+"
    r"(?P<time>[0-9]{2}:[0-9]{2}:[0-9]{2})[ \t]+"
    r"(?P<level>DEBUG|INFO|WARN|ERROR)[ \t]+"
    r"(?P<message>[^\r\n]*)"
)
m = log.fullmatch("2026-09-21 09:30:05 INFO server started")
assert m.group("level") == "INFO"
assert m.group("message") == "server started"

什么时候不该用正则

  • 邮箱:[^\s@]+@[^\s@]+\.[^\s@]+ 只能粗筛,真正确认要发验证邮件。

  • 电话号码:规则因国家而异,用号码解析库。

  • URL、IP、JSON、HTML、CSV:都有专门的解析器,正则只适合在已知格式里定位片段。

  • 文件扩展名:.bashrc、archive.tar.gz 各有语义,用路径库处理。

10. 两个大坑:Unicode 与回溯

Unicode 与引擎差异

不同语言的正则引擎“方言”不同,写跨语言的正则前先确认:

特性

Python re

JavaScript

\d 是否包含全角数字

是(re.A 可关闭)

否

命名分组

(?P<name>...)

(?<name>...)

整串匹配

fullmatch()

^...$(不要加 m)

Unicode 属性 \p{...}

标准库不支持

u 标志下支持

console.assert(/^\p{Script=Han}+$/u.test("正则表达式"));
console.assert(!/^\p{Script=Han}+$/u.test("RegEx中文"));

[一-龥] 并不是完整的汉字集合;一个“看起来的字符”(如 emoji 组合)也可能由多个码点组成,.{1,10} 不能等同于“最多 10 个字”。

回溯为什么会卡死

^(a+)+$ 遇到 aaaa…a! 这样的输入时,引擎会尝试把这串 a 拆成各种组合,次数随长度指数增长。在演示框里选最后一个示例,可以看到它被强制停止。

防范方法:

  1. 限制输入长度。

  2. 避免“量词套量词”、相互重叠的分支;能写具体字符类就写具体字符类。

  3. 测试时专门构造“前面都对、最后一个字符错”的输入。

  4. 简单任务(包含、前缀、按逗号拆分)直接用字符串函数。

这里只需要 a+,配合 fullmatch() 即可。

速查表

  • 校验整个值用 fullmatch();查找片段用 search()。

  • ^ 在外面是“开头”,在 [^...] 里是“取反”。

  • m 管 ^ $,s 管 .。

  • * 可以是 0 次,+ 至少 1 次。

  • (...) 捕获,(?:...) 只分组。

  • 模式里反向引用写 \1;替换模板里 Python 写 \g<1>,JavaScript 写 $1。

  • 用户输入放进正则前先 re.escape()。

  • 格式对不等于内容对:日期、号码、URL 还要做语义校验。

小练习

先自己写,再用上面的演示框验证,最后展开答案。

1. 匹配 6 位邮政编码(只允许半角数字),整串校验。

re.fullmatch(r"[0-9]{6}", s)。不用 \d,因为它在 Python 里会接受全角数字。

2. 从 “耗时 120ms,重试 3 次,总计 480ms” 中只提取毫秒数。

re.findall(r"[0-9]+(?=ms)", text) → ['120', '480']。用先行断言要求后面是 ms,结果里不包含单位。

3. 把 “2026-09-21” 改写成 “2026年9月21日”(月、日去掉前导零)。
import re

result = re.sub(r"([0-9]{4})-([0-9]{2})-([0-9]{2})",
                lambda m: f"{m[1]}年{int(m[2])}月{int(m[3])}日", "2026-09-21")
assert result == "2026年9月21日"

需要计算时,替换参数用函数比模板更方便。

参考资料