正则表达式面试通关
面试高频: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)。
面试记住三条防御原则:
- 不写嵌套量词:
(a+)+、(\w*)*、(.*)*一律警惕。 - 用否定字符集替代
.*?:"[^"]*"比".*?"天然抗回溯。 - 正则别接受用户输入:搜索框、日志解析这类场景,要么白名单校验,要么加长度上限,要么干脆用
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 规则、
.gitignoreglob 转正则。 - 日志和埋点解析:从接口返回的长文本里
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 与转义的安全意识,这题就满分了。