文章

正则表达式面试通关

面试高频:test/exec/match/matchAll 怎么选、g 标志的 lastIndex 陷阱、贪婪与惰性、四种零宽断言、灾难性回溯 ReDoS,以及动态构造正则为什么必须转义。

正则表达式面试通关

一句话概括

正则面试不考你能不能背出一个 200 字符的邮箱正则,考的是四件事:API 选对没有、量词行为懂不懂、断言会不会用、有没有 ReDoS 意识。

说白了,正则的核心心智模型只有一句话:引擎从左到右逐位置尝试匹配,量词默认贪婪,走不通就回溯。理解”回溯”这两个字,贪婪/惰性、性能陷阱、零宽断言三块知识全都串起来了。

核心知识点

1. 七个 API 怎么选(问得最多)

先给结论表,面试直接照着答:

需求用什么返回
只要”有没有”re.test(str)boolean
要第一个匹配 + 分组str.match(re) 或 re.exec(str)数组(含 groups / index)
要所有匹配 + 分组str.matchAll(re)(必须带 g)迭代器
只要所有匹配的文本str.match(re) + g字符串数组(没有分组信息)
替换replace / replaceAll新字符串
拆分str.split(re)数组

三个高频坑:

1
2
3
4
5
6
7
8
9
10
// ① 带 g 的 match 会丢掉分组信息
"a1b2".match(/(\w)(\d)/g);   // ['a1','b2']  ← 拿不到 $1/$2
[..."a1b2".matchAll(/(\w)(\d)/g)]; // 每项都是完整匹配数组,分组都在

// ② matchAll / replaceAll 传非全局正则直接抛错
"aa".matchAll(/a/);   // ❌ TypeError: called with a non-global RegExp argument
"aa".replaceAll(/a/, "b"); // ❌ 同上;replaceAll 传字符串则没这个限制

// ③ split 带捕获组会把分隔符也塞进结果
"a1b2c".split(/(\d)/);  // ['a','1','b','2','c']

2. lastIndex 陷阱:带 g 的正则是”有状态”的

这题是经典送命题:

1
2
3
const re = /\d+/g;
re.test("a1");  // true   → 副作用:re.lastIndex 变成 2
re.test("a1");  // ❌ false!从下标 2 开始找,找不到了

原因:g(和 y)标志会让 test / exec 记住 lastIndex,下次从那儿继续找。三种解法:

1
2
3
4
5
6
7
8
// ✅ 方案一:test 就别加 g(最省事)
const re1 = /\d+/;

// ✅ 方案二:复用前手动重置
re.lastIndex = 0;

// ✅ 方案三:别把带 g 的正则提成共享常量,用时新建
const isNum = (s) => /\d+/g.test(s); // 每次字面量都是新对象

顺带记住 y(sticky):必须从 lastIndex 那个位置严格开始匹配,不像 g 会往后搜——写词法分析器/token 解析器时才用得上。

3. 贪婪 vs 惰性 vs 否定字符集

1
2
3
const html = "<p>A</p><p>B</p>";
html.match(/<p>(.*)<\/p>/)[1];   // 'A</p><p>B'  贪婪:吃到最后一个 </p>
html.match(/<p>(.*?)<\/p>/)[1];  // 'A'          惰性:够用就停
  • 贪婪(默认):先吃到底,再一点点回退,直到后面的模式能匹配。
  • 惰性(量词后加 ?):先只吃最少,不够再一点点多吃。

加分点:很多场景根本不用惰性,用”否定字符集”更快更清楚。

1
2
3
// ✅ 提取双引号里的内容,首选这个
'<a href="x">'.match(/"([^"]*)"/)[1];  // 'x'
// 因为 [^"] 物理上不可能吃到引号,一步到位,零回溯

4. 分组与反向引用

