正则表达式大概是程序员工具箱里最“两极分化”的一件东西:有人把它当成万能钥匙,一行 /.*/ 走天下;有人一看到反斜杠和括号就头皮发麻,宁可写三十行 indexOf 也不碰它。真相介于两者之间——正则不是魔法,它是一门只有几十个符号的微型语言,而它的难点从来不在语法,在于“你写的符号,引擎是怎么理解的”。很多人卡在正则,不是因为记不住 \d 和 \w,而是因为没有建立起“回溯”“贪婪”“断言不消费字符”这几条底层直觉。本篇 40 分钟长文,从引擎的执行模型讲起,把字符类、量词、分组、断言、标志、回溯逐层拆开,配合十来个可以直接上手操作的试验台——你可以实时改模式、改标志、改测试文本,亲眼看着匹配结果变红变绿。后半程会进入工程化部分:灾难性回溯的成因与规避、常见业务正则的逐字拆解、以及一套可落地的调试方法论。读完之后,你未必能背下所有语法,但你一定能看着一条正则,说出它会在什么输入上爆炸。
一、先搞懂引擎:正则到底是怎么跑的
绝大多数人学正则的顺序是“先背语法,再试着写”。这个顺序是反的。正确的顺序是:先知道引擎拿到模式和字符串之后做了什么,再去理解每个符号的意义。因为所有“为什么这个正则不匹配”“为什么这个正则慢得要死”的疑问,答案都在引擎的执行模型里。
1.1 两种引擎:DFA 与 NFA
正则引擎大致分两派:
把整个正则编译成一张状态转移图,然后拿着字符串一个字符一个字符地“走图”。
特点:每个字符只处理一次,速度稳定,永远不会回溯,也永远不会因为正则写法而爆炸。
代价:不支持反向引用、不支持捕获组回填、不支持复杂的断言。
代表:RE2、Go 的 regexp、Rust 的 regex crate。
引擎从字符串的某个位置出发,沿着正则的写法一路“尝试”,走不通就退回来换个分支再试。
特点:功能完整,支持反向引用、断言、具名组。
代价:一旦写法不当,尝试次数会指数级增长——这就是灾难性回溯。
代表:JavaScript(V8 的 Irregexp)、PCRE、Python、Java、.NET。
JavaScript 用的是后者。这意味着你在 JS 里写的每一条正则,都有可能因为一个不巧的输入而从 0.1 毫秒变成 10 秒。这不是危言耸听,第七节会有可复现的实测。
1.2 一次匹配的完整流程
当你写下 /ab+c/.test('xxabbc'),引擎大致做了这几件事:
点击上面的步骤卡片可以展开细节。第 2 步和第 4 步是理解正则性能的两把钥匙。
第 2 步意味着:如果你的正则没有用 ^ 锚定,引擎会从字符串的每一个位置都试一遍。一个 10000 字符的字符串,最坏情况下就是一万次尝试。
第 4 步意味着:量词 *、+、?、{n,m} 在引擎内部都是“先尽可能多地吃掉字符,然后根据后面的模式决定要不要吐回来”。这个“吐回来”的动作就是回溯。
1.3 一个最小的回溯演示
模式 /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,}
{1,}
{0,1}
/(\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 目前不支持 |
切换下面的按钮,观察同一段文本、同一条“意图”,三种写法给出的不同结果:
结论非常清楚:
<.+>贪婪,一路吃到最后一个>,所以把整行都吞了。<.+?>惰性,吃到第一个>就停,得到两个短标签。<[^>]+>用字符类排除>,同样得到两个短标签,而且不需要回溯。
[^x]+ 通常比 .+? 更快也更准。因为惰性量词每一步都要尝试“后面的模式能不能匹配”,而否定字符类只是单纯地排除。
<.+?> 遇到 <a href="x>y"> 时会在 x 后面的 > 提前截断,得到错误结果。正则处理结构化文本的局限就在这里。
3.2 为什么 JavaScript 没有占有量词
占有量词(也叫原子组)在 PCRE、Java、.NET 里都有,它的作用是“吃掉之后绝不吐回”,从根源上杜绝回溯。JS 至今没有实现,因为它会破坏一些依赖回溯的特性(比如反向引用)。
不过有一个常用的模拟技巧——用先行断言 + 反向引用制造原子组:
// 技巧:用先行断言一口气吃掉,再用反向引用"确认"
/(?=(a+))\1b/
// 拆解:
// (?=(a+)) 先行断言:从当前位置往后看,捕获全部 a 到分组 1
// 注意,断言本身不消费字符
// \1 反向引用:把分组 1 捕获到的内容原地消费掉
// b 字面量 b
// 效果:a+ 的部分变成"一次性消费",不会因为后面匹配失败而逐个吐回,
// 从而把灾难性回溯降级为线性。但这个技巧可读性差,
// 能用否定字符类解决时优先用否定字符类。
3.3 量词选择决策表
| 你想做的事 | 优先选择 | 次选 | 避免 |
|---|---|---|---|
| 匹配到某个字符为止 | [^x]+ |
.+? |
.+ |
| 匹配一对括号之间的内容 | \([^)]*\) |
\(.*?\) |
\(.*\) |
| 匹配多行中的一行 | ^.+$ + m |
[^\n]+ |
.*(不带 s) |
| 匹配可选的协议前缀 | (?:https?:)? |
(https?)? |
.*? |
四、分组与捕获:把匹配结果切开
分组不只是“给量词限定范围”,它更重要的价值是把一次匹配拆成结构化的数据。这是正则从“判断真假”升级为“提取信息”的关键一步。
4.1 三种分组的写法与代价
match() 结果里,可以用 $1 引用。有内存开销
groups.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,把协议、主机、端口、路径、查询串、锚点分别提取出来。试着改一改输入:
// 逐段读:
// ^([a-z][a-z0-9+.-]*):\/\/ 协议:字母开头,后跟字母数字和 +.-
// ([^/:?#]+) 主机:不含 / : ? # 的任意字符
// (?::(\d+))? 端口:可选,冒号加数字
// (\/[^?#]*)? 路径:可选,从 / 开始,不含 ? #
// (?:\?([^#]*))? 查询串:可选,? 开头,到 # 为止
// (?:#(.*))? 锚点:可选,到结尾为止
// $ 整串结束
// 注意:这里大量使用了 [^x] 而不是 .*?,
// 因为"排除特定字符"既准确又不需要回溯。
4.3 replace 里的特殊变量
String.prototype.replace 的第二个参数是一个被严重低估的工具。它支持一组以 $ 开头的替换变量:
| 变量 | 含义 |
|---|---|
$$ | 一个字面量的 $ |
$& | 整个匹配到的内容 |
$` | 匹配之前的文本 |
$' | 匹配之后的文本 |
$1 ~ $99 | 第 n 个捕获组 |
$<name> | 具名捕获组 |
下面这个演示会把日期格式从 YYYY-MM-DD 转换成 DD/MM/YYYY。改一改输入试试:
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}`;
});
还有一个常见需求是千分位分隔,用正则实现非常简洁:
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。在下面输入密码试试:
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'.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 六个标志速览
| 标志 | 名称 | 作用 |
|---|---|---|
g | global | 找出所有匹配,而不是只找第一个 |
i | ignoreCase | 忽略大小写 |
m | multiline | ^ 和 $ 匹配每一行的开头结尾 |
s | dotAll | . 也匹配换行符 |
u | unicode | 按码点处理,支持 \p{...} 和 \u{...} |
y | sticky | 从 lastIndex 处精确匹配,不向前搜索 |
6.2 亲手体验 g / i / m 的差别
下面的试验台使用固定的测试文本,你可以随意增删标志,观察匹配结果的变化:
A1a2A
bbb
aXXa
建议按这个顺序试一遍,体会会非常直观:
- 不加任何标志 → 什么都不匹配。因为
^和$锚定的是整串的开头和结尾,而整串里有换行。 - 加上
m→ 匹配到aaa和aXXa两行。 - 再加上
i→A1a2A这一行也被匹配到了。 - 再加上
g→ 结果本身不变(因为模式已经能找到多处),但match()的返回形态会改变。
6.3 sticky 标志:被遗忘的 y
y 标志和 g 长得很像,但行为完全不同:g 会从 lastIndex 开始向后搜索,y 则要求必须正好在 lastIndex 处匹配,不匹配就立即返回 null。
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 之后,引擎按码点处理:
/^.$/.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 正则最著名的坑,没有之一:
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 什么导致了指数级回溯
答案是:嵌套量词 + 无法匹配的结尾。看这个经典模式:
// 匹配 "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”),但性能差距是几千倍。这就是为什么“正则能跑就行”是一种危险的开发习惯。
7.3 四种防回溯的写法
方法一:用否定字符类消除嵌套
/(\w+\s?)*/
// 安全:用否定字符类明确"不要什么"
/[\w\s]*/
// 或者更精确地描述结构
/\w+(?:\s+\w+)*/
方法二:把重叠的分支改成互斥的
/(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 常用正则速查卡(点击填入试验台)
点击任意一张卡片,它会自动填入上面的试验台并立即重新渲染。这是学习正则最有效的方式——改一个字符,看结果怎么变,再改回来。
九、调试方法论:从“瞎试”到“有章法”
写正则最大的心理障碍,是“改一个字符就全乱了”。解决方法不是更小心,而是把调试过程结构化。下面这套流程,能覆盖 90% 的正则调试场景。
9.1 五步调试法
^[a-zA-Z];“后面是字母数字下划线” → \w*;“长度 3-16” → {2,15}。先把片段写出来,别急着拼。
| 或括号拆开,逐段测试。哪一段不匹配,问题就在哪一段。这比盯着整条正则找 bug 快十倍。
9.2 把它写成可执行的测试
这是最被低估的一步。正则一旦写进测试,就再也不会被后续的无意修改悄悄破坏:
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 把用户输入变成正则时的安全做法
从搜索框、路由参数、配置项里拿到的字符串,如果要拼进正则,必须先转义:
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));
正则表达式最反直觉的一点是:它的难点不在“写”,在“读”。你写得出一条能跑的正则,不代表你知道它在什么输入下会慢、会错、会崩。真正的进阶,是从“试出来的正则”变成“推导出来的正则”——看到一条模式,能在脑子里跑一遍引擎的执行过程,知道它从哪里开始、在哪里回溯、最坏情况下要试多少次。
就像正则圈里那句流传很广的话:有些人面对一个问题,心想“我用正则解决它”,于是现在他们有了两个问题。把这句话当座右铭,你会在该用正则的时候用得更好,在该放手的时候放得更果断。