上手:真实场景与边界

正则不工作怎么查

它在解决什么

前面几篇讲了很多「为什么会错」。这一篇讲的是另一件事:

手上这条正则现在就不工作,我该按什么顺序查?

在生产里这是最高频的需求,而它和「理解正则」是两种不同的能力—— 知道贪婪是什么,不等于能在五分钟内定位一条两百字符的模式为什么突然不匹配了。

盯着模式硬看是最慢的办法。下面这套顺序,从最容易被忽略、也最容易排除的开始。

先分清症状

症状 最可能的方向
一个都不匹配 转义层数、不可见字符、\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$/ 匹配不了 'ab​c'——中间藏着一个零宽空格, 那串的长度其实是 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 而不是字面空格,能挡掉其中一半。

一份「看起来对但不对」清单

按被踩到的频率排:

  1. 忘了 ^ $ —— 校验放行了带垃圾后缀的输入
  2. 拼字符串时少写一层反斜杠 —— 见第一步
  3. \w 不含点号、连字符、中文 —— 邮箱、域名、中文文本上一撞就断
  4. . 不匹配换行 —— 测试数据是单行的,真实数据有换行(加 s)
  5. 忘了 g —— 只替换了第一处,长文本里看不出来
  6. 带 g 的正则存成常量 —— 结果在 true / false 之间交替
  7. {2,3} 写成 {2, 3} —— 逗号后有空格,整个花括号变成字面量
  8. $ 在 Python 里吃末尾换行,JS 里不吃 —— 跨语言搬运时踩

怎么查

把上面几步压成一个顺序,卡住的时候照着走:

1. console.log(re.source, re.flags)     模式真的是我想的那条吗
2. 砍成几段,找第一段不匹配的            错在哪一段
3. 打印输入的码点                        数据和我看到的一样吗
4. 对照上面那份清单                      是不是那八个之一
5. 还没解决 → 换个角度:这件事该用正则做吗

⭐ 第 5 步不是玩笑。查到这一步还没结果,通常说明需求本身超出了正则的舒适区—— 什么时候不该用正则那篇讲的就是这个边界。

下一步

《什么时候不该用正则》—— 这一篇教你把不工作的正则修好,下一篇告诉你有些正则不该被修好, 应该被换掉。

本篇示例

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

  1. 🚨 new RegExp('\d+') 里的 \d 被字符串吃掉了

    调用
    /d+/.test("123")
    结果
    false
    换成 /\d+/
    true

    🚨 它不报错,而且会匹配到完全不同的东西("ddd" 能过)。拼字符串造正则时 \d 要写成 \\d。

  2. 输入里那个「空格」不是空格

    调用
    /^a b$/.test("a b")
    结果
    false
    换成 /^a\sb$/
    true

    ⚠️ 从网页/PDF 复制来的文本最容易混进 U+00A0。\s 认得它,一个字面空格不认得。

  3. 砍半定位:一整条不匹配时,先验第一段

    调用
    "a.b@example.com".match(/^(\w+)@/)
    结果
    null
    换成 /^([\w.]+)@/
    ["a.b@","a.b"]

    \w 不含点号,而邮箱本地部分允许点。把长模式砍成几段分别跑,比盯着整条看快得多。

  4. 看起来是三个字符,实际是四个

    调用
    /^abc$/.test("ab​c")
    结果
    false
    换成 /^ab.c$/
    true

    📌 怀疑不可见字符时,先 [...s].map(c => c.codePointAt(0).toString(16)) 把码点打出来 —— 这一步能省掉半小时。

练习

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

  1. 从访问日志行里取出 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"这条上就错了。

  2. 匹配「a 空格 b」,而那个空格可能是从网页复制来的 U+00A0

    用 re.test(输入) 判断

    输入期望
    "a b"应匹配
    "a b"应匹配
    "ab"不应匹配
    提示

    有一个简写类专门表示「各种空白」。

    参考答案

    /^a\sb$/

    一个字面空格只认 U+0020。\s 覆盖普通空格、Tab、换行、U+00A0、全角空格 —— 处理外部来源的文本时,默认就该用 \s。

    常见错解 /^a b$/ —— 它在"a b"这条上就错了。

  3. 取出邮箱 @ 前面的部分,本地部分可能含点号

    用 输入.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"这条上就错了。