组装:把模式搭起来
位置:锚点与零宽
它在解决什么
上一篇留了个尾巴:/^\d{3,4}/ 对 '12345' 返回 true,
因为量词只说「重复几次」,说不了「后面没别的了」。
补这一块的东西叫锚点。它和前面见过的所有东西有一个根本区别:
普通字符和字符类匹配的是字符,锚点匹配的是位置。
位置没有宽度,所以锚点不消耗任何字符,也不会出现在匹配结果里。 这个性质有个名字叫零宽(zero-width),后面那一整章的先行/后行也是这个家族的。
^ 和 $:不锚定的「匹配」只是「包含」
这是最该先说清楚的一件事,因为它是校验类 bug 的头号来源。
/cat/.test('concat') // 它问的是「串里有没有」,不是「串是不是」
这不是 bug —— test() 问的从来就是「串里有没有」,不是「串是不是」。
想问后者,你得自己用锚点说出来:/^cat/ 对 'concat' 返回 false。
| 写法 | 它实际在问 |
|---|---|
/cat/ |
串里含不含 cat |
/^cat/ |
串是不是以 cat 开头 |
/cat$/ |
串是不是以 cat 结尾 |
/^cat$/ |
串是不是正好是 cat |
🚨 半锚定是这四种里最危险的一种。 只写 ^ 或只写 $,对你手上那几个测试输入
通常都是对的 —— 问题输入恰恰是「前缀合法、后面跟了脏东西」那一类,
而那正是有人刻意构造时会送进来的东西。校验类正则请养成两端都写的习惯,
哪怕当时觉得多余。
零宽是什么意思:一次不替换任何字符的替换
「匹配位置而不是字符」这句话有点抽象,但有一个例子能让它变得很具体 —— 替换一个锚点:
'a\nb'.replace(/^/gm, '> ') 得到 '> a\n> b'。
原文一个字都没少,只是在两个「行首位置」上各插进去一个 > 。
因为 ^ 匹配到的东西宽度是零,把它「替换」掉等于在那里插入。
👉 记住这个例子,本章后面讲先行/后行时会用到同一个直觉: 零宽的东西可以用来限定位置,而不影响你最终取到的内容。
🚨 m 会改掉 ^ 和 $ 的含义
默认情况下,^ 指的是整个字符串的开头,$ 指的是整个字符串的结尾 ——
哪怕串里有一百个换行,它们也只认头尾那两个位置。
加上 m(multiline)之后,两者改成认每一行的行首和行尾:
/^\w+/gm 从 'alpha\nbeta\ngamma' 取出三条,
而同一个模式只带 g 不带 m 时只能取到一条。
⚠️ g 和 m 管的是两件完全不同的事,很容易混。
g (global) 决定「找几个」—— 找到一个之后要不要继续往后找
m (multiline) 决定「^ 和 $ 算哪里」—— 串的两端,还是每行的两端
少了 m 不会报错、不会抛异常,只会少拿几条。而「少拿了」是所有失败形态里
最难看出来的一种 —— 你拿到了一个非空的、看起来合理的结果。
还有一个相关的细节:末尾的换行。
'a\n'.match(/a$/) 返回 null ——
因为 JS 的 $(不带 m 时)严格指字符串的最末尾,而最末尾是那个 \n,不是 a。
📌 这条正则在 Python 里结果相反:re.search(r'a$', 'a\n') 是匹配得到的
(Python 的 $ 额外允许末尾那一个换行)。这类差异是「从网上抄来的正则粘进另一个
语言就不工作」的典型来源,方言那一篇会集中讲。
\b:把「刚好是这个词」和「含这几个字母」分开
\b 是第三个锚点,它匹配的位置是单词边界。定义很机械:
\b站在「是\w的字符」与「不是\w的字符」之间。 字符串的开头和结尾也算「不是」。
所以 /\bcat\b/g 从 'cat category concat cat.' 里只取出两个 ——
category 里的 cat 后面跟着 e(是 \w),concat 里的 cat 前面挨着 n(是 \w),
两处都不构成边界。而句号、空格、串首串尾都算边界。
还有一个反过来的 \B(非边界),用得少,但偶尔正好合适:
想找「出现在词内部的 cat」就是 \Bcat。
🚨 \b 对中文完全无效
这一条对写中文内容的人特别重要,而且它不报错。
\b 的定义依赖 \w,而 JS 里 \w 就是 [A-Za-z0-9_] 这九十几个字符 ——
中文不在里面。于是在一串纯中文里,任何两个字之间都不存在「一侧是 \w
一侧不是」的位置,也就一个边界都没有:
它不会抛异常、不会警告,只是永远匹配不到。所以:
- 想靠
\b做中文分词是行不通的,\b压根不认识中文的词。 - 混合文本里方向还会反过来:
/\bhello\b/在'说hello吧'里匹配得到, 因为「说」「吧」都不是\w,两侧于是都构成了边界。 这次碰巧对了,但对的理由和你想的不一样 —— 生效是因为中文被当成了 「非单词字符」(和空格、标点同一类),不是因为正则理解了中文。
⚠️ 真要在中文里表达「前后不能是汉字」,得用先行/后行显式写出来 (比如「后面不是汉字」这种条件),那是先行与后行的内容。
怎么选
| 你要表达的 | 写法 |
|---|---|
| 整串正好是这个格式(校验) | 两端都锚:^…$ |
| 串里含有某个东西(搜索) | 不要锚点 |
| 每一行都要单独判断 | 加 m |
| 刚好是这个英文单词 | \b…\b |
| 刚好是这个中文词 | \b 用不了,见上一节 |
| 在某个位置插入而不删除 | 替换一个零宽的东西(^、$) |
一条实用的自查:写完一条校验正则,先拿一个「合法前缀 + 一坨垃圾」去试一次。
比如校验手机号的正则,拿 13800138000abc 试 —— 过了就说明你漏了 $。
这个输入比再多几个正常号码都有价值,因为它专打半锚定这个最常见的洞。
下一步
《分组、捕获与反向引用》—— () 同时在做两件事:把多个字符当整体
加量词,以及把匹配到的内容存下来备用。量词那篇说的
「量词只管紧挨着的一个元素」,
括号就是让它管住一整段的办法。
本篇示例
下面每一条都由 npm run test:regex 在每次构建前实跑验证, 结果是现算的,不是抄进数据里的副本。正文里的代码块只用来演示匹配过程和写法对照,不进闸门; 凡是「这个模式配这个输入得到这个结果」的断言,只存在于这里。
不锚定的「匹配」只是「包含」
- 调用
/^cat/.test("concat")- 结果
false- 换成
/cat/ true
test()问的是「串里有没有」,不是「串是不是」。要问后者,必须自己用锚点说出来。「零宽」的意思:一次不替换任何字符的替换
- 调用
"a\nb".replace(/^/gm, "> ")- 结果
"> a\n> b"- 换成
/^/g "> a\nb"
^匹配到的是位置而不是字符,所以替换它等于在那个位置插入 —— 原文一个字都没少。m把^从「串首」改成「每行行首」- 调用
[..."alpha\nbeta\ngamma".matchAll(/^\w+/gm)]- 结果
["alpha","beta","gamma"]- 换成
/^\w+/g ["alpha"]
⚠️
g和m管的是两件事:g决定「找几个」,m决定「^算哪里」。少了m不会报错,只会少拿几条。末尾有换行时,
$认不认- 调用
"a\n".match(/a$/)- 结果
null- 换成
/a$/m ["a"]
📌 同一条正则在 Python 里结果相反(
re.search(r"a$", "a\n")匹配得到)—— 别跨语言照搬,方言那篇会展开。\b把「刚好是这个词」和「含这几个字母」分开- 调用
[..."cat category concat cat.".matchAll(/\bcat\b/g)]- 结果
["cat","cat"]- 换成
/cat/g ["cat","cat","cat","cat"]
\b站在「是\w的字符」与「不是\w的字符」之间。句号、空格、串首串尾都算「不是」。🚨
\b对中文完全无效- 调用
/\b中文\b/.test("中文")- 结果
false- 换成
/中文/ true
不报错、不抛异常,只是永远匹配不到 —— 想靠
\b做中文分词是行不通的。混合文本里
\b反而生效 —— 但理由和你想的不一样- 调用
/\bhello\b/.test("说hello吧")- 结果
true- 换成
/\Bhello\B/ false
⚠️ 这次碰巧对了。同一条规则在纯中文串里就完全失效 —— 见上一条。
练习
先自己写,再看答案。读懂和写得出是两件事,而这一节练的是后者。每道题的参考答案都由 npm run test:exercises-regex 实跑验证: 答案必须通过全部用例,「常见错解」必须至少被一条用例抓住, 而且 /.*/ 这类万能写法必须过不了 —— 否则这道题就没有区分度。
给每一行开头加上
>(引用块的写法)用 输入.replace(re, "> ") 替换
输入 期望 "a\nb""> a\n> b" "only""> only" 提示
这题的难点不在 pattern(它只有一个字符),在修饰符。
参考答案
/^/gm⭐ 整条模式只有一个零宽的
^,所以「替换」它等于在那个位置插入。少了m,^只认整串开头 —— 结果只有第一行被加上前缀,而且不报错。常见错解
/^/g—— 它在"a\nb"这条上就错了。取出所有独立的单词
cat(cats、concat里的不算)用 [...输入.matchAll(re)] 取全部匹配
输入 期望 "cat cats concat cat."["cat","cat"] "category"[] 参考答案
/\bcat\b/g\b是单词边界。⚠️ 它认的是\w与非\w的交界,对中文完全无效 —— 中文场景得用先行/后行自己写条件。常见错解
/cat/g—— 它在"cat cats concat cat."这条上就错了。取出所有以句号结尾的整行
用 [...输入.matchAll(re)] 取全部匹配
输入 期望 "a.\nb\nc."["a.","c."] "no period here"[] 提示
句号在正则里是元字符,要匹配它本身得转义。
参考答案
/^.+\.$/gmm让^$改认每行的两端。⚠️ 漏了它的表现是一条都取不到(因为.不匹配换行,整串根本凑不出一个「从头到尾」的匹配)。常见错解
/^.+\.$/g—— 它在"a.\nb\nc."这条上就错了。