1
2
3
/(\d{4})-(\d{2})/           // 捕获组:靠位置 $1 $2 取
/(?:\d{4})-(\d{2})/         // 非捕获组:只分组不捕获,省开销
/(?<y>\d{4})-(?<m>\d{2})/   // 具名组:靠名字取,可读性好得多

具名组(ES2018)配 replace 的 $<name> 语法非常好用:

1
2
"2026-08-25".replace(/(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/, "$<d>/$<m>/$<y>");
// '25/08/2026'

反向引用 \1 / \k<name>——”要求前后一致”的题都靠它:

1
2
3
4
// 匹配成对的引号(单双都行,但必须首尾同种)
/(['"]).*?\1/.exec(`say 'hi'`)[0];  // "'hi'"
// 找连续重复的词
/\b(\w+)\s+\1\b/.test("the the");   // true

还有个冷门但会被问的 d 标志(ES2022),给出每个分组的起止下标,做语法高亮 / 编译器报错定位时用:

1
2
const m = /(?<y>\d{4})-(?<mo>\d{2})/d.exec("2026-08");
m.indices.groups;  // { y: [0,4], mo: [5,7] }

5. 断言:匹配”位置”而不消耗字符

先分清两类:

  • 边界断言:^ $(配 m 标志变成每行的行首行尾)、\b 单词边界。
  • 零宽断言(四种,务必记牢方向):
写法名称含义
x(?=y)正向先行x 后面是 y
x(?!y)负向先行x 后面不是 y
(?<=y)x正向后行x 前面是 y
(?<!y)x负向后行x 前面不是 y

记忆口诀:尖括号 < 指向左边,就是”看前面”(后行);没有 < 就是”看后面”(先行)。 后行断言是 ES2018 加的,如今主流浏览器都支持。

1
2
"$100 €200".match(/(?<=\$)\d+/)[0];   // '100'  只要美元后面的数字,且 $ 不进结果
"$100 €200".match(/(?<!\$)\b\d+/g);   // ['200'] 排除美元的

经典应用——千分位分隔:

1
2
"1234567".replace(/\B(?=(\d{3})+(?!\d))/g, ",");  // '1,234,567'
// \B 非单词边界 + 先行断言:往"后面剩余位数是 3 的倍数"的空位插逗号

⚠️ 这个正则有个真实面试坑:带小数就错了。

1
2
3
4
5
6
// ❌ 小数部分也被插了逗号
"1234567.8912".replace(/\B(?=(\d{3})+(?!\d))/g, ","); // '1,234,567.8,912'

// ✅ 只处理整数部分
"1234567.8912".replace(/\d+/, (n) => n.replace(/\B(?=(\d{3})+$)/g, ","));
// '1,234,567.8912'  (生产环境更推荐 Intl.NumberFormat)

6. 灾难性回溯(ReDoS)——高级岗必问

嵌套量词 + 匹配失败 = 指数级回溯,能把主线程直接卡死:

1
2
3
// ❌ 危险:(a+)+ 对同一段字符有指数级切分方式
/^(a+)+$/.test("a".repeat(28) + "b");  // 实测卡了 5 秒多
/^a+$/.test("a".repeat(50000) + "b");  // ✅ 改掉嵌套后:0 毫秒

为什么?a...ab 里 b 永远匹配不上,引擎就得穷举 (a+)+ 的每一种拆分方式来重试,复杂度 O(2^n)。

面试记住三条防御原则:

  1. 不写嵌套量词:(a+)+、(\w*)*、(.*)* 一律警惕。
  2. 用否定字符集替代 .*?:"[^"]*" 比 ".*?" 天然抗回溯。
  3. 正则别接受用户输入:搜索框、日志解析这类场景,要么白名单校验,要么加长度上限,要么干脆用 indexOf / includes。

7. 动态构造正则必须转义

1
2
3
4
5
6
7
8
9
const kw = "file.txt";
new RegExp(kw).test("fileAtxt");  // ❌ true!. 被当成通配符了

// ✅ ES2025 新增(Chrome 136+ / Node 24+ / Safari 18.2+)
new RegExp(RegExp.escape(kw)).test("fileAtxt");  // false

// ✅ 老环境手写兜底
const esc = (s) => s.replace(/[.*+?^$()|[\]\\{}]/g, "\\$&");
new RegExp(esc(kw)).test("fileAtxt");            // false

注意 RegExp.escape("foo.bar") 返回的不是 "foo\.bar" 而是 "\x66oo\.bar"(首字符也会被十六进制转义),这是刻意设计的,保证结果嵌到别的模式中间也安全。兼容性还没铺满,老项目继续用手写版更稳。

最后补一个 Unicode 小坑,问到就是加分:

1
2
3
4
"👍".length;                    // 2  → emoji 是两个码元
"👍a".match(/./)[0].length;     // 1  ❌ 切坏了代理对
"👍a".match(/./u)[0].length;    // 2  ✅ 加 u 后按码点匹配
/\p{Script=Han}/u.test("中");   // true  按 Unicode 属性匹配中文

v 标志(ES2024)是 u 的升级版,支持字符类求交求差和多字符字符串集合,比如 /[\p{RGI_Emoji}]/v 能整体匹配复合 emoji。

其实你每天都在用

  • 表单校验:手机号 /^1[3-9]\d{9}$/、密码强度用多个断言组合。
  • 驼峰下划线互转:str.replace(/_(\w)/g, (_, c) => c.toUpperCase())。
  • Vue Router / Express 路由:路径参数 /user/:id 底层就是编译成正则。
  • Webpack / Vite 配置:test: /\.tsx?$/ 就是正则匹配文件后缀。
  • ESLint / Prettier 的 ignore 规则、.gitignore glob 转正则。
  • 日志和埋点解析:从接口返回的长文本里 matchAll 抽字段。
  • 富文本 XSS 过滤:注意——这个场景别只靠正则,HTML 不是正则语言,要用专门的 sanitizer。

常见误解(FAQ)

❌ 误区一:”test 和 exec 都能反复调,结果一样”

带 g / y 的正则有 lastIndex 状态,同一个正则对象连续 test 同一字符串会 true / false 交替。共享的正则常量千万别带 g 去做布尔判断。

❌ 误区二:”match 加 g 就能拿到所有分组”

带 g 的 match 只返回匹配到的文本数组,groups、index 全丢。要分组就用 matchAll 或 exec 循环。

❌ 误区三:”惰性匹配 .*? 性能比贪婪好”

不一定。惰性只是改变了”先试少还是先试多”的顺序,回溯量取决于具体输入。真正省事的是否定字符集 [^x]*,它压根不会越界,无需回溯。

❌ 误区四:”\b 能正确处理中文分词”

不能。\b 的定义基于 \w(即 [A-Za-z0-9_]),中文字符不属于 \w,所以 "你好abc".match(/\babc\b/) 能匹配上(因为”好”和”a”之间恰好是边界),但用它做中文分词一定出错。中文场景要用 \p{Script=Han} 配 u 标志。

❌ 误区五:”复杂的正则只要写对了就行,性能不用管”

嵌套量词的灾难性回溯是真实的线上事故来源(Cloudflare 2019 年全球宕机就是 ReDoS)。正则接触用户输入时,安全性优先于简洁性。

❌ 误区六:”用正则校验邮箱要写最严格的那版”

完整符合 RFC 5322 的邮箱正则有几百字符,还是会误杀合法地址。工程实践是宽松正则(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)+ 发验证码确认,面试这么答比背长正则更显专业。

一句话总结

正则的一切行为都能用”逐位置尝试 + 量词贪婪 + 失败回溯“解释:API 层记住”带 g 有状态、matchAll 才有分组”,模式层记住”惰性能解决的用否定字符集更好”,断言层记住”尖括号朝左看前面”,最后加一句 ReDoS 与转义的安全意识,这题就满分了。

本文由作者按照 CC BY 4.0 进行授权