正则锚点:^、$ 与多行模式详解
^ 和 $ 匹配的不是字符,而是位置。为什么没有锚点的正则检查得比你想的少,m 修饰符改变了什么,Python 的 $ 如何对待末尾的换行符,以及什么时候该改用 \A 和 \z。

· 阅读约 4 分钟
/^\d{5}$/
抓住
- 90210
- 10001
- 02139
- 60614
放过
- 9021
- 902100
- ZIP 90210
- 90210-1234
大多数正则记号匹配的是一个字符。锚点匹配的是一个位置:文本的开头、结尾,或两个字符之间的空隙。它们完全不占宽度。
它们也是校验用的正则里最常被漏掉的东西。本文讲解 ^ 和 $、m 修饰符改变了什么,以及各语言之间不一样的边界情况。每个示例都可以直接编辑:改改正则,看看各行如何变化。
01没有锚点的正则,检查得比你想的少
正则引擎做的是搜索:只要正则能在文本的任意位置匹配上,就报告匹配。所以一个没有锚点的“五位数 ZIP 邮政编码”正则,实际意思是“包含连续五个数字”:
02^ 和 $
^ 匹配文本的开头,$ 匹配文本的结尾。把它们放在正则两边,整个输入就必须完全符合:
也可以只用其中一个。^# 表示“以 # 开头”,\.pdf$ 表示“以 .pdf 结尾”:
03分支两边的锚点
| 是正则里优先级最低的,比锚点还低。所以 ^cat|dog$ 的意思不是“恰好是 cat 或 dog”,而是“以 cat 开头,或者以 dog 结尾”:
把各个分支放进一个分组里,两个锚点就都作用于所有分支:
这在真实代码里会坑人,比如 ^admin|editor$ 这样的白名单,会悄悄放行 admin-old 或 not-an-editor。
04多行模式
默认情况下,即使文本里有换行符,^ 和 $ 也只匹配整段文本的最开头和最结尾。m 修饰符让它们改为匹配每一行的开头和结尾:
const text = "apple\nbanana\ncherry";
text.match(/^\w+$/g); // null: "apple\nbanana\ncherry" isn't one word
text.match(/^\w+$/gm); // ["apple", "banana", "cherry"]
当你要从文件里逐行提取内容时,这正是你想要的。在 Python 里是 re.MULTILINE,或者在正则开头写 (?m):
import re
re.findall(r"^#+ .*$", markdown, re.MULTILINE) # every heading line
这个修饰符只改变 ^ 和 $。点号仍然不会跨越换行符。那是另一个修饰符 s 的事,在正则修饰符一文中有讲解。
05末尾换行符陷阱
在这里各语言出现了分歧。在 JavaScript 里,不加 m 时,$ 只匹配最末尾。在 Python 里,$ 还能匹配最末尾那个换行符之前的位置:
import re
re.match(r"^\d{5}$", "90210\n") # matches in Python
re.fullmatch(r"\d{5}", "90210\n") # None
/^\d{5}$/.test("90210\n"); // false in JavaScript
所以在 Python 里,^\d{5}$ 会接受末尾粘着一个换行符的 ZIP 邮政编码。如果这个值要写进数据库或用作文件名,这就可能出问题。两种修复:
- 使用
re.fullmatch(),它必须吃掉整个字符串。 - 使用
\A和\Z:在 Python 里,\A是最开头,\Z是最末尾,没有换行符例外。(Ruby、PCRE 和 Java 把严格的结尾写作\z。)
JavaScript 没有 \A 和 \z,但不加 m 修饰符时,它的 ^ 和 $ 本来就是这样的行为。
06单词边界也是锚点
\b 是另一个零宽锚点:单词字符和非单词字符之间的位置。在文本里匹配一个完整的单词,就靠它:
07试一试
抓住只有一个话题标签、别无其他内容的行:
抓住话题标签:一个 # 后面跟字母、数字或下划线,别无其他。
目标 · 抓住 0/5
- #regex目标
- #100DaysOfCode目标
- #a目标
- #snake_case目标
- #2026目标
无辜词 · 误抓 0
- regex无辜
- ##double无辜
- #two words无辜
- #无辜
- #dash-ed无辜
- a#tag无辜
输入正则——棋盘会实时更新。