正则表达式(RegExp):贪婪/懒惰、捕获组、断言、g 的 lastIndex 与回溯坑
面试速答(30 秒版 TL;DR)
- JS 正则是
RegExp,核心要会:元字符、分组、量词、边界、断言、常用 flags。 - 贪婪量词(默认)会尽可能多匹配;在量词后加
?变懒惰(尽可能少)。 ()会产生捕获组,(?: )是非捕获组;(?= )/(?! )是前瞻断言;部分环境支持后顾(?<= )/(?<! )。- 带
g的正则在多次exec/test时会维护lastIndex,这是很多“偶现 bug”的根源。 - 复杂正则可能出现灾难性回溯导致卡死,面试要能说出原因与规避策略。
心智模型:正则到底在“跑”什么
把 JS 正则理解成一个“从左到右尝试匹配”的引擎(多数场景是回溯型 backtracking 引擎):
- 先按模式逐字符尝试,遇到分支(如
|、量词* + {m,n})会做选择 - 之后如果整体匹配失败,会回到最近的“选择点”改走另一条路(这就是回溯)
- 贪婪/懒惰不是“能不能匹配”,而是“在有歧义时先走哪条路”:贪婪先吃多再回退,懒惰先吃少再补
面试追问常见点:
- 你写的正则是否存在多重歧义(嵌套量词、可选分支叠加)从而触发指数级回溯(ReDoS)?
- 你是否用锚点
^/$或更具体的字符类把搜索空间“锁住”?
基础能力:会读会写
// 匹配 3 到 6 位数字
const re = /^\d{3,6}$/;
要点:
^/$:行首/行尾锚点\d:数字{m,n}:重复次数
语法速查:字符类、边界、分组、分支
1) 字符类(Character Class)
[...]:匹配集合中的任意 1 个字符,如[abc][^...]:取反,如[^0-9]匹配“非数字”- 范围:
[a-z]、[0-9](注意-在字符类里有特殊意义,需要时放到开头/结尾或转义) - 常用预定义类:
\d/\D:数字/非数字(JS 中等价于[0-9],不是“所有 Unicode 数字”)\w/\W:单词字符/非单词字符(大多数情况下约等于[A-Za-z0-9_],不是“所有语言的字母”)\s/\S:空白/非空白(包括空格、Tab、换行等)
示例:只允许字母、数字、下划线,且长度 4-16:
const re = /^[A-Za-z0-9_]{4,16}$/;
2) 边界(Anchors / Boundaries)
^、$:行首/行尾锚点- 配合
m(multiline)时:^/$会匹配每一行的开头/结尾,而不是整段字符串的开头/结尾
- 配合
\b、\B:单词边界/非单词边界(边界由\w与\W的切换定义)
示例:匹配独立单词 cat,避免匹配到 concatenate:
console.log("a cat".match(/\bcat\b/)[0]); // "cat"
console.log("concatenate".match(/\bcat\b/)); // null
3) 分组(Groups)
- 捕获组:
(...),会占用组号($1、\1) - 非捕获组:
(?:...),仅分组不捕获(性能更好,且不改变组号时很常用) - 命名捕获组:
(?<name>...),更利于维护(配合$<name>替换很爽)
4) 分支(Alternation)
a|ab的优先级常坑人:引擎从左到右尝试,先试a成功就不会再试ab- 最佳实践:用分组明确范围,如
(?:ab|a)或把更长的分支放前面
console.log("ab".match(/a|ab/)[0]); // "a"
console.log("ab".match(/ab|a/)[0]); // "ab"
贪婪 vs 懒惰:一道必考题
const s = "<b>1</b><b>2</b>";
console.log(s.match(/<b>.*<\/b>/)[0]); // "<b>1</b><b>2</b>"
console.log(s.match(/<b>.*?<\/b>/)[0]); // "<b>1</b>"
解释要点:
.*默认贪婪,把能吃的都吃掉,再回退满足后续.*?懒惰,能少吃就少吃,优先让后续尽快匹配
量词与作用范围:? * + {m,n} 的坑
量词只作用于它前面的一个原子(字符、字符类、分组等):
ab+:+只作用于b,不是ab(?:ab)+:+作用于整个ab
常见量词:
?:0 或 1 次(可选)*:0 次或多次+:1 次或多次{m}:恰好 m 次{m,n}:m 到 n 次{m,}:至少 m 次
贪婪/懒惰的组合记忆:
- 默认贪婪:
* + {m,n}会尽量多吃,再回退 - 变懒惰:在量词后加
?,如*? +? {m,n}? - 懒惰不等于“更快”:遇到复杂分支依旧可能回溯(甚至更糟),性能关键还是看歧义结构
Flags(修饰符)全量:g i m s u y d 都是啥
JS 正则常用 flags(不同环境支持度略有差异,面试时建议说清 Node/现代浏览器):
g(global):全局搜索。注意会引入lastIndex状态i(ignoreCase):忽略大小写m(multiline):多行模式,让^/$作用于每一行s(dotAll):让.可以匹配换行u(unicode):Unicode 模式(影响转义、码点处理、Unicode 属性类等)y(sticky):粘连匹配,只能从lastIndex位置“紧贴着”匹配d(hasIndices):返回匹配的索引范围(用于高亮/定位很有用)
示例:s 与 [\s\S] 的对比(跨行匹配):
console.log("a\nb".match(/a.b/)); // null
console.log("a\nb".match(/a.b/s)[0]); // "a\nb"
console.log("a\nb".match(/a[\s\S]b/)[0]); // "a\nb"
捕获组、命名组、反向引用
const re = /^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})$/;
const m = "2026-03-13".match(re);
console.log(m.groups.year); // "2026"
反向引用(同一内容重复出现):
console.log("aa__aa".match(/^(\w+)__\1$/) != null); // true
转义与动态拼正则:/.../ vs new RegExp()
1) 字面量更直观,但不能动态拼
const re1 = /\d+/g;
2) 构造函数可动态拼,但要注意“双层转义”
const re2 = new RegExp("\\d+", "g"); // 字符串里 \ 要写成 \\,否则会被 JS 字符串先吃掉
3) 把用户输入拼进正则前,一定要先 escape(避免“正则注入”与误匹配)
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const keyword = "a+b"; // 用户输入
const re = new RegExp(escapeRegExp(keyword), "g");
console.log("xx a+b yy".match(re)[0]); // "a+b"
常用字符串 API:match/exec/test/replace 怎么在工程里选
1) RegExp.prototype.test(str):只要布尔值
console.log(/^\d+$/.test("123")); // true
console.log(/^\d+$/.test("12a")); // false
2) RegExp.prototype.exec(str):要分组,要迭代
const re = /\d+/g;
const s = "a1b22c333";
let m;
while ((m = re.exec(s)) != null) {
// m[0] 是命中子串;m.index 是起始位置;m[1..] 是捕获组
console.log(m[0], m.index);
}
3) String.prototype.match(re):一次性拿结果
- 不带
g:返回类似exec的结果(含捕获组) - 带
g:只返回所有命中的子串数组(不含捕获组)
console.log("a1b22".match(/\d+/)); // ["1", index: 1, input: "a1b22", ...]
console.log("a1b22".match(/\d+/g)); // ["1", "22"]
4) String.prototype.matchAll(re):既要全局,又要捕获组
const s = "2026-03-13 2026-03-14";
const re = /(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/g;
for (const m of s.matchAll(re)) {
console.log(m[0], m.groups);
}
5) replace/replaceAll:掌握替换模板与函数替换
替换模板常用项:
$&:整个匹配$1、$2...:第 1/2 个捕获组$<name>:命名捕获组$$:字面量$- `$``:匹配前的内容
$':匹配后的内容
console.log("2026-03-13".replace(/(\d{4})-(\d{2})-(\d{2})/, "$2/$3/$1")); // "03/13/2026"
console.log("x=1,y=22".replace(/\d+/g, (m) => String(Number(m) + 1))); // "x=2,y=23"
补充:需要“精确定位每个捕获组的位置”时,考虑 d(hasIndices):
const re = /(\d+)-(\d+)/d;
const m = re.exec("a12-34b");
console.log(m[0], m.index); // "12-34" 1
console.log(m.indices[0]); // [1, 6] 整体命中范围(左闭右开)
console.log(m.indices[1]); // [1, 3] 第 1 组范围
console.log(m.indices[2]); // [4, 6] 第 2 组范围
断言(Assertions):匹配位置,不消耗字符
前瞻示例(后面跟着数字但不包含数字):
console.log("price=100".match(/price(?==\d+)/)[0]); // "price"
补充要点:
- 正向前瞻:
(?=...),负向前瞻:(?!...) - 正向后顾:
(?<=...),负向后顾:(?<!...) - 后顾断言兼容性与限制在面试里要说清:旧环境可能不支持;并且引擎通常要求后顾的“长度可确定或可控”(过于复杂的变长后顾可能不被接受)
示例:只匹配 = 后面的数字,但不包含 =:
console.log("a=12,b=3".match(/(?<=\=)\d+/g)); // ["12", "3"](不支持后顾的环境会报错)
g 与 lastIndex:为什么 test 会一会儿 true 一会儿 false
const re = /a/g;
console.log(re.test("a")); // true
console.log(re.test("a")); // false(lastIndex 变了)
console.log(re.test("a")); // true
口述要点:
- 带
g时,test/exec会从lastIndex位置继续匹配 - 复用同一个正则对象时要小心:需要时手动
re.lastIndex = 0或每次 new 一个
再补一个常见追问:g vs y(sticky)区别是什么?
g:从lastIndex开始“向后找”,可以跳过不匹配的字符y:必须在lastIndex位置“紧贴着”就匹配,否则直接失败(更适合写词法扫描器/逐 token 解析)
const s = "a1b2";
const g = /\d/g;
g.lastIndex = 1;
console.log(g.exec(s)[0]); // "1"
g.lastIndex = 2;
console.log(g.exec(s)[0]); // "2"(会跳过 "b" 再找到 "2")
const y = /\d/y;
y.lastIndex = 1;
console.log(y.exec(s)[0]); // "1"
y.lastIndex = 2;
console.log(y.exec(s)); // null(必须紧贴着匹配)
Unicode 与中文场景:什么时候一定要加 u
JS 字符串是 UTF-16 编码单元序列,不加 u 时很多行为是“按编码单元”而不是“按 Unicode 码点”:
- 处理 emoji 等超出 BMP 的字符时,不加
u可能出现“把一个字符拆成两个”的问题 \u{...}这种码点写法需要u- Unicode 属性转义(如
\p{L}、\p{Script=Han})需要u
示例:用 \p{Script=Han} 匹配中文(现代环境):
const re = /^\p{Script=Han}+$/u;
console.log(re.test("中文")); // true
console.log(re.test("中文ABC")); // false
工程建议(面试可说):
- 只处理 ASCII(账号、ID)时:明确写
[A-Za-z0-9_],不要误以为\w覆盖所有字母 - 处理多语言文本时:优先考虑
u+\p{...},并说明兼容性(旧环境可能不支持)
灾难性回溯:正则为什么会“卡死”
典型风险形态:嵌套量词或可选分支导致指数级尝试。
// 风险示例:不要在不受控输入上使用
const re = /^(a+)+$/;
规避策略(面试版):
- 尽量用更具体的表达式,减少歧义(例如用字符类、明确边界)
- 避免可导致大量回溯的嵌套量词
- 对不受控输入设置超时/长度上限(服务端尤重要)
典型题 & 标准答法
Q1:match、exec、test 怎么选?
test:只要布尔结果exec:需要分组捕获,且可配合g逐次迭代match:一次性取匹配结果(是否返回分组取决于是否有g)- 追问:既要全局又要捕获组?用
matchAll
- 追问:既要全局又要捕获组?用
Q2:正则能解析 HTML 吗?
面试建议答法:不要。HTML 不是正则语言,复杂嵌套会失败且易回溯爆炸;应使用解析器(DOMParser、Cheerio 等)。
易错点/坑
.默认不匹配换行;需要时用s(dotAll)或[\s\S]。/^...$/是“整串匹配”,而不是“包含匹配”。g会引入lastIndex状态,复用对象需小心。- 用
new RegExp(str)拼正则时要注意转义层级:字符串里\d需要写成"\\d"。 match带g时不返回捕获组,很多人以为“丢了”。要捕获组用exec循环或matchAll。- 分支优先级:
a|ab会先吃a;用分组或把更长的分支放前面。 \b的“单词边界”是按\w定义的,对中文并不友好(中文分词不是它能解决的)。
速记要点(可背诵)
- 贪婪默认,量词加
?变懒惰。 ()捕获,(?:)不捕获;前瞻/后顾是匹配位置。g有状态(lastIndex),可能导致 test/exec 结果交替。- 既要全局又要捕获组:
matchAll;想做粘连扫描:用y。