上手:真实场景与边界

让同事读懂,也让机器测得了

它在解决什么

前面几篇解决的是「写得对」。这一篇解决的是它之后的事:

这条正则六个月后还有人敢改吗?它坏了会有测试告诉我吗?

一条正则在你脑子里最清楚的时刻,是刚写完那一刻。之后它只会越来越难懂—— 包括对你自己。生产环境里,一条没人敢动的正则和一个 bug 没什么区别: 需求变了也不敢改,只能在外面再套一层判断。

长正则怎么拆

JS 的正则字面量必须写在一行里,所以长模式很快就会变成一条看不懂的横线。 办法是拆成命名片段,再拼起来:

const YEAR  = String.raw`\d{4}`;
const MONTH = String.raw`(?:0[1-9]|1[0-2])`;
const DAY   = String.raw`(?:0[1-9]|[12]\d|3[01])`;

const DATE = new RegExp(`^${YEAR}-${MONTH}-${DAY}$`);

三个好处:每段能单独看懂、能单独测、能被别的模式复用。

⚠️ 用 String.raw 而不是普通字符串——普通字符串里 \d 会被吃成 d (见调试那篇第一步), 写成 '\\d' 也行,但一旦嵌套就很难数清楚有几层反斜杠。

🚨 拆的时候,锚点只能留在最外层

这是拆分最容易出的错,而且它不报错:

/^\d{4}$-^\d{2}$/ 对 '2026-09' 恒为 false。

因为每个片段都自带了 ^ 和 $,拼起来之后中间那个 $ 要求「字符串到此结束」, 而后面明明还有东西——这条模式永远不可能匹配任何字符串。

👉 判据:片段是零件,不是成品。 零件里不放 ^、$, 它们只在最后组装时加一次。

JS 没有 x 修饰符——空格是有意义的

Perl、Python 有个 x 修饰符,能让正则里的空白和注释被忽略,于是可以写成多行。 JS 没有这个东西。

所以千万别为了「好看」在模式里加空格: /^\d{4} - \d{2}$/ 匹配不了 '2026-09', 它要求输入里真的有那两个空格。

想排版,只能用上面那种拼字符串的办法——把换行放在 JS 代码里,而不是正则里。

用命名组,别数括号

分组那篇讲过编号会漂:在前面插一个捕获组, 后面所有编号往后推一位,而 m[2] 不会报错,只是开始指向别的东西。

// 半年后的你看到这行,得回去数括号
const [, year, month, day] = s.match(DATE);

// 这行不用数
const { year, month, day } = s.match(DATE).groups;

👉 三条实用规则:

  1. 打算取内容的括号用 (?<名字>…)
  2. 只为圈范围的括号用 (?:…) —— 它不占编号,也是在告诉读者「这里没内容可取」
  3. 一个模式里别混用编号和命名 —— 混了之后读代码的人还是得数

怎么给正则写测试

这一节是整篇里最该照做的。

一条正则的测试,必须包含至少一个「应该失败」的用例。

describe('DATE', () => {
  // ✅ 该通过的
  it.each(['2026-09-11', '2026-12-31'])('接受 %s', (s) => {
    expect(DATE.test(s)).toBe(true);
  });

  // 🚨 该拒绝的 —— 少了这一组,测试基本没有价值
  it.each([
    '2026-13-01',   // 13 月
    '2026-09-11x',  // 尾巴上有垃圾  ← 专打漏写 $
    'x2026-09-11',  // 头上有垃圾    ← 专打漏写 ^
    '2026-9-11',    // 位数不对
  ])('拒绝 %s', (s) => {
    expect(DATE.test(s)).toBe(false);
  });
});

为什么反例不能省: 只有正例的测试,对 /.*/ 这种什么都匹配的模式也是全绿的。 换句话说,一组只有正例的测试不能区分「写对了」和「写了个恒真的东西」。

⭐ 固定要加的三个反例,它们各自专打一种最常见的错误:

反例 打的是
合法值 + 尾巴('2026-09-11x') 漏写 $
头 + 合法值('x2026-09-11') 漏写 ^
空字符串 量词该用 + 却写成了 *

📌 本站这套教程自己就是这么做的:每一条示例 都必须附一个「似是而非的写法」,闸门断言它跑出来的结果必须不同—— 否则说明那组输入分不出对错,这条示例什么都没证明。

Code review 看这七项

看到一条正则时,按这个顺序扫一遍,三十秒:

  1. 两端有锚点吗? 校验类没有 ^…$ 基本就是错的
  2. \d \w 的范围够吗? \w 不含点号、连字符、中文
  3. 有嵌套量词吗? (x+)+、(x*)* —— ReDoS 的信号
  4. 带 g 的正则是常量吗? 有状态,会间歇性出错
  5. i 和 [a-z] 一起出现了吗? 那 [a-z] 就不再是「小写」
  6. 括号该取内容吗? 不取就该是 (?:
  7. 输入长度有上限吗? 没有就是一个待开的 ReDoS 口子

第 5 条最隐蔽,它对应上面的那条示例—— /^[a-z]+$/i 会放行 'ABC',而写这行的人想表达的恰恰是「必须小写」。

什么时候别自己写

正则不是所有字符串问题的答案。下面这些场景,用现成的东西:

需求 用什么
解析 URL new URL()
解析 HTML / XML DOM 解析器
解析 JSON / YAML / CSV 对应的解析库
解析日期 Date 或日期库
判断字符串包含 String.includes() —— 别用正则
按固定分隔符切分 String.split()
邮箱是否真的存在 发一封验证邮件

👉 判据:这个格式有标准吗? 有标准就有解析器,而解析器处理的边界情况 比你能想到的多得多——见什么时候不该用正则。

下一步

《一个需求走完全程》—— 这本书到此为止每篇讲一件事,那一篇把它们串起来: 从一个真实需求出发,走完分析、写、测、撞墙、停手的全过程。

本篇示例

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

  1. 🚨 拆成片段时,锚点只能留在最外层

    调用
    /^\d{4}$-^\d{2}$/.test("2026-09")
    结果
    false
    换成 /^\d{4}-\d{2}$/
    true

    ⚠️ 它不报错,只是恒为 false。拆片段是好习惯,但片段本身必须是「可拼接的零件」,不能自带两端。

  2. i 会让 [a-z] 连大写一起收

    调用
    /^[a-z]+$/i.test("ABC")
    结果
    true
    换成 /^[a-z]+$/
    false

    🚨 想表达「必须是小写」时不能带 i —— 加了 i 的那一刻,[a-z] 就不再是「小写字母」,而是「字母」。code review 时值得专门看一眼。

  3. 正则里的空格就是空格,不是排版

    调用
    /^\d{4} - \d{2}$/.test("2026-09")
    结果
    false
    换成 /^\d{4}-\d{2}$/
    true

    📌 JS 没有 Perl/Python 那个忽略空白的 x 修饰符。想把长模式排版得好读,只能拆成字符串片段再拼。

练习

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

  1. 校验 年-月(如 2026-09),月份必须是 01–12 —— 13 月要挡住

    用 re.test(输入) 判断

    输入期望
    "2026-09"应匹配
    "2026-12"应匹配
    "2026-13"不应匹配
    "2026-00"不应匹配
    "2026-9"不应匹配
    提示

    把「01 到 09」和「10 到 12」分成两支,再用括号圈起来。

    参考答案

    /^\d{4}-(?:0[1-9]|1[0-2])$/

    月份要枚举不能只数位数:0[1-9] 覆盖 01–09,1[0-2] 覆盖 10–12。⭐ 用 (?: 而不是 ( —— 这对括号只是为了圈住分支,不打算取它的内容。

    常见错解 /^\d{4}-\d{2}$/ —— 它在"2026-13"这条上就错了。

  2. 校验「只能是小写字母」,至少一个

    用 re.test(输入) 判断

    输入期望
    "abc"应匹配
    "ABC"不应匹配
    "aBc"不应匹配
    ""不应匹配
    参考答案

    /^[a-z]+$/

    🚨 真正的坑不在这条答案上,而在别给它加 i —— 加了 i 的那一刻 [a-z] 就不再是「小写字母」而是「字母」,这条校验会完全失效而且不报错。

    常见错解 /^[a-zA-Z]+$/ —— 它在"ABC"这条上就错了。

  3. 校验用户名:3–16 位,只允许字母、数字、下划线、连字符

    用 re.test(输入) 判断

    输入期望
    "abc"应匹配
    "a_b-c1"应匹配
    "ab"不应匹配
    "aaaaaaaaaaaaaaaaa"不应匹配
    "a b"不应匹配
    "a.b"不应匹配
    提示

    连字符在方括号里有特殊含义,想表达它本身该放哪个位置?

    参考答案

    /^[\w-]{3,16}$/

    \w 是 [A-Za-z0-9_] —— 含下划线,不含连字符。要加连字符就把它放进字符类,且放在末尾(放中间会被当成范围符号)。

    常见错解 /^\w{3,16}$/ —— 它在"a_b-c1"这条上就错了。