正则表达式进阶

张玥 2026年9月20日 阅读时间 40分钟
正则表达式 JavaScript 文本处理 调试 性能
前端开发技巧之正则表达式进阶

正则表达式大概是程序员工具箱里最“两极分化”的一件东西:有人把它当成万能钥匙,一行 /.*/ 走天下;有人一看到反斜杠和括号就头皮发麻,宁可写三十行 indexOf 也不碰它。真相介于两者之间——正则不是魔法,它是一门只有几十个符号的微型语言,而它的难点从来不在语法,在于“你写的符号,引擎是怎么理解的”。很多人卡在正则,不是因为记不住 \d 和 \w,而是因为没有建立起“回溯”“贪婪”“断言不消费字符”这几条底层直觉。本篇 40 分钟长文,从引擎的执行模型讲起,把字符类、量词、分组、断言、标志、回溯逐层拆开,配合十来个可以直接上手操作的试验台——你可以实时改模式、改标志、改测试文本,亲眼看着匹配结果变红变绿。后半程会进入工程化部分:灾难性回溯的成因与规避、常见业务正则的逐字拆解、以及一套可落地的调试方法论。读完之后,你未必能背下所有语法,但你一定能看着一条正则,说出它会在什么输入上爆炸。

一、先搞懂引擎:正则到底是怎么跑的

绝大多数人学正则的顺序是“先背语法,再试着写”。这个顺序是反的。正确的顺序是:先知道引擎拿到模式和字符串之后做了什么,再去理解每个符号的意义。因为所有“为什么这个正则不匹配”“为什么这个正则慢得要死”的疑问,答案都在引擎的执行模型里。

1.1 两种引擎:DFA 与 NFA

正则引擎大致分两派:

DFA(确定有限自动机)

把整个正则编译成一张状态转移图,然后拿着字符串一个字符一个字符地“走图”。

特点:每个字符只处理一次,速度稳定,永远不会回溯,也永远不会因为正则写法而爆炸。
代价:不支持反向引用、不支持捕获组回填、不支持复杂的断言。
代表:RE2、Go 的 regexp、Rust 的 regex crate。

NFA(非确定有限自动机)

引擎从字符串的某个位置出发,沿着正则的写法一路“尝试”,走不通就退回来换个分支再试。

特点:功能完整,支持反向引用、断言、具名组。
代价:一旦写法不当,尝试次数会指数级增长——这就是灾难性回溯。
代表:JavaScript(V8 的 Irregexp)、PCRE、Python、Java、.NET。

JavaScript 用的是后者。这意味着你在 JS 里写的每一条正则,都有可能因为一个不巧的输入而从 0.1 毫秒变成 10 秒。这不是危言耸听,第七节会有可复现的实测。

1.2 一次匹配的完整流程

当你写下 /ab+c/.test('xxabbc'),引擎大致做了这几件事:

1
编译 把模式字符串解析成内部指令树
2
定位起点 从索引 0 开始尝试,失败就右移一位
3
逐项匹配 按顺序消费字符,遇到量词就记录状态
4
回溯 走不通就退回上一个可选项,换一条路
5
返回结果 成功返回匹配区间与捕获组,失败返回 null

点击上面的步骤卡片可以展开细节。第 2 步和第 4 步是理解正则性能的两把钥匙。

第 2 步意味着:如果你的正则没有用 ^ 锚定,引擎会从字符串的每一个位置都试一遍。一个 10000 字符的字符串,最坏情况下就是一万次尝试。

第 4 步意味着:量词 *、+、?、{n,m} 在引擎内部都是“先尽可能多地吃掉字符,然后根据后面的模式决定要不要吐回来”。这个“吐回来”的动作就是回溯。

1.3 一个最小的回溯演示

模式 /a+ab/ 匹配字符串 "aaab",引擎的实际动作是:

// 目标:/a+ab/ 匹配 "aaab"

// 第 1 次尝试:a+ 贪婪,一口气吃掉全部 3 个 a
  a+ → "aaa" 剩余 "b"
  接着要匹配字面量 "a",但剩余是 "b" → 失败

// 第 2 次尝试:回溯,a+ 吐回一个 a
  a+ → "aa" 剩余 "ab"
  字面量 "a" 匹配成功,接着 "b" 匹配成功 → 整体匹配 "aaab"

// 一共尝试了 2 次。如果字符串是 "aaaaaaaaaaab"(10 个 a)呢?
// 最坏情况下会退 10 次——注意,这里是线性增长,还不可怕。
// 可怕的是"嵌套量词",见第七节。

这个例子说明了一件重要的事:正则引擎并不"聪明",它只是忠实地执行"先贪婪、再回溯"的规则。你写的每一个量词,都在给它增加潜在的退路。

1.4 反模式:正则的常见误用

误用方式 典型代码 后果
用正则解析 HTML / XML /<div>(.*?)<\/div>/ 嵌套标签直接失效,极端输入下回溯爆炸
用正则校验邮箱"绝对正确" 两百字符的 RFC 5322 正则 可读性归零,仍有漏网之鱼,不如发验证邮件
忘记转义用户输入 new RegExp(userInput) 用户输入 ( 直接抛语法错误,甚至 ReDoS
在循环里重复编译正则 for (...) { /abc/.test(x) } 字面量会被缓存,但 new RegExp 不会
用 . 匹配任意字符 /a.b/ . 不匹配换行,跨行场景静默失败
嵌套量词不加约束 /(a+)+b/ 灾难性回溯,可被恶意输入打挂页面

二、语法精要:把字母表变成描述

正则的语法其实非常小:字符类 + 量词 + 分组 + 断言 + 锚点,五样东西。难点在于它们的组合方式,以及不同组合在引擎里的代价差异。

2.1 元字符与转义

下面这 12 个字符在正则里有特殊含义,要匹配它们本身必须加反斜杠:

. * + ? ^ $ { } [ ] ( ) | \

记忆口诀是:“点星加问、尖头圆尾、方括号竖线、反斜杠自己”。其中 ^ 和 $ 在字符类内部会失去锚点含义([^abc] 表示"除 abc 外"),- 在字符类首尾也是字面量。

// 想匹配文件路径里的点,必须转义
/file\.txt/ // ✅ 匹配 "file.txt"
/file.txt/ // ❌ 会匹配 "fileXtxt"、"file1txt"

// 字符类内部的特殊规则
/[.]/ // 就是字面量点,无需转义
/[a-z-]/ // 末尾的 - 是字面量
/[-a-z]/ // 开头的 - 也是字面量
/[a\-z]/ // 中间的 - 需转义才表示字面量

2.2 字符类:把"或"压缩成方括号

写法 含义 等价于
.任意字符(不含换行)[^\n\r\u2028\u2029]
\d数字[0-9]
\D非数字[^0-9]
\w单词字符[A-Za-z0-9_]
\W非单词字符[^A-Za-z0-9_]
\s空白字符[ \t\n\r\f\v\u00a0...]
\S非空白字符上面取反
[\u4e00-\u9fa5]常用汉字基本区 CJK 范围

一个常被忽略的细节:\s 包含的不只是空格和换行,还包括全角空格 \u3000、不换行空格 \u00a0、以及各种 Unicode 空白。所以在处理用户输入时,\s 往往比 (字面空格)更合适。

2.3 量词:重复的次数

* 0 次或多次,等价于 {0,}
+ 1 次或多次,等价于 {1,}
? 0 次或 1 次,等价于 {0,1}
{n} 恰好 n 次
{n,} 至少 n 次
{n,m} n 到 m 次,包含两端
危险的写法
/(\d+)*\./
嵌套量词,输入 "1111111111111111x" 时可能瞬间卡死。
安全的写法
/\d+\./
去掉外层量词后语义相同(因为 \d+ 已经能覆盖任意位数),但不会爆炸。

2.4 分组:把一串符号当成一个整体

括号的作用有三个:分组(改变量词作用范围)、捕获(把匹配内容单独取出来)、分支(配合 | 表示"或")。

// 不加括号:量词只作用于前一个字符
/ab+/ // 匹配 "ab"、"abb"、"abbb" —— b 重复

// 加括号:量词作用于整组
/(ab)+/ // 匹配 "ab"、"abab"、"ababab" —— "ab" 重复

// 分支
/(cat|dog|bird)/ // 匹配三种动物之一
/cat|dog|bird/ // 同上,但括号有时是必要的

// 分支的优先级陷阱
/^abc|def$/ // 实际是 (^abc) 或 (def$),不是 ^(abc|def)$
/^(abc|def)$/ // 这才是"整串是 abc 或整串是 def"

2.5 逐段拆解一条真实的正则

下面是一条常见的邮箱校验正则。点击任意片段,看看它到底在做什么:

👆 点击上面任意一个片段,这里会显示它的详细说明。

把这七个片段连起来读,就是一句话:“从开头起,来一串允许的用户名字符;遇到一个 @;接着来一串域名允许的字符;一个转义的点;至少两个字母;然后结束。”——这就是读懂正则的正确方式:不要逐个字符看,要按“块”看。

三、贪婪、惰性、占有:量词的三种性格

这是正则里最容易产生“为什么结果和我预期不一样”的地方。同一个模式,只加一个 ?,匹配结果可能完全不同。

3.1 三种模式的定义

类型 写法 行为
贪婪(Greedy) * + ? {n,m} 尽可能多匹配,匹配不下再回溯吐回来
惰性(Lazy) *? +? ?? {n,m}? 尽可能少匹配,后面的模式匹配不上再多吃一个
占有(Possessive) *+ ++ {n,m}+ 尽可能多匹配,且绝不回溯。JavaScript 目前不支持

切换下面的按钮,观察同一段文本、同一条“意图”,三种写法给出的不同结果:

测试文本
<b>加粗</b><i>斜体</i>
匹配结果

结论非常清楚:

  • <.+> 贪婪,一路吃到最后一个 >,所以把整行都吞了。
  • <.+?> 惰性,吃到第一个 > 就停,得到两个短标签。
  • <[^>]+> 用字符类排除 >,同样得到两个短标签,而且不需要回溯。
推荐:用否定字符类代替惰性量词
当你能明确说出“不要什么”时,[^x]+ 通常比 .+? 更快也更准。因为惰性量词每一步都要尝试“后面的模式能不能匹配”,而否定字符类只是单纯地排除。
警惕:惰性不等于正确
<.+?> 遇到 <a href="x>y"> 时会在 x 后面的 > 提前截断,得到错误结果。正则处理结构化文本的局限就在这里。

3.2 为什么 JavaScript 没有占有量词

占有量词(也叫原子组)在 PCRE、Java、.NET 里都有,它的作用是“吃掉之后绝不吐回”,从根源上杜绝回溯。JS 至今没有实现,因为它会破坏一些依赖回溯的特性(比如反向引用)。

不过有一个常用的模拟技巧——用先行断言 + 反向引用制造原子组:

// 目标:模拟 (?>a+) 原子组,即吃掉所有 a 之后不再退回

// 技巧:用先行断言一口气吃掉,再用反向引用"确认"
/(?=(a+))\1b/

// 拆解:
// (?=(a+)) 先行断言:从当前位置往后看,捕获全部 a 到分组 1
// 注意,断言本身不消费字符
// \1 反向引用:把分组 1 捕获到的内容原地消费掉
// b 字面量 b

// 效果:a+ 的部分变成"一次性消费",不会因为后面匹配失败而逐个吐回,
// 从而把灾难性回溯降级为线性。但这个技巧可读性差,
// 能用否定字符类解决时优先用否定字符类。

3.3 量词选择决策表

你想做的事 优先选择 次选 避免
匹配到某个字符为止 [^x]+ .+? .+
匹配一对括号之间的内容 \([^)]*\) \(.*?\) \(.*\)
匹配多行中的一行 ^.+$ + m [^\n]+ .*(不带 s)
匹配可选的协议前缀 (?:https?:)? (https?)? .*?

四、分组与捕获:把匹配结果切开

分组不只是“给量词限定范围”,它更重要的价值是把一次匹配拆成结构化的数据。这是正则从“判断真假”升级为“提取信息”的关键一步。

4.1 三种分组的写法与代价

( ) 捕获组。会出现在 match() 结果里,可以用 $1 引用。有内存开销
(?: ) 非捕获组。只分组不记录。当你只需要限定量词范围时,用它
(?<name> ) 具名捕获组。结果里通过 groups.name 访问,可读性最好
\1 \2 \k<name> 反向引用。匹配"和前面某个分组完全相同的内容"
// 非捕获组:这里只是想给量词限定范围,并不需要捕获
/(?:https?):\/\//
// 用 ( ) 也能工作,但会白白多出一个分组,影响 $1 $2 的编号

// 具名组:现代 JS 的推荐写法
const re = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const m = '2026-09-20'.match(re);

m.groups.year; // "2026"
m.groups.month; // "09"
m.groups.day; // "20"
m[1]; // "2026" —— 具名组同样占用编号

// 反向引用:找出重复的词
const dup = /\b(\w+)\s+\1\b/;
dup.test('hello hello world'); // true
dup.test('hello world'); // false

// 具名版反向引用
/\b(?<word>\w+)\s+\k<word>\b/;

4.2 实战:用一条正则拆解 URL

下面这个试验台会实时解析你输入的 URL,把协议、主机、端口、路径、查询串、锚点分别提取出来。试着改一改输入:

输入一个 URL
解析结果
const URL_RE = /^(?<protocol>[a-z][a-z0-9+.-]*):\/\/(?<host>[^/:?#]+)(?::(?<port>\d+))?(?<path>\/[^?#]*)?(?:\?(?<query>[^#]*))?(?:#(?<hash>.*))?$/i;

// 逐段读:
// ^([a-z][a-z0-9+.-]*):\/\/ 协议:字母开头,后跟字母数字和 +.-
// ([^/:?#]+) 主机:不含 / : ? # 的任意字符
// (?::(\d+))? 端口:可选,冒号加数字
// (\/[^?#]*)? 路径:可选,从 / 开始,不含 ? #
// (?:\?([^#]*))? 查询串:可选,? 开头,到 # 为止
// (?:#(.*))? 锚点:可选,到结尾为止
// $ 整串结束

// 注意:这里大量使用了 [^x] 而不是 .*?,
// 因为"排除特定字符"既准确又不需要回溯。

4.3 replace 里的特殊变量

String.prototype.replace 的第二个参数是一个被严重低估的工具。它支持一组以 $ 开头的替换变量:

变量 含义
$$一个字面量的 $
$&整个匹配到的内容
$`匹配之前的文本
$'匹配之后的文本
$1 ~ $99第 n 个捕获组
$<name>具名捕获组

下面这个演示会把日期格式从 YYYY-MM-DD 转换成 DD/MM/YYYY。改一改输入试试:

输入日期
// 用的是具名组 + $<name> 替换
text.replace(
  /(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/g,
  '$<d>/$<m>/$<y>'
);

// 用函数的写法更灵活(可以做大写转换、条件判断等)
text.replace(/(\d{4})-(\d{2})-(\d{2})/g, (full, y, m, d) => {
  return `${d}/${m}/${y}`;
});

还有一个常见需求是千分位分隔,用正则实现非常简洁:

// 数字千分位:利用先行断言保证"后面正好还有 3 的倍数位"
function formatNumber(n) {
  return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}

formatNumber(1234567); // "1,234,567"
formatNumber(1234567.891); // "1,234,567.891"

// 拆解:
// \B 非单词边界(不能在字符串最开头插入逗号)
// (?=...) 先行断言:要求当前位置后面满足某条件
// (\d{3})+ 后面是 3 的整数倍个数字
// (?!\d) 而且紧接着不能是数字(保证是最后一组)
// 由于断言不消费字符,所以每次匹配的都是"零宽位置",只插入逗号

五、断言:不消费字符的匹配

断言(Assertion)也叫“零宽断言”,它的特点是只判断条件是否成立,不占用字符。理解这一点,就能理解为什么 /(?=a)a/ 匹配的是同一个 a,而不是两个。

5.1 四种断言

写法 名称 含义
(?=...)先行肯定右边必须能匹配 ...
(?!...)先行否定右边必须不能匹配 ...
(?<=...)后行肯定左边必须能匹配 ...
(?<!...)后行否定左边必须不能匹配 ...
// 先行肯定:密码必须包含数字,但我们不想把数字吃掉
/^(?=.*\d).{8,}$/

// 先行否定:匹配后面不是"元"的价格数字
/\d+(?!元)/

// 后行肯定:取 @ 后面的用户名
/(?<=@)\w+/
// "hello @zhangyue" → 匹配 "zhangyue"

// 后行否定:匹配不在 $ 后面的数字
/(?<!\$)\d+/
// "价格 $100,库存 20" → 只匹配 "20"

5.2 用断言实现密码强度校验

这是断言最经典的应用场景:用多个先行断言,把“与”条件叠加起来,而不用写一堆 if。在下面输入密码试试:

输入密码
至少 8 位
含小写字母
含大写字母
含数字
含特殊符号
不含空白字符
// 一次性校验所有规则:五个先行断言叠加
const STRONG_PWD = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9])(?!.*\s).{8,}$/;

// 逐段读:
// ^ 从开头
// (?=.*[a-z]) 后面某处必须有小写字母
// (?=.*[A-Z]) 后面某处必须有大写字母
// (?=.*\d) 后面某处必须有数字
// (?=.*[^A-Za-z0-9]) 后面某处必须有非字母数字字符
// (?!.*\s) 后面任何位置都不能有空白
// .{8,} 真正的消费部分:至少 8 个字符
// $ 到结尾

// 关键点:前面五个断言都是"零宽"的,它们只是在原地"探头看",
// 真正消费字符的只有 .{8,} 这一段。如果没有它,正则只会匹配到空串。
断言的优势
一条正则搞定全部规则,新增规则只需再加一个 (?=...),不需要改动结构。而且每个断言可以独立拆出来做单元测试。
断言也有代价
每个 (?=.*x) 都会让引擎在当前位置向后扫描一遍。规则很多、字符串很长时,开销会叠加。如果只是为了给用户实时提示,用几个独立的 test() 反而更清晰、更易缓存。

5.3 词边界:最被低估的断言

\b 是一个零宽的“单词边界”断言,它匹配的是“\w 与 \W 之间的位置”。它不需要写括号,但作用等同于断言。

// 想替换独立的 "cat",不碰 "category" 和 "concat"
'cat category concat cat'.replace(/\bcat\b/g, '🐱');
// → "🐱 category concat 🐱"

// 没有 \b 的后果
'cat category concat'.replace(/cat/g, '🐱');
// → "🐱 🐱egory con🐱" ← 灾难

// 相关断言
\b // 单词边界(\w 和 \W 之间)
\B // 非单词边界

// 注意:\b 对中文不友好,因为中文不属于 \w
// 处理中英混排时,通常需要自定义边界
/(?<![a-zA-Z])cat(?![a-zA-Z])/;

六、标志与 Unicode:那些“改了结果”的开关

标志(flags)写在正则字面量的第二个斜杠后面:/pattern/gimsuy。它们看起来不起眼,但每一个都会显著改变匹配行为。

6.1 六个标志速览

标志 名称 作用
gglobal找出所有匹配,而不是只找第一个
iignoreCase忽略大小写
mmultiline^ 和 $ 匹配每一行的开头结尾
sdotAll. 也匹配换行符
uunicode按码点处理,支持 \p{...} 和 \u{...}
ysticky从 lastIndex 处精确匹配,不向前搜索

6.2 亲手体验 g / i / m 的差别

下面的试验台使用固定的测试文本,你可以随意增删标志,观察匹配结果的变化:

测试文本(共 4 行)
aaa
A1a2A
bbb
aXXa
模式
/ /
匹配结果

建议按这个顺序试一遍,体会会非常直观:

  1. 不加任何标志 → 什么都不匹配。因为 ^ 和 $ 锚定的是整串的开头和结尾,而整串里有换行。
  2. 加上 m → 匹配到 aaa 和 aXXa 两行。
  3. 再加上 i → A1a2A 这一行也被匹配到了。
  4. 再加上 g → 结果本身不变(因为模式已经能找到多处),但 match() 的返回形态会改变。

6.3 sticky 标志:被遗忘的 y

y 标志和 g 长得很像,但行为完全不同:g 会从 lastIndex 开始向后搜索,y 则要求必须正好在 lastIndex 处匹配,不匹配就立即返回 null。

const reG = /\d+/g;
const reY = /\d+/y;
const str = 'abc123def456';

reG.lastIndex = 0;
reG.exec(str); // 匹配 "123"(跳过 abc)

reY.lastIndex = 0;
reY.exec(str); // null —— 0 位置是 'a',不是数字

reY.lastIndex = 3;
reY.exec(str); // 匹配 "123" —— 正好在 3 位置

// y 的典型用途:手写词法分析器
const tokenizer = [
  { type: 'number', re: /\d+/y },
  { type: 'ident', re: /[a-zA-Z_]\w*/y },
  { type: 'space', re: /\s+/y },
];

6.4 u 标志与 Unicode 属性转义

没有 u 标志时,JavaScript 的字符串处理是“按 UTF-16 码元”的,这会导致一个 emoji 被当成两个字符。加上 u 之后,引擎按码点处理:

const emoji = '👨‍👩‍👧';

/^.$/.test(emoji); // false —— 一个 emoji 在无 u 模式下"太长"了
/^.$/u.test(emoji); // true —— 有 u 模式按码点算

// 码点转义写法
/\u{1F600}/u.test('😀'); // true

// Unicode 属性转义:按"字符的类别"匹配,而不是按码点范围
/\p{Script=Han}/u.test('汉'); // true
/\p{Emoji}/u.test('😀'); // true
/\p{Number}/u.test('٣'); // true —— 阿拉伯数字
/\p{Letter}/u.test('é'); // true

// 取反:\P{...}(大写 P)
/\P{Script=Han}/u.test('A'); // true

// 实战:只保留中英文和数字,去掉所有标点
const clean = (s) => s.replace(/[^\p{Script=Han}\p{Letter}\p{Number}]/gu, '');
clean('Hello,世界!2026。');
// → "Hello世界2026"

在 u 模式下,还有两个必须知道的规则:

  • 无效的转义会直接报错。无 u 模式下 /\a/ 会被当作字面量 a,有 u 模式则会抛出 SyntaxError。这其实是好事——能让拼写错误尽早暴露。
  • 量词作用于码点而非码元。/^👨‍👩‍👧{2}$/u 的语义是“这个 emoji 重复两次”,而不是“最后一个码元重复两次”。

6.5 那个经典的 g 标志陷阱

这是 JavaScript 正则最著名的坑,没有之一:

const re = /\d+/g;

re.test('a1b2c3'); // true —— lastIndex 变成 2
re.test('a1b2c3'); // true —— lastIndex 变成 4
re.test('a1b2c3'); // true —— lastIndex 变成 6
re.test('a1b2c3'); // false —— 从 6 开始找不到了
re.test('a1b2c3'); // true —— lastIndex 归零,重新开始

// 罪魁祸首:带 g 或 y 标志的正则对象会"记住"上一次的位置
// 这在循环里会造成难以复现的 bug:

const re2 = /\d+/g;
const arr = ['a1', 'b2', 'c3', 'd4'];
arr.forEach((s) => {
  console.log(s, re2.test(s)); // true, false, true, false —— 交替出现!
});

// 解决办法一:改用字符串方法或每次新建正则
arr.forEach((s) => console.log(/\d+/.test(s)));

// 解决办法二:用完把 lastIndex 归零
re2.lastIndex = 0;

// 解决办法三:用 match / matchAll 这类一次性方法
for (const m of 'a1b2c3'.matchAll(/\d+/g)) {
  console.log(m[0]);
}
常见踩坑
把带 g 的正则定义在模块顶层,然后在多个函数里复用 .test()。第一次跑没问题,第二次开始结果就飘了。
稳妥做法
只做“是否存在”判断时,不要加 g。需要遍历所有匹配时,用 matchAll 或 replace,它们内部会正确管理 lastIndex。

七、回溯:性能的隐形杀手

如果说前面六节讲的是“怎么写得对”,这一节讲的是“怎么写得快、写得安全”。灾难性回溯(Catastrophic Backtracking)是前端最容易被忽视的一类性能事故,因为它平时完全正常,只在特定输入下才会爆发。

7.1 什么导致了指数级回溯

答案是:嵌套量词 + 无法匹配的结尾。看这个经典模式:

/(a+)+b/

// 匹配 "aaaa...ac"(结尾不是 b)时,引擎会:
// 外层 + 尝试"分成 1 组",内层 + 吃掉所有 a → 发现后面不是 b → 回溯
// 外层 + 尝试"分成 2 组":第 1 组吃几个?第 2 组吃几个?
// 每一种"分组方式"都要试一遍 —— 这就是 2^n 的来源

// n 个 a 的可能性数量:2^(n-1)
// n=16 → 32768 次
// n=20 → 524288 次
// n=24 → 8388608 次
// n=30 → 5.3 亿次 ← 浏览器直接卡死

7.2 亲手制造一次灾难性回溯

下面的试验台会真实地运行危险正则,并测量耗时。请从最小规模开始点,不要一上来就点最大的。

危险模式:/(a+)+b/ 安全模式:/a+b/
危险正则 /(a+)+b/ —
安全正则 /a+b/ —

你会发现一件很重要的事:两个正则的语义几乎相同(都表示“一串 a 后面跟 b”),但性能差距是几千倍。这就是为什么“正则能跑就行”是一种危险的开发习惯。

7.3 四种防回溯的写法

方法一:用否定字符类消除嵌套

// 危险
/(\w+\s?)*/

// 安全:用否定字符类明确"不要什么"
/[\w\s]*/
// 或者更精确地描述结构
/\w+(?:\s+\w+)*/

方法二:把重叠的分支改成互斥的

// 危险:两个分支都能匹配 "0",引擎要试两次
/(0|[0-9])+/

// 安全:合并成互斥的字符类
/[0-9]+/

方法三:用锚点限制搜索起点

// 危险:没有锚点,引擎会从每个位置重试
/(\d+)+x/

// 安全:加上 ^ 后,失败就立即结束,不做位置偏移
/^(\d+)+x/ // 还是有嵌套,但至少不会乘以 n
/^\d+x/ // 更好:彻底去掉嵌套

方法四:从源头限制输入长度

// 如果输入本身就不可能很长,回溯的上限就是可控的
function safeTest(input) {
  const MAX = 200;
  if (typeof input !== 'string' || input.length > MAX) {
    throw new Error('输入过长');
  }
  return /(\d+)+x/.test(input);
}

// 这是最简单也最有效的防御:
// ReDoS 攻击的前提是攻击者能提供超长输入,掐断这个前提就够了。

7.4 用字符类代替交替分支

交替分支 (a|b|c) 除了可能重叠之外,还有一个隐藏成本:引擎要按顺序逐个尝试,而且每个分支失败后都要回到原点。如果能用字符类表达,一定优先用字符类。

慢的写法 快的写法 原因
(a|e|i|o|u) [aeiou] 字符类是一次查表,分支是四次尝试
(0|1|2|3|4|5|6|7|8|9) [0-9] 或 \d 同上
(\s|\t|\n) \s 内置类已经涵盖
(cat|category) categor(y|ies) 提取公共前缀,减少分支长度

7.5 什么时候该放弃正则

有些问题用正则解决,成本远高于收益。以下是明确的“不该用正则”的信号:

不该用正则
  • 解析 HTML / XML / JSON 等嵌套结构
  • 需要跨行、跨块、带嵌套层级的匹配
  • 规则数量超过 10 条且彼此有优先级
  • 需要给出精确的“哪一位错了”的错误提示
  • 输入来自不可信的外部,且模式里有嵌套量词
该用正则
  • 格式固定的字符串:日期、时间、编号、版本号
  • 查找替换:批量改名、格式转换、清理空白
  • 词法切分:tokenizer、语法高亮
  • 输入过滤:去掉非法字符、提取数字
  • 轻量格式校验的第一道防线

遇到“不该用”的场景时,替代方案通常有三种:写一个小型状态机(逐字符扫描,线性复杂度、完全可控)、用现成的解析器(DOMParser、JSON.parse)、用分词 + 多次浅层正则(把一个复杂正则拆成几个简单正则,各管一段)。

八、动手试验台:实时改,实时看

前面各节都是局部演示。这一节给你一个完整的、可以随意折腾的试验台:改模式、改标志、改测试文本,右侧实时高亮所有匹配,并列出每一处的详细信息。下面的速查卡可以直接把常用正则填进试验台。

/ /
匹配高亮
匹配详情

8.1 常用正则速查卡(点击填入试验台)

中国大陆手机号
/^1[3-9]\d{9}$/
强密码(大小写+数字)
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/
ISO 日期
/\d{4}-\d{2}-\d{2}/
十六进制颜色
/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
重复单词
/\b(\w+)\s+\1\b/
简单标签配对
/<([a-z][a-z0-9]*)\b[^>]*>(.*?)<\/\1>/
连续中文
/[\u4e00-\u9fa5]+/
美元金额
/(?<=\$)\d[\d,]*(\.\d+)?/
空白行
/^\s*$/
驼峰分词
/[a-z]+(?=[A-Z])|^[a-z]+/
千分位插入点
/\B(?=(\d{3})+(?!\d))/
不含尖括号
/^(?![\s\S]*[<>])/

点击任意一张卡片,它会自动填入上面的试验台并立即重新渲染。这是学习正则最有效的方式——改一个字符,看结果怎么变,再改回来。

九、调试方法论:从“瞎试”到“有章法”

写正则最大的心理障碍,是“改一个字符就全乱了”。解决方法不是更小心,而是把调试过程结构化。下面这套流程,能覆盖 90% 的正则调试场景。

9.1 五步调试法

第一步 用自然语言写下规则。比如“以字母开头,后面是字母数字下划线,长度 3 到 16”。写不出来就说明你还没想清楚,此时写正则一定失败。
第二步 把规则逐条翻译成片段。“以字母开头” → ^[a-zA-Z];“后面是字母数字下划线” → \w*;“长度 3-16” → {2,15}。先把片段写出来,别急着拼。
第三步 准备三组测试数据。必须通过的(正例)、必须不通过的(反例)、边界情况(空串、超长、特殊字符)。
第四步 分而治之。把整条正则按 | 或括号拆开,逐段测试。哪一段不匹配,问题就在哪一段。这比盯着整条正则找 bug 快十倍。
第五步 用代码而非肉眼验证。把正例反例写成断言,跑一遍。正则太容易“看着对实际不对”了。

9.2 把它写成可执行的测试

这是最被低估的一步。正则一旦写进测试,就再也不会被后续的无意修改悄悄破坏:

function describe(name, fn) {
  console.log(`\n📦 ${name}`);
  fn();
}

function it(desc, fn) {
  try {
    const ok = fn();
    console.log(ok ? ` ✅ ${desc}` : ` ❌ ${desc}`);
  } catch (e) {
    console.log(` 💥 ${desc} —— ${e.message}`);
  }
}

const USERNAME = /^[a-zA-Z]\w{2,15}$/;

describe('用户名校验', () => {
  // 正例
  it('abc 应该通过', () => USERNAME.test('abc') === true);
  it('a_1 应该通过', () => USERNAME.test('a_1') === true);
  it('16 位应该通过', () => USERNAME.test('a123456789012345') === true);

  // 反例
  it('数字开头应拒绝', () => USERNAME.test('1abc') === false);
  it('2 位应拒绝', () => USERNAME.test('ab') === false);
  it('17 位应拒绝', () => USERNAME.test('a1234567890123456') === false);
  it('含空格应拒绝', () => USERNAME.test('ab c') === false);

  // 边界
  it('空串应拒绝', () => USERNAME.test('') === false);
  it('带换行应拒绝', () => USERNAME.test('abc\n123') === false);
});

注意最后一条测试:'abc\n123' 在 没有 m 标志时应该被拒绝。如果你不小心给正则加了 m,这条测试会立即变红——这就是“边界用例”的价值。

9.3 把用户输入变成正则时的安全做法

从搜索框、路由参数、配置项里拿到的字符串,如果要拼进正则,必须先转义:

// 标准转义函数(MDN 推荐写法)
function escapeRegExp(string) {
  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

// 反面教材
const keyword = getUserInput();
new RegExp(keyword, 'g');
// 用户输入 "(" → SyntaxError,整个页面白屏
// 用户输入 "(a+)+b" → 潜在的 ReDoS

// 正确做法
const safe = new RegExp(escapeRegExp(keyword), 'g');

// 如果只是想"包含某段文本",优先用字符串方法
text.includes(keyword); // 不需要正则
text.indexOf(keyword) !== -1; // 同上

9.4 调试清单

排查清单(结果不对时)
  • 是不是忘了加 g,只匹配到第一个?
  • 是不是加了 g,lastIndex 没重置?
  • 点号 . 是不是需要转义成 \.?
  • ^ $ 是不是需要 m 才能按行匹配?
  • 量词是不是贪婪导致吃太多?试试 ? 或否定字符类
  • 字符类里 - 的位置对不对?
  • 中文字符是不是被 \w 排除了?
  • 是不是应该用 u 模式处理 emoji?
排查清单(性能不对时)
  • 有没有嵌套量词 (x+)+?
  • 有没有重叠的分支 (a|ab)?
  • 能不能用字符类代替分支?
  • 能不能用 [^x]+ 代替 .+??
  • 能不能加 ^ 锚定,减少起点的重复尝试?
  • 是不是在循环里用 new RegExp 反复编译?
  • 有没有对输入长度做上限?

9.5 工具推荐

工具 特点 适用场景
regex101.com 分步解释、回溯可视化、多引擎对比 学习和深度调试
regexper.com 把正则画成铁路图 理解复杂分支结构
Debuggex 实时轨道图 + 匹配动画 直观理解贪婪与惰性
浏览器 DevTools 控制台直接写、断点调 验证真实运行时行为
自己写的测试脚本 可回归、可 CI 长期维护的正则

在线工具适合“搞清楚它怎么工作”,但不适合当最终验证。同一个正则在 regex101 和 V8 里可能有细微差异(尤其是 Unicode 相关),最终一定要在目标运行环境里跑一遍。

十、速查表与落地清单

最后把全文浓缩成一份可以贴在工位上的速查表。左边是“你遇到什么问题”,右边是“应该用哪个语法或技巧”。

你想做的事 写法 注意事项
只匹配一次 /pattern/ 不加 g,避免 lastIndex 污染
找出所有匹配 /pattern/g + matchAll 比 while(exec) 更安全
从某个位置开始精确匹配 /pattern/y 适合手写 tokenizer
匹配到某个字符为止 [^x]+ 优先于 .+?
匹配一对括号之间 \([^)]*\) 不能处理嵌套
只分组不捕获 (?:...) 减少编号混乱
提取结构化字段 (?<name>...) 用 m.groups.name 访问
匹配重复内容 \1 / \k<name> 反向引用不支持 DFA 引擎
多个条件同时满足 多个 (?=...) 叠加 注意每个断言都会扫一遍
取某个标记之后的内容 (?<=@)\w+ 后行断言在旧 Safari 上不可用
按行处理 m 标志 + ^ $ 不要忘了 g
匹配包含换行的任意字符 s 标志 或 [\s\S] [\s\S] 兼容性更好
处理 emoji / 生僻字 u 标志 + \p{...} 没有 u 会按码元切碎
处理中文 [\u4e00-\u9fa5] 或 \p{Script=Han} 后者需要 u 标志
防止灾难性回溯 去掉嵌套量词、用字符类、加锚点 再加一道输入长度上限
拼接用户输入 escapeRegExp() 或者干脆别用正则

落地清单

  • 先用自然语言写规则,再翻译成正则。写不出自然语言,说明需求本身没想清楚。
  • 默认不加 g。只有真的需要遍历所有匹配时才加,并且用 matchAll。
  • 能用字符类就别用分支。[aeiou] 永远比 (a|e|i|o|u) 快。
  • 能用否定字符类就别用惰性量词。[^>]+ 比 .+? 更准更快。
  • 需要限定量词范围又不需要捕获时,用 (?:)。少一个分组,少一份心智负担。
  • 需要取字段时用具名组。三个月后你会感谢自己没写 m[3]。
  • 拒绝嵌套量词。看到 (x+)+ 就改写成 x+ 或 [^y]+。
  • 给正则加锚点。^ 和 $ 不只是“校验完整串”,也是性能优化。
  • 处理用户输入前先转义。或者直接改用字符串方法。
  • 正则要有测试。正例、反例、边界,三组缺一不可。
  • 别用正则解析嵌套结构。HTML、JSON、CSS 都有现成的解析器。
  • 性能问题优先怀疑正则。尤其是那些“平时很快,偶尔卡死”的页面。
// 一份可以常备的"正则工具箱"骨架

// 1. 转义用户输入
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

// 2. 安全测试:限制长度 + 缓存正则
const cache = new Map();
function safeTest(pattern, flags, input, maxLen = 10000) {
  if (input.length > maxLen) return false;
  const key = pattern + '\u0000' + flags;
  let re = cache.get(key);
  if (!re) {
    re = new RegExp(pattern, flags);
    cache.set(key, re);
  }
  re.lastIndex = 0; // 每次重置,避免 g 标志污染
  return re.test(input);
}

// 3. 取出所有匹配及其分组
function findAll(pattern, flags, text) {
  const f = flags.includes('g') ? flags : flags + 'g';
  return [...text.matchAll(new RegExp(pattern, f))].map((m) => ({
    value: m[0],
    index: m.index,
    groups: m.groups ?? {},
    captures: m.slice(1),
  }));
}

// 4. 格式化数字
const thousands = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');

// 5. 去除 HTML 标签(仅用于纯文本场景)
const stripTags = (s) => s.replace(/<[^>]*>/g, '');

// 6. 只保留中英文和数字
const keepText = (s) => s.replace(/[^\p{Script=Han}\p{Letter}\p{Number}]/gu, '');

// 7. 驼峰与连字符互转
const toKebab = (s) => s.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
const toCamel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());

// 8. 简单的中文姓名脱敏
const maskName = (s) => s.replace(/^(.?)(.+)$/, (_, a, b) => a + '*'.repeat(b.length));

正则表达式最反直觉的一点是:它的难点不在“写”,在“读”。你写得出一条能跑的正则,不代表你知道它在什么输入下会慢、会错、会崩。真正的进阶,是从“试出来的正则”变成“推导出来的正则”——看到一条模式,能在脑子里跑一遍引擎的执行过程,知道它从哪里开始、在哪里回溯、最坏情况下要试多少次。

就像正则圈里那句流传很广的话:有些人面对一个问题,心想“我用正则解决它”,于是现在他们有了两个问题。把这句话当座右铭,你会在该用正则的时候用得更好,在该放手的时候放得更果断。