上手:真实场景与边界
一个需求走完全程
它在解决什么
前面十四篇每一篇讲一件事。这一篇把它们串起来——因为真实工作里, 你面对的不是「量词该怎么写」,而是:
产品说要把文章里的链接都提取出来,做个死链检查。
这一篇就是那个需求从头到尾的过程。每一版正则都由上一版的失败推动, 最后一版停在「正则解决不了」的地方——那个停手的判断,和前面的写法同样重要。
第 1 步:先写验收用例,再写正则
在动手写模式之前,先把「什么算对」写下来:
// 该抓到的
'[文档](https://a.com)' → 文字「文档」,地址 https://a.com
'看 [这里](/guide) 了解' → 文字「这里」,地址 /guide
// 🚨 该拒绝的 —— 这一组比上面那组更重要
'' → 这是图片,不是链接
'[空]( )' → 地址是空白
'`[代码](示例)`' → 行内代码里的,不是真链接
⭐ 先写反例,是因为反例决定了模式的形状。 只盯着正例写,
你会得到一条对所有测试输入都对、对真实数据一塌糊涂的正则——
工程化那篇讲的「只有正例的测试对 /.*/ 也是全绿的」
就是这个道理。
第 2 步:写最朴素的一版,然后让它失败
Markdown 链接长这样:[文字](地址)。直译过来:
/\[(.+)\]\((.+)\)/
跑第一条正例,通过。跑一条含两个链接的:
对 '[a](x) 和 [b](y)' 得到的是整串,
而且第 2 组拿到的是 'y' 不是 'x'。
🚨 注意它的失败方式:有结果。 不是报错,不是返回 null, 而是给你一个看起来正常的数组——如果只用单链接的输入测,这个 bug 能活很久。
原因是两个 .+ 都贪婪(量词那篇)。
第 3 步:把「到哪为止」写进模式里
修法不是改成懒惰 .+?,而是直接说清楚哪些字符不能出现:
/\[([^\]]+)\]\(([^)]+)\)/
[^\]]+ 读作「不是 ] 的字符」——它比 .+? 更准,也不依赖回溯
(不该用正则那篇会告诉你这还更安全)。
跑全部正例,通过。跑反例——
第 4 步:反例揭出一个「需求没说清」的问题
⭐ 这一版的正则没写错,是需求没说清。 Markdown 的图片语法 只比链接多一个前导感叹号,而「把链接都提取出来」这句话里, 没人说过图片算不算。
真实项目里这一步通常要回去问一句。假设答案是「图片不算」:
/(?<!!)\[([^\]]+)\]\(([^)]+)\)/
(?<!!) 是负向后行(先行与后行那篇),
读作「前面不能是感叹号」。它是零宽的,所以不影响取到的内容:
对 '' 返回 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
值得单独记住的三件事:
- 先写反例。 它决定模式的形状,也提前暴露「没想到」的情况。
- 失败不一定是报错。 v1 给了一个看起来正常的数组,那才是最危险的一类。
- 认出墙,并把停手的理由写进注释。 否则下一个人会继续加补丁。
下一步
《常用模式速查》—— 全书最后一篇,一组经过验证的常用模式。 每条都标了局限,抄之前先读那一栏。
本篇示例
下面每一条都由 npm run test:regex 在每次构建前实跑验证, 结果是现算的,不是抄进数据里的副本。正文里的代码块只用来演示匹配过程和写法对照,不进闸门; 凡是「这个模式配这个输入得到这个结果」的断言,只存在于这里。
v1 最朴素的写法:贪婪把两个链接并成了一个
- 调用
"[a](x) 和 [b](y)".match(/\[(.+)\]\((.+)\)/)- 结果
["[a](x) 和 [b](y)","a](x) 和 [b","y"]- 换成
/\[([^\]]+)\]\(([^)]+)\)/ ["[a](x)","a","x"]
🚨 它有结果,所以不会立刻发现错了 —— 组里那坨
a](x) 和 [b才是真相。v2 换成否定字符类,但图片也被当成了链接
- 调用
[..." [link](b)".matchAll(/\[([^\]]+)\]\(([^)]+)\)/g)]- 结果
["[img](a.png)","[link](b)"]- 换成
/(?<!!)\[([^\]]+)\]\(([^)]+)\)/g ["[link](b)"]
⭐ 这一版的正则没错,是需求没说清:「提取链接」到底算不算图片?边界情况通常不是写错,是没想到。
v3 用负向后行排除图片
- 调用
"".match(/(?<!!)\[([^\]]+)\]\(([^)]+)\)/)- 结果
null- 换成
/\[([^\]]+)\]\(([^)]+)\)/ ["[img](a.png)","img","a.png"]
(?<!!)读作「前面不能是感叹号」。它是零宽的,所以不影响取到的内容。🚨 v4 撞墙:行内代码里的假链接
- 调用
[..."`[not](a link)` [real](b)".matchAll(/(?<!!)\[([^\]]+)\]\(([^)]+)\)/g)]- 结果
["[not](a link)","[real](b)"]- 换成
/(?<![!\`])\[([^\]]+)\]\(([^)]+)\)/g ["[real](b)"]
⚠️ 对照那版看起来修好了,但它只挡住紧贴反引号的写法;代码块(``
)、缩进代码、转义的\[` 一个都没管 —— 这就是该停手的信号。
练习
先自己写,再看答案。读懂和写得出是两件事,而这一节练的是后者。每道题的参考答案都由 npm run test:exercises-regex 实跑验证: 答案必须通过全部用例,「常见错解」必须至少被一条用例抓住, 而且 /.*/ 这类万能写法必须过不了 —— 否则这道题就没有区分度。
从一段文字里提取所有
@提及(字母数字下划线)—— 邮箱里的@不算用 [...输入.matchAll(re)] 取全部匹配
输入 期望 "hi @alice and a@b.com"["@alice"] "@bob 你好"["@bob"] 提示
先想清楚「什么情况下 @ 不是提及」,再把那个条件写成先行/后行。
参考答案
/(?<![\w.])@\w+/g边界情况是邮箱:
@前面如果紧挨着字母或点号,那多半是地址不是提及。用负向后行把这个条件写出来,而且它是零宽的、不影响取到的内容。常见错解
/@\w+/g—— 它在"hi @alice and a@b.com"这条上就错了。取出 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\">"这条上就错了。取出独占一行的 TODO 注释(行中间那种不算)
用 [...输入.matchAll(re)] 取全部匹配
输入 期望 "// TODO: a\ncode // TODO: b"["// TODO: a"] "// TODO: only"["// TODO: only"] 提示
「行首」和「串首」不是一回事,需要一个修饰符来区分。
参考答案
/^\/\/ TODO: (.+)$/gm「独占一行」= 行首锚点 +
m。⚠️ 注意第二条用例只有一行,两种写法结果相同 —— 区分力全靠第一条,这正是「用例要覆盖边界」的意思。常见错解
/\/\/ TODO: (.+)/gm—— 它在"// TODO: a\ncode // TODO: b"这条上就错了。