上手:真实场景与边界

实战:校验、提取、替换

它在解决什么

前面九篇把语法讲完了。这一篇讲怎么把它们拼起来解决真实需求, 以及一件同样重要的事:什么时候该停手。

正则的三类典型用途,侧重完全不同:

关键点 最常见的错误
校验 两端锚定 忘了 $,前缀合法就放行
提取 分组 + 别让 . 吃太多 用贪婪的 .+ 跨过了边界
替换 替换串里的记号 忘了 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 在每次构建前实跑验证, 结果是现算的,不是抄进数据里的副本。正文里的代码块只用来演示匹配过程和写法对照,不进闸门; 凡是「这个模式配这个输入得到这个结果」的断言,只存在于这里。

  1. 邮箱:够用的宽松校验

    调用
    /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test("a@b")
    结果
    false
    换成 /^[^\s@]+@[^\s@]+$/
    true

    「三段、各段不含空格和 @」是投入产出比最高的那档。再严格下去就要付出不成比例的代价 —— 见正文。

  2. 🚨 格式对 ≠ 值合法: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 去判。

  3. 提取:用否定字符类,别用 .

    调用
    "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/)结尾埋的就是这条。

  4. 替换:$& 是「整个匹配」

    调用
    "a1b22".replace(/\d+/g, "**$&**")
    结果
    "a**1**b**22**"
    换成 /\d+/
    "a**1**b22"

    替换串里的几个记号:$& 整个匹配、$1 第 1 组、` $ ` 匹配之前的部分、$$ 一个字面量 $`。

  5. 脱敏:把中间四位换成星号

    调用
    "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 实跑验证: 答案必须通过全部用例,「常见错解」必须至少被一条用例抓住, 而且 /.*/ 这类万能写法必须过不了 —— 否则这道题就没有区分度。

  1. 写一个够用的邮箱校验:三段、各段不含空格和 @、中间有个点

    用 re.test(输入) 判断

    输入期望
    "a@b.co"应匹配
    "x.y@z.com"应匹配
    "a@b"不应匹配
    "a b@c.d"不应匹配
    提示

    用「不是空格也不是 @ 的字符」来描述每一段,比枚举合法字符省事得多。

    参考答案

    /^[^\s@]+@[^\s@]+\.[^\s@]+$/

    ⭐ 关键是别追求严格:这一档的投入产出比最高,剩下的交给验证邮件。严格的代价是误杀真实用户,而他不会来报 bug,他会走掉。

    常见错解 /^[^\s@]+@[^\s@]+$/ —— 它在"a@b"这条上就错了。

  2. 把手机号中间四位换成 `**(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"这条上就错了。

  3. 取出第一对方括号里的内容(行里可能有好几对)

    用 输入.match(re) 取结果

    输入期望
    "ERROR [db] [retry] x"["[db]","db"]
    "[one]"["[one]","one"]
    提示

    与其让引擎「试出来该在哪停」,不如直接说清楚「哪个字符不能出现」。

    参考答案

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

    ⭐ 比懒惰 .+? 更好的是否定字符类 [^\]]+ ——「不是 ] 的字符」是确定的,不依赖回溯,更准也更快,而且在[不该用正则那篇](/regex/practice/when-not-to-use/)里你会看到它还更安全。

    常见错解 /\[(.+)\]/ —— 它在"ERROR [db] [retry] x"这条上就错了。