上手:真实场景与边界
正则不工作怎么查
它在解决什么
前面几篇讲了很多「为什么会错」。这一篇讲的是另一件事:
手上这条正则现在就不工作,我该按什么顺序查?
在生产里这是最高频的需求,而它和「理解正则」是两种不同的能力—— 知道贪婪是什么,不等于能在五分钟内定位一条两百字符的模式为什么突然不匹配了。
盯着模式硬看是最慢的办法。下面这套顺序,从最容易被忽略、也最容易排除的开始。
先分清症状
| 症状 | 最可能的方向 |
|---|---|
| 一个都不匹配 | 转义层数、不可见字符、\w 之类的范围比你想的窄 |
| 匹配到了,但多了一大坨 | 贪婪(见量词那篇) |
| 匹配到了,但是错的东西 | 缺少路标/锚点,撞上了前面一个长得像的 |
| 时而对时而错 | 带 g 的正则被复用了(见修饰符那篇) |
| 偶尔跑很久或卡死 | 灾难性回溯(见不该用正则那篇) |
最后两行有个共同特征:它们不是「一直错」,而是「有时候错」。 遇到间歇性问题,先去看这两条,别在模式本身上耗。
🚨 第一步:确认你跑的是你以为的那条正则
这一步最常被跳过,也最常是答案。
模式一旦不是写成字面量 /…/,而是用字符串拼出来的,就多了一层转义:
new RegExp('\d+') // 你以为是 /\d+/
它实际等价于 /d+/——对 '123' 返回 false,
而对 'ddd' 返回 true。原因是 JS 的字符串字面量里 \d 不是有效转义序列,
它被解释成了字母 d,正则根本没见过那个反斜杠。
new RegExp('\\d+') // ← 拼字符串时要写两个反斜杠
👉 判据:把 re.source 打出来看一眼。
console.log(re.source, re.flags);
这一行能一次性排除转义层数、拼接错位、flags 漏写三类问题。 如果打出来的东西和你脑子里那条不一样,后面都不用查了。
⚠️ 需要把用户输入当字面量塞进正则时,别手动加反斜杠——
写个转义函数,或者干脆用 String.prototype.includes。
第二步:砍半定位
确认模式没写错之后,如果还是不匹配,别整条盯着看,把它砍开。
一条正则是从左往右逐段推进的,所以「整条不匹配」一定对应「某一段开始接不上」。 从左边取一小段先跑:
/^(\w+)@/ 对 'a.b@example.com' 返回 null——
第一段就断了,后面的域名部分根本不用怀疑。原因是 \w 不含点号,
而邮箱本地部分允许点。
// 一条不工作的长模式
/^(\w+)@(\w+)\.(\w{2,})$/
// 砍成三段分别跑,找出第一个返回 null 的
/^(\w+)@/ // ← null,问题在这
/@(\w+)\./
/\.(\w{2,})$/
⭐ 这个办法之所以有效,是因为它把「哪里错」和「为什么错」分开了。 先用三十秒定位到段,再去想那一段为什么不对——比一开始就想「整条为什么不对」快得多。
第三步:怀疑数据,不只怀疑模式
模式看着没问题、砍半也定位不到,那多半是输入和你看到的不一样。
/^a b$/ 匹配不了 'a b'——那个「空格」是 U+00A0
(不换行空格),屏幕上和普通空格完全一样。
/^abc$/ 匹配不了 'abc'——中间藏着一个零宽空格,
那串的长度其实是 4。
这类字符最常见的来源是从网页、PDF、Word 里复制过来的文本。
👉 判据:把码点打出来。
[...s].map(c => 'U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0'))
// [ 'U+0061', 'U+00A0', 'U+0062' ] ← 一眼看出中间不是 U+0020
这一行能省掉半小时。常见嫌疑犯:
| 码点 | 是什么 | 伪装成 |
|---|---|---|
| U+00A0 | 不换行空格 | 空格 |
| U+3000 | 全角空格 | 空格 |
| U+200B | 零宽空格 | 什么都不显示 |
| U+FEFF | BOM | 什么都不显示,常在文件开头 |
| U+2018/2019 | 弯引号 | ' |
| U+2013/2014 | 短划线/破折号 | - |
处理外部来源的文本时,默认用 \s 而不是字面空格,能挡掉其中一半。
一份「看起来对但不对」清单
按被踩到的频率排:
- 忘了
^$—— 校验放行了带垃圾后缀的输入 - 拼字符串时少写一层反斜杠 —— 见第一步
\w不含点号、连字符、中文 —— 邮箱、域名、中文文本上一撞就断.不匹配换行 —— 测试数据是单行的,真实数据有换行(加s)- 忘了
g—— 只替换了第一处,长文本里看不出来 - 带
g的正则存成常量 —— 结果在 true / false 之间交替 {2,3}写成{2, 3}—— 逗号后有空格,整个花括号变成字面量$在 Python 里吃末尾换行,JS 里不吃 —— 跨语言搬运时踩
怎么查
把上面几步压成一个顺序,卡住的时候照着走:
1. console.log(re.source, re.flags) 模式真的是我想的那条吗
2. 砍成几段,找第一段不匹配的 错在哪一段
3. 打印输入的码点 数据和我看到的一样吗
4. 对照上面那份清单 是不是那八个之一
5. 还没解决 → 换个角度:这件事该用正则做吗
⭐ 第 5 步不是玩笑。查到这一步还没结果,通常说明需求本身超出了正则的舒适区—— 什么时候不该用正则那篇讲的就是这个边界。
下一步
《什么时候不该用正则》—— 这一篇教你把不工作的正则修好,下一篇告诉你有些正则不该被修好, 应该被换掉。
本篇示例
下面每一条都由 npm run test:regex 在每次构建前实跑验证, 结果是现算的,不是抄进数据里的副本。正文里的代码块只用来演示匹配过程和写法对照,不进闸门; 凡是「这个模式配这个输入得到这个结果」的断言,只存在于这里。
🚨
new RegExp('\d+')里的\d被字符串吃掉了- 调用
/d+/.test("123")- 结果
false- 换成
/\d+/ true
🚨 它不报错,而且会匹配到完全不同的东西(
"ddd"能过)。拼字符串造正则时\d要写成\\d。输入里那个「空格」不是空格
- 调用
/^a b$/.test("a b")- 结果
false- 换成
/^a\sb$/ true
⚠️ 从网页/PDF 复制来的文本最容易混进 U+00A0。
\s认得它,一个字面空格不认得。砍半定位:一整条不匹配时,先验第一段
- 调用
"a.b@example.com".match(/^(\w+)@/)- 结果
null- 换成
/^([\w.]+)@/ ["a.b@","a.b"]
\w不含点号,而邮箱本地部分允许点。把长模式砍成几段分别跑,比盯着整条看快得多。看起来是三个字符,实际是四个
- 调用
/^abc$/.test("abc")- 结果
false- 换成
/^ab.c$/ true
📌 怀疑不可见字符时,先
[...s].map(c => c.codePointAt(0).toString(16))把码点打出来 —— 这一步能省掉半小时。
练习
先自己写,再看答案。读懂和写得出是两件事,而这一节练的是后者。每道题的参考答案都由 npm run test:exercises-regex 实跑验证: 答案必须通过全部用例,「常见错解」必须至少被一条用例抓住, 而且 /.*/ 这类万能写法必须过不了 —— 否则这道题就没有区分度。
从访问日志行里取出 HTTP 状态码(三位数)—— 注意行里还有 IP 和日期
用 输入.match(re) 取结果
输入 期望 "192.168.1.1 - - [11/Sep/2026] \"GET /a\" 200 1234"["\" 200 ","200"] "10.0.0.2 - - [11/Sep/2026] \"POST /b\" 404 99"["\" 404 ","404"] 提示
状态码前面紧挨着的是什么字符?把那个字符也写进模式里当路标。
参考答案
/"\s(\d{3})\s/只写
(\d{3})会取到 IP 里的192—— 它有结果,所以你不会立刻发现取错了。要靠状态码两侧的引号和空格把位置锁住。常见错解
/(\d{3})/—— 它在"192.168.1.1 - - [11/Sep/2026] \"GET /a\" 200 1234"这条上就错了。匹配「a 空格 b」,而那个空格可能是从网页复制来的 U+00A0
用 re.test(输入) 判断
输入 期望 "a b"应匹配 "a b"应匹配 "ab"不应匹配 提示
有一个简写类专门表示「各种空白」。
参考答案
/^a\sb$/一个字面空格只认 U+0020。
\s覆盖普通空格、Tab、换行、U+00A0、全角空格 —— 处理外部来源的文本时,默认就该用\s。常见错解
/^a b$/—— 它在"a b"这条上就错了。取出邮箱
@前面的部分,本地部分可能含点号用 输入.match(re) 取结果
输入 期望 "a.b@example.com"["a.b@","a.b"] "ab@example.com"["ab@","ab"] 参考答案
/^([\w.]+)@/\w是[A-Za-z0-9_],不含点号。⭐ 注意它的失败形态是整条返回null(而不是取到一半),这正好是「砍半定位」能立刻查出来的那一类。常见错解
/^(\w+)@/—— 它在"a.b@example.com"这条上就错了。