上手:真实场景与边界
让同事读懂,也让机器测得了
它在解决什么
前面几篇解决的是「写得对」。这一篇解决的是它之后的事:
这条正则六个月后还有人敢改吗?它坏了会有测试告诉我吗?
一条正则在你脑子里最清楚的时刻,是刚写完那一刻。之后它只会越来越难懂—— 包括对你自己。生产环境里,一条没人敢动的正则和一个 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;
👉 三条实用规则:
- 打算取内容的括号用
(?<名字>…) - 只为圈范围的括号用
(?:…)—— 它不占编号,也是在告诉读者「这里没内容可取」 - 一个模式里别混用编号和命名 —— 混了之后读代码的人还是得数
怎么给正则写测试
这一节是整篇里最该照做的。
一条正则的测试,必须包含至少一个「应该失败」的用例。
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 看这七项
看到一条正则时,按这个顺序扫一遍,三十秒:
- 两端有锚点吗? 校验类没有
^…$基本就是错的 \d\w的范围够吗?\w不含点号、连字符、中文- 有嵌套量词吗?
(x+)+、(x*)*—— ReDoS 的信号 - 带
g的正则是常量吗? 有状态,会间歇性出错 i和[a-z]一起出现了吗? 那[a-z]就不再是「小写」- 括号该取内容吗? 不取就该是
(?: - 输入长度有上限吗? 没有就是一个待开的 ReDoS 口子
第 5 条最隐蔽,它对应上面的那条示例——
/^[a-z]+$/i 会放行 'ABC',而写这行的人想表达的恰恰是「必须小写」。
什么时候别自己写
正则不是所有字符串问题的答案。下面这些场景,用现成的东西:
| 需求 | 用什么 |
|---|---|
| 解析 URL | new URL() |
| 解析 HTML / XML | DOM 解析器 |
| 解析 JSON / YAML / CSV | 对应的解析库 |
| 解析日期 | Date 或日期库 |
| 判断字符串包含 | String.includes() —— 别用正则 |
| 按固定分隔符切分 | String.split() |
| 邮箱是否真的存在 | 发一封验证邮件 |
👉 判据:这个格式有标准吗? 有标准就有解析器,而解析器处理的边界情况 比你能想到的多得多——见什么时候不该用正则。
下一步
《一个需求走完全程》—— 这本书到此为止每篇讲一件事,那一篇把它们串起来: 从一个真实需求出发,走完分析、写、测、撞墙、停手的全过程。
本篇示例
下面每一条都由 npm run test:regex 在每次构建前实跑验证, 结果是现算的,不是抄进数据里的副本。正文里的代码块只用来演示匹配过程和写法对照,不进闸门; 凡是「这个模式配这个输入得到这个结果」的断言,只存在于这里。
🚨 拆成片段时,锚点只能留在最外层
- 调用
/^\d{4}$-^\d{2}$/.test("2026-09")- 结果
false- 换成
/^\d{4}-\d{2}$/ true
⚠️ 它不报错,只是恒为 false。拆片段是好习惯,但片段本身必须是「可拼接的零件」,不能自带两端。
i会让[a-z]连大写一起收- 调用
/^[a-z]+$/i.test("ABC")- 结果
true- 换成
/^[a-z]+$/ false
🚨 想表达「必须是小写」时不能带
i—— 加了i的那一刻,[a-z]就不再是「小写字母」,而是「字母」。code review 时值得专门看一眼。正则里的空格就是空格,不是排版
- 调用
/^\d{4} - \d{2}$/.test("2026-09")- 结果
false- 换成
/^\d{4}-\d{2}$/ true
📌 JS 没有 Perl/Python 那个忽略空白的
x修饰符。想把长模式排版得好读,只能拆成字符串片段再拼。
练习
先自己写,再看答案。读懂和写得出是两件事,而这一节练的是后者。每道题的参考答案都由 npm run test:exercises-regex 实跑验证: 答案必须通过全部用例,「常见错解」必须至少被一条用例抓住, 而且 /.*/ 这类万能写法必须过不了 —— 否则这道题就没有区分度。
校验
年-月(如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"这条上就错了。校验「只能是小写字母」,至少一个
用 re.test(输入) 判断
输入 期望 "abc"应匹配 "ABC"不应匹配 "aBc"不应匹配 ""不应匹配 参考答案
/^[a-z]+$/🚨 真正的坑不在这条答案上,而在别给它加
i—— 加了i的那一刻[a-z]就不再是「小写字母」而是「字母」,这条校验会完全失效而且不报错。常见错解
/^[a-zA-Z]+$/—— 它在"ABC"这条上就错了。校验用户名: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"这条上就错了。