上手:真实场景与边界
实战:校验、提取、替换
它在解决什么
前面九篇把语法讲完了。这一篇讲怎么把它们拼起来解决真实需求, 以及一件同样重要的事:什么时候该停手。
正则的三类典型用途,侧重完全不同:
| 关键点 | 最常见的错误 | |
|---|---|---|
| 校验 | 两端锚定 | 忘了 $,前缀合法就放行 |
| 提取 | 分组 + 别让 . 吃太多 |
用贪婪的 .+ 跨过了边界 |
| 替换 | 替换串里的记号 | 忘了 g,只换了第一个 |
校验:两端锚定,然后停在「够用」
校验的写法模板只有一个形状:
^ 条件… $
多个条件同时成立时用先行叠加(先行那篇讲过):
^(?=.*\d)(?=.*[a-z]).{8,}$。
难的从来不是语法,是决定严格到什么程度。
🚨 为什么「完美的邮箱正则」是个陷阱
先说结论:别写它。用一个宽松版本,然后发一封验证邮件。
/^[^\s@]+@[^\s@]+\.[^\s@]+$/ 拒绝 'a@b'——
三段、各段不含空格和 @、中间有个点。这一档的投入产出比最高。
往上加严会撞到三堵墙:
第一堵:RFC 5322 允许的东西比你想的多得多。 引号里可以有空格、 可以有注释、本地部分可以包含一堆特殊符号。照着标准写出来的正则长到没法维护, 而且你读不懂它,也就没法在它出错时判断是哪里错了。
第二堵:写对了也没用。 就算你的正则百分之百符合 RFC,它能回答的仍然只有
「这串字符语法合法吗」,回答不了**「这个邮箱真的存在吗」**——
而后者才是你真正想知道的。asdfgh@gmail.com 语法完美无缺。
第三堵:严格的代价是误杀真实用户。 新顶级域名一直在加,
+ 号别名、长域名、非 ASCII 域名都是合法的。一条过度严格的正则,
失败方式是「某个真人填了自己的真邮箱,被你的表单拒绝」——而他不会来报 bug,
他会走掉。
👉 判据:校验的严格程度,取决于「猜错了谁承担代价」。 挡不住的假地址由验证邮件兜底,成本很低;误杀的真用户没人兜底。 所以宁松勿严。
同一条思路适用于手机号、URL、身份证号:先问「后面还有没有一道真正的检查」。 有,就宽松;没有,才值得在正则上较劲。
🚨 格式对不代表值合法
这条是校验类正则最容易忽略的边界:
/^\d{4}-\d{2}-\d{2}$/ 认为 '2026-13-45' 是合法的。
13 月 45 日,格式完全正确。把月份写成枚举 (0[1-9]|1[0-2]) 能挡住 13 月,
但挡不住 2 月 30 日;把日也写成枚举能挡住 32 日,但挡不住闰年判断。
顺着这条路走下去,正则会越来越长而且永远差一点。
正则管的是字符的排列,管不了「这个值有没有意义」。
日期合不合法交给 Date 判,金额范围交给数值比较判,
身份证校验位交给那个校验算法判。正则负责把它们摘出来,
到此为止。
提取:分组 + 否定字符类
提取的模板是「定位 + 分组」,难点在怎么让它停在该停的地方。
从 'ERROR [db] [retry] failed' 里取字段,
用 \[([^\]]+)\] 能正确取到 db;换成贪婪的 \[(.+)\],
它会一路吃到最后一个 ],把两对方括号并成一个组,取到 db] [retry。
三种写法的取舍:
\[(.+)\] 贪婪 ❌ 吃到最后一个 ]
\[(.+?)\] 懒惰 ✅ 对,但靠回溯一步步试
\[([^\]]+)\] 否定类 ✅ 对,而且不需要回溯 —— 直接说「不是 ] 的字符」
⭐ 量词那篇结尾埋过这条:
比懒惰更好的往往是「别用 .」。 否定字符类把「到哪为止」这个信息
直接写进了模式里,而不是让引擎试出来。它更准、更快,
而且讲灾难性回溯那篇会告诉你它还更安全。
取多个用 matchAll 配命名组:
for (const m of text.matchAll(/(?<level>\w+)\s+\[(?<mod>[^\]]+)\]/g)) {
// m.groups.level, m.groups.mod
}
⚠️ 如果那个正则是复用的对象,先看一眼修饰符那篇关于
matchAll 继承 lastIndex 的那一段。
替换:替换串里的几个记号
'a1b22'.replace(/\d+/g, '**$&**') 得到 'a**1**b**22**'。
| 记号 | 含义 |
|---|---|
$& |
整个匹配 |
$1 $2 |
第 1、2 个捕获组 |
$<名字> |
命名组 |
$` / $' |
匹配之前 / 之后的那部分 |
$$ |
一个字面量 $ |
两个「不报错」的坑,前面都遇到过同族的:
忘了 g 只换第一个。 不报错,表现是「有的换了有的没换」,
在长文本上很容易漏看。
引用不存在的组也不报错。
把 (\d{3})\d{4}(\d{4}) 写成 (\d{3})\d{4}\d{4},$1****$2 会输出 '138****$2'——
那个 $2 原样进了结果。和分组那篇里
「没有命名组时 $<d> 原样输出」是同一回事。
回调函数:正则管找,逻辑归你
replace 的第二个参数可以是函数。这是 JS 相对其他语言的一个明显优势,
也是上面「正则管格式、不管语义」那条的落地办法:
// 正则只负责把「看起来像日期的东西」找出来,
// 合不合法、怎么格式化,全在函数里用普通代码判断。
text.replace(/(\d{4})-(\d{2})-(\d{2})/g, (whole, y, m, d) => {
const dt = new Date(+y, +m - 1, +d);
return dt.getMonth() + 1 === +m ? dt.toLocaleDateString() : whole;
});
参数顺序是:完整匹配、各个捕获组、匹配位置、原始字符串
(有命名组时最后还会多一个 groups 对象)。
👉 凡是「替换内容取决于匹配到了什么」的需求,都用回调, 别试图用一条更聪明的正则去表达条件逻辑。
怎么选
| 需求 | 起手式 | 别忘了 |
|---|---|---|
| 校验一个格式 | ^…$ |
问一句「后面还有没有真正的检查」 |
| 校验多个条件 | ^(?=…)(?=…)…$ |
条件之间没有顺序 |
| 取一段 | (…) + [^x]+ |
别用 .+ |
| 取全部 | matchAll + g |
命名组比编号稳 |
| 全局替换 | replace + g |
检查 $n 的编号对不对 |
| 替换要带判断 | replace + 回调 |
判断写普通代码,别塞进正则 |
一条贯穿全篇的判据:正则擅长「找到它」,不擅长「决定它对不对」。 找的部分交给正则,判的部分交给代码——上面那张表里有一半的条目 其实都是这句话的不同写法。
下一步
《正则不工作怎么查》——这一篇讲的是「写得对」, 下一篇讲「写错了怎么快速找出来」:一套能照着走的排查顺序, 从「确认你跑的是你以为的那条正则」开始。
本篇示例
下面每一条都由 npm run test:regex 在每次构建前实跑验证, 结果是现算的,不是抄进数据里的副本。正文里的代码块只用来演示匹配过程和写法对照,不进闸门; 凡是「这个模式配这个输入得到这个结果」的断言,只存在于这里。
邮箱:够用的宽松校验
- 调用
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test("a@b")- 结果
false- 换成
/^[^\s@]+@[^\s@]+$/ true
「三段、各段不含空格和 @」是投入产出比最高的那档。再严格下去就要付出不成比例的代价 —— 见正文。
🚨 格式对 ≠ 值合法:13 月 45 日完美通过
- 调用
/^\d{4}-\d{2}-\d{2}$/.test("2026-13-45")- 结果
true- 换成
/^\d{4}-(0[1-9]|1[0-2])-\d{2}$/ false
正则管的是字符的排列,管不了「这个值有没有意义」。日期合不合法要交给
Date去判。提取:用否定字符类,别用
.- 调用
"ERROR [db] [retry] failed".match(/^(\w+)\s+\[([^\]]+)\]\s+(.+)$/)- 结果
["ERROR [db] [retry] failed","ERROR","db","[retry] failed"]- 换成
/^(\w+)\s+\[(.+)\]\s+(.+)$/ ["ERROR [db] [retry] failed","ERROR","db] [retry","failed"]
⭐
[^\]]+读作「不是]的字符」—— 它比.+?更准(不依赖回溯)也更快。[量词那篇](/regex/foundation/quantifiers/)结尾埋的就是这条。替换:
$&是「整个匹配」- 调用
"a1b22".replace(/\d+/g, "**$&**")- 结果
"a**1**b**22**"- 换成
/\d+/ "a**1**b22"
替换串里的几个记号:
$&整个匹配、$1第 1 组、`$`匹配之前的部分、$$一个字面量$`。脱敏:把中间四位换成星号
- 调用
"13812345678".replace(/(\d{3})\d{4}(\d{4})/, "$1****$2")- 结果
"138****5678"- 换成
/(\d{3})\d{4}\d{4}/ "138****$2"
🚨 引用不存在的组不会报错,只会把
$2当普通文字输出 —— 同[分组那篇](/regex/structure/groups/)里$<d>那个坑。
练习
先自己写,再看答案。读懂和写得出是两件事,而这一节练的是后者。每道题的参考答案都由 npm run test:exercises-regex 实跑验证: 答案必须通过全部用例,「常见错解」必须至少被一条用例抓住, 而且 /.*/ 这类万能写法必须过不了 —— 否则这道题就没有区分度。
写一个够用的邮箱校验:三段、各段不含空格和
@、中间有个点用 re.test(输入) 判断
输入 期望 "a@b.co"应匹配 "x.y@z.com"应匹配 "a@b"不应匹配 "a b@c.d"不应匹配 提示
用「不是空格也不是 @ 的字符」来描述每一段,比枚举合法字符省事得多。
参考答案
/^[^\s@]+@[^\s@]+\.[^\s@]+$/⭐ 关键是别追求严格:这一档的投入产出比最高,剩下的交给验证邮件。严格的代价是误杀真实用户,而他不会来报 bug,他会走掉。
常见错解
/^[^\s@]+@[^\s@]+$/—— 它在"a@b"这条上就错了。把手机号中间四位换成 `**
(13812345678→138**5678`)用 输入.replace(re, "$1****$2") 替换
输入 期望 "13812345678""138****5678" "15900001111""159****1111" 参考答案
/(\d{3})\d{4}(\d{4})/🚨 引用不存在的组不报错 ——
$2会被当成普通文字写进结果,得到138****$2。只有比对输出才看得出来。常见错解
/(\d{3})\d{4}\d{4}/—— 它在"13812345678"这条上就错了。取出第一对方括号里的内容(行里可能有好几对)
用 输入.match(re) 取结果
输入 期望 "ERROR [db] [retry] x"["[db]","db"] "[one]"["[one]","one"] 提示
与其让引擎「试出来该在哪停」,不如直接说清楚「哪个字符不能出现」。
参考答案
/\[([^\]]+)\]/⭐ 比懒惰
.+?更好的是否定字符类[^\]]+——「不是]的字符」是确定的,不依赖回溯,更准也更快,而且在[不该用正则那篇](/regex/practice/when-not-to-use/)里你会看到它还更安全。常见错解
/\[(.+)\]/—— 它在"ERROR [db] [retry] x"这条上就错了。