组装:把模式搭起来

位置:锚点与零宽

它在解决什么

上一篇留了个尾巴:/^\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/.test('中文') 返回 false。

它不会抛异常、不会警告,只是永远匹配不到。所以:

  • 想靠 \b 做中文分词是行不通的,\b 压根不认识中文的词。
  • 混合文本里方向还会反过来:/\bhello\b/ 在 '说hello吧' 里匹配得到, 因为「说」「吧」都不是 \w,两侧于是都构成了边界。 这次碰巧对了,但对的理由和你想的不一样 —— 生效是因为中文被当成了 「非单词字符」(和空格、标点同一类),不是因为正则理解了中文。

⚠️ 真要在中文里表达「前后不能是汉字」,得用先行/后行显式写出来 (比如「后面不是汉字」这种条件),那是先行与后行的内容。

怎么选

你要表达的 写法
整串正好是这个格式(校验) 两端都锚:^…$
串里含有某个东西(搜索) 不要锚点
每一行都要单独判断 加 m
刚好是这个英文单词 \b…\b
刚好是这个中文词 \b 用不了,见上一节
在某个位置插入而不删除 替换一个零宽的东西(^、$)

一条实用的自查:写完一条校验正则,先拿一个「合法前缀 + 一坨垃圾」去试一次。 比如校验手机号的正则,拿 13800138000abc 试 —— 过了就说明你漏了 $。 这个输入比再多几个正常号码都有价值,因为它专打半锚定这个最常见的洞。

下一步

《分组、捕获与反向引用》—— () 同时在做两件事:把多个字符当整体 加量词,以及把匹配到的内容存下来备用。量词那篇说的 「量词只管紧挨着的一个元素」, 括号就是让它管住一整段的办法。

本篇示例

下面每一条都由 npm run test:regex 在每次构建前实跑验证, 结果是现算的,不是抄进数据里的副本。正文里的代码块只用来演示匹配过程和写法对照,不进闸门; 凡是「这个模式配这个输入得到这个结果」的断言,只存在于这里。

  1. 不锚定的「匹配」只是「包含」

    调用
    /^cat/.test("concat")
    结果
    false
    换成 /cat/
    true

    test() 问的是「串里有没有」,不是「串是不是」。要问后者,必须自己用锚点说出来。

  2. 「零宽」的意思:一次不替换任何字符的替换

    调用
    "a\nb".replace(/^/gm, "> ")
    结果
    "> a\n> b"
    换成 /^/g
    "> a\nb"

    ^ 匹配到的是位置而不是字符,所以替换它等于在那个位置插入 —— 原文一个字都没少。

  3. m 把 ^ 从「串首」改成「每行行首」

    调用
    [..."alpha\nbeta\ngamma".matchAll(/^\w+/gm)]
    结果
    ["alpha","beta","gamma"]
    换成 /^\w+/g
    ["alpha"]

    ⚠️ g 和 m 管的是两件事:g 决定「找几个」,m 决定「^ 算哪里」。少了 m 不会报错,只会少拿几条。

  4. 末尾有换行时,$ 认不认

    调用
    "a\n".match(/a$/)
    结果
    null
    换成 /a$/m
    ["a"]

    📌 同一条正则在 Python 里结果相反(re.search(r"a$", "a\n") 匹配得到)—— 别跨语言照搬,方言那篇会展开。

  5. \b 把「刚好是这个词」和「含这几个字母」分开

    调用
    [..."cat category concat cat.".matchAll(/\bcat\b/g)]
    结果
    ["cat","cat"]
    换成 /cat/g
    ["cat","cat","cat","cat"]

    \b 站在「是 \w 的字符」与「不是 \w 的字符」之间。句号、空格、串首串尾都算「不是」。

  6. 🚨 \b 对中文完全无效

    调用
    /\b中文\b/.test("中文")
    结果
    false
    换成 /中文/
    true

    不报错、不抛异常,只是永远匹配不到 —— 想靠 \b 做中文分词是行不通的。

  7. 混合文本里 \b 反而生效 —— 但理由和你想的不一样

    调用
    /\bhello\b/.test("说hello吧")
    结果
    true
    换成 /\Bhello\B/
    false

    ⚠️ 这次碰巧对了。同一条规则在纯中文串里就完全失效 —— 见上一条。

练习

先自己写,再看答案。读懂和写得出是两件事,而这一节练的是后者。每道题的参考答案都由 npm run test:exercises-regex 实跑验证: 答案必须通过全部用例,「常见错解」必须至少被一条用例抓住, 而且 /.*/ 这类万能写法必须过不了 —— 否则这道题就没有区分度。

  1. 给每一行开头加上 > (引用块的写法)

    用 输入.replace(re, "> ") 替换

    输入期望
    "a\nb""> a\n> b"
    "only""> only"
    提示

    这题的难点不在 pattern(它只有一个字符),在修饰符。

    参考答案

    /^/gm

    ⭐ 整条模式只有一个零宽的 ^,所以「替换」它等于在那个位置插入。少了 m,^ 只认整串开头 —— 结果只有第一行被加上前缀,而且不报错。

    常见错解 /^/g —— 它在"a\nb"这条上就错了。

  2. 取出所有独立的单词 cat(cats、concat 里的不算)

    用 [...输入.matchAll(re)] 取全部匹配

    输入期望
    "cat cats concat cat."["cat","cat"]
    "category"[]
    参考答案

    /\bcat\b/g

    \b 是单词边界。⚠️ 它认的是 \w 与非 \w 的交界,对中文完全无效 —— 中文场景得用先行/后行自己写条件。

    常见错解 /cat/g —— 它在"cat cats concat cat."这条上就错了。

  3. 取出所有以句号结尾的整行

    用 [...输入.matchAll(re)] 取全部匹配

    输入期望
    "a.\nb\nc."["a.","c."]
    "no period here"[]
    提示

    句号在正则里是元字符,要匹配它本身得转义。

    参考答案

    /^.+\.$/gm

    m 让 ^ $ 改认每行的两端。⚠️ 漏了它的表现是一条都取不到(因为 . 不匹配换行,整串根本凑不出一个「从头到尾」的匹配)。

    常见错解 /^.+\.$/g —— 它在"a.\nb\nc."这条上就错了。