上手:真实场景与边界

一个需求走完全程

它在解决什么

前面十四篇每一篇讲一件事。这一篇把它们串起来——因为真实工作里, 你面对的不是「量词该怎么写」,而是:

产品说要把文章里的链接都提取出来,做个死链检查。

这一篇就是那个需求从头到尾的过程。每一版正则都由上一版的失败推动, 最后一版停在「正则解决不了」的地方——那个停手的判断,和前面的写法同样重要。

第 1 步:先写验收用例,再写正则

在动手写模式之前,先把「什么算对」写下来:

// 该抓到的
'[文档](https://a.com)'        → 文字「文档」,地址 https://a.com
'看 [这里](/guide) 了解'        → 文字「这里」,地址 /guide

// 🚨 该拒绝的 —— 这一组比上面那组更重要
'![图](a.png)'                 → 这是图片,不是链接
'[空](  )'                     → 地址是空白
'`[代码](示例)`'                → 行内代码里的,不是真链接

⭐ 先写反例,是因为反例决定了模式的形状。 只盯着正例写, 你会得到一条对所有测试输入都对、对真实数据一塌糊涂的正则—— 工程化那篇讲的「只有正例的测试对 /.*/ 也是全绿的」 就是这个道理。

第 2 步:写最朴素的一版,然后让它失败

Markdown 链接长这样:[文字](地址)。直译过来:

/\[(.+)\]\((.+)\)/

跑第一条正例,通过。跑一条含两个链接的:

对 '[a](x) 和 [b](y)' 得到的是整串, 而且第 2 组拿到的是 'y' 不是 'x'。

🚨 注意它的失败方式:有结果。 不是报错,不是返回 null, 而是给你一个看起来正常的数组——如果只用单链接的输入测,这个 bug 能活很久。

原因是两个 .+ 都贪婪(量词那篇)。

第 3 步:把「到哪为止」写进模式里

修法不是改成懒惰 .+?,而是直接说清楚哪些字符不能出现:

/\[([^\]]+)\]\(([^)]+)\)/

[^\]]+ 读作「不是 ] 的字符」——它比 .+? 更准,也不依赖回溯 (不该用正则那篇会告诉你这还更安全)。

跑全部正例,通过。跑反例——

第 4 步:反例揭出一个「需求没说清」的问题

图片 ![img](a.png) 也被抓到了。

⭐ 这一版的正则没写错,是需求没说清。 Markdown 的图片语法 只比链接多一个前导感叹号,而「把链接都提取出来」这句话里, 没人说过图片算不算。

真实项目里这一步通常要回去问一句。假设答案是「图片不算」:

/(?<!!)\[([^\]]+)\]\(([^)]+)\)/

(?<!!) 是负向后行(先行与后行那篇), 读作「前面不能是感叹号」。它是零宽的,所以不影响取到的内容: 对 '![img](a.png)' 返回 null。

📌 边界情况通常不是写错,是没想到。这也是第 1 步先写反例的价值—— 它逼你把「没想到」提前到动手之前。

🚨 第 5 步:撞墙,并且认出这是墙

还剩最后一条反例:行内代码里的假链接。

'`[not](a link)` [real](b)' 仍然抓到两个。

自然的反应是再加一条排除:「前面不能是反引号」。 那个版本看起来修好了——它确实只抓到 [real](b)。

但它只挡住了紧贴反引号的那一种写法。还有:

围栏代码块(用三个反引号包起来的整段)
    缩进四个空格的代码
\[转义的方括号\]
<!-- 注释里的 -->

每加一条补丁挡住一类,而剩下的类别不收敛。 这就是什么时候不该用正则讲的那条边界: 要区分「代码块里」和「正文里」,需要知道当前处在什么上下文—— 而正则没有状态,它记不住自己在哪。

👉 认出墙的判据:你发现自己在加第三条补丁,而第四类边界情况已经在脑子里了。

第 6 步:决定停在哪

撞墙之后有三条路,选哪条取决于猜错了谁承担代价:

选择 什么时候合适
接受这个精度 死链检查这种场景:多报几个代码示例里的假链接,人工扫一眼就排除了
换 Markdown 解析器 结果要写回文章、或要统计数量 —— 错一条就是脏数据
正则 + 一层预处理 先用解析器把代码块剥掉,再用正则处理剩下的文本

这个需求是死链检查,误报的代价很低(多检查几个 URL 而已), 而漏报的代价才高。所以接受当前精度,到此为止。

⚠️ 但这个决定必须写进代码注释,否则半年后有人看到那条正则, 会以为它本该处理所有情况,然后开始加第四条补丁。

第 7 步:上线前加固

正则定下来了,剩下的是工程化(那一篇的清单):

// 提取 Markdown 正文里的链接。
// ⚠️ 已知不处理:代码块 / 缩进代码 / 转义方括号里的假链接。
//    这是**有意的** —— 要区分上下文需要解析器,而本用途(死链检查)
//    误报成本很低。别再往这条正则上加补丁,要更准就换解析器。
const MD_LINK = /(?<!!)\[(?<text>[^\]]+)\]\((?<href>[^)]+)\)/g;

export function extractLinks(md) {
  // 🚨 输入长度兜底:这条正则本身不会灾难性回溯(用的是否定字符类),
  //    但对超长输入做全局匹配仍然值得设个上限。
  if (md.length > 1_000_000) throw new Error('文档过大');

  // ⚠️ 每次新建,不复用带 g 的常量 —— lastIndex 会串。
  return [...md.matchAll(new RegExp(MD_LINK.source, MD_LINK.flags))]
    .map((m) => m.groups);
}

四个点对应前面几篇:命名组(编号会漂)、(?<!!) 零宽条件、 输入长度上限、以及不复用带 g 的正则。

回头看这条路

写反例  →  最朴素版本  →  贪婪失败  →  否定字符类
                                          ↓
      停手并注明  ←  撞墙  ←  需求没说清(图片)
          ↓
      命名组 / 长度上限 / 不复用 g

值得单独记住的三件事:

  1. 先写反例。 它决定模式的形状,也提前暴露「没想到」的情况。
  2. 失败不一定是报错。 v1 给了一个看起来正常的数组,那才是最危险的一类。
  3. 认出墙,并把停手的理由写进注释。 否则下一个人会继续加补丁。

下一步

《常用模式速查》—— 全书最后一篇,一组经过验证的常用模式。 每条都标了局限,抄之前先读那一栏。

本篇示例

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

  1. v1 最朴素的写法:贪婪把两个链接并成了一个

    调用
    "[a](x) 和 [b](y)".match(/\[(.+)\]\((.+)\)/)
    结果
    ["[a](x) 和 [b](y)","a](x) 和 [b","y"]
    换成 /\[([^\]]+)\]\(([^)]+)\)/
    ["[a](x)","a","x"]

    🚨 它有结果,所以不会立刻发现错了 —— 组里那坨 a](x) 和 [b 才是真相。

  2. v2 换成否定字符类,但图片也被当成了链接

    调用
    [..."![img](a.png) [link](b)".matchAll(/\[([^\]]+)\]\(([^)]+)\)/g)]
    结果
    ["[img](a.png)","[link](b)"]
    换成 /(?<!!)\[([^\]]+)\]\(([^)]+)\)/g
    ["[link](b)"]

    ⭐ 这一版的正则没错,是需求没说清:「提取链接」到底算不算图片?边界情况通常不是写错,是没想到。

  3. v3 用负向后行排除图片

    调用
    "![img](a.png)".match(/(?<!!)\[([^\]]+)\]\(([^)]+)\)/)
    结果
    null
    换成 /\[([^\]]+)\]\(([^)]+)\)/
    ["[img](a.png)","img","a.png"]

    (?<!!) 读作「前面不能是感叹号」。它是零宽的,所以不影响取到的内容。

  4. 🚨 v4 撞墙:行内代码里的假链接

    调用
    [..."`[not](a link)` [real](b)".matchAll(/(?<!!)\[([^\]]+)\]\(([^)]+)\)/g)]
    结果
    ["[not](a link)","[real](b)"]
    换成 /(?<![!\`])\[([^\]]+)\]\(([^)]+)\)/g
    ["[real](b)"]

    ⚠️ 对照那版看起来修好了,但它只挡住紧贴反引号的写法;代码块(``)、缩进代码、转义的 \[` 一个都没管 —— 这就是该停手的信号。

练习

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

  1. 从一段文字里提取所有 @提及(字母数字下划线)—— 邮箱里的 @ 不算

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

    输入期望
    "hi @alice and a@b.com"["@alice"]
    "@bob 你好"["@bob"]
    提示

    先想清楚「什么情况下 @ 不是提及」,再把那个条件写成先行/后行。

    参考答案

    /(?<![\w.])@\w+/g

    边界情况是邮箱:@ 前面如果紧挨着字母或点号,那多半是地址不是提及。用负向后行把这个条件写出来,而且它是零宽的、不影响取到的内容。

    常见错解 /@\w+/g —— 它在"hi @alice and a@b.com"这条上就错了。

  2. 取出 HTML 标签里 href 属性的值 —— 同一个标签里还有别的属性

    用 输入.match(re) 取结果

    输入期望
    "<a href=\"/x\" title=\"a>b\">"["href=\"/x\"","/x"]
    "<a href=\"/only\">"["href=\"/only\"","/only"]
    参考答案

    /href="([^"]*)"/

    ⭐ 又一次「别用 .」:[^"]* 直接说清「到下一个引号为止」,不依赖回溯。⚠️ 但这只对格式可控的 HTML 成立 —— 真要解析 HTML 请用 DOM 解析器。

    常见错解 /href="(.*)"/ —— 它在"<a href=\"/x\" title=\"a>b\">"这条上就错了。

  3. 取出独占一行的 TODO 注释(行中间那种不算)

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

    输入期望
    "// TODO: a\ncode // TODO: b"["// TODO: a"]
    "// TODO: only"["// TODO: only"]
    提示

    「行首」和「串首」不是一回事,需要一个修饰符来区分。

    参考答案

    /^\/\/ TODO: (.+)$/gm

    「独占一行」= 行首锚点 + m。⚠️ 注意第二条用例只有一行,两种写法结果相同 —— 区分力全靠第一条,这正是「用例要覆盖边界」的意思。

    常见错解 /\/\/ TODO: (.+)/gm —— 它在"// TODO: a\ncode // TODO: b"这条上就错了。