跳到主要内容

正则表达式(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:matchexectest 怎么选?

  • test:只要布尔结果
  • exec:需要分组捕获,且可配合 g 逐次迭代
  • match:一次性取匹配结果(是否返回分组取决于是否有 g
    • 追问:既要全局又要捕获组?用 matchAll

Q2:正则能解析 HTML 吗?

面试建议答法:不要。HTML 不是正则语言,复杂嵌套会失败且易回溯爆炸;应使用解析器(DOMParser、Cheerio 等)。


易错点/坑

  • . 默认不匹配换行;需要时用 s(dotAll)或 [\s\S]
  • /^...$/ 是“整串匹配”,而不是“包含匹配”。
  • g 会引入 lastIndex 状态,复用对象需小心。
  • new RegExp(str) 拼正则时要注意转义层级:字符串里 \d 需要写成 "\\d"
  • matchg 时不返回捕获组,很多人以为“丢了”。要捕获组用 exec 循环或 matchAll
  • 分支优先级:a|ab 会先吃 a;用分组或把更长的分支放前面。
  • \b 的“单词边界”是按 \w 定义的,对中文并不友好(中文分词不是它能解决的)。

速记要点(可背诵)

  • 贪婪默认,量词加 ? 变懒惰。
  • () 捕获,(?:) 不捕获;前瞻/后顾是匹配位置。
  • g 有状态(lastIndex),可能导致 test/exec 结果交替。
  • 既要全局又要捕获组:matchAll;想做粘连扫描:用 y