正则表达式入门与实战:从语法基础到捕获组、标志位与 ReDoS 防护

正则表达式是什么

正则表达式(Regular Expression,简称 regex 或 regexp)是一种描述字符串模式的微型语言。它用少量元字符与量词组合出极其灵活的匹配规则,被几乎所有编程语言、文本编辑器、命令行工具支持,是开发者处理字符串的瑞士军刀。

典型应用场景:

  • 数据校验:判断邮箱、手机号、URL、IP 是否符合格式
  • 信息提取:从日志中抽取时间戳、IP、错误码;从 HTML 中抽取链接
  • 批量替换:把 camelCase 转为 snake_case,把模板占位符替换为真实值
  • 语法高亮:编辑器用正则识别关键字、字符串、注释等 token

JavaScript 内置 RegExp 类型与正则字面量语法 /pattern/flags,无需引入任何库即可使用。本文配套的在线正则测试工具可让你边读边试。

基础语法:字符类与量词

正则中的字符分为两类:字面字符(匹配自身)与元字符(有特殊含义)。需要匹配元字符本身时,用反斜杠 \ 转义。

元字符列表

元字符含义示例
.匹配除换行符外的任意字符a.c 匹配 abc axc a c
*前一项重复 0 次或多次ab* 匹配 a ab abbb
+前一项重复 1 次或多次ab+ 匹配 ab abbb,不匹配 a
?前一项出现 0 次或 1 次colou?r 同时匹配 color colour
{n} {n,} {n,m}精确重复 n 次 / 至少 n 次 / n 到 m 次\d{4} 匹配 4 位数字
^ $字符串首尾锚点(多行模式下匹配行首尾)^hello world$
\b \B单词边界 / 非单词边界\bword\b 不匹配 keyword
``
[]字符集(匹配其中任意一个)[aeiou] 匹配任意元音
[^]否定字符集[^0-9] 匹配非数字
()分组与捕获(ab)+ 匹配 ab abab

预定义字符类

写法等价于含义
\d[0-9]数字
\D[^0-9]非数字
\w[A-Za-z0-9_]单词字符(字母数字下划线)
\W[^A-Za-z0-9_]非单词字符
\s[ \t\n\r\f\v]空白字符
\S[^ \t\n\r\f\v]非空白字符

在线正则测试工具 中输入 \d+ 勾选 g 标志,粘贴一段含数字的文本,所有数字片段会被立即高亮。

标志位:g / i / m / s / u / y 的真实含义

JavaScript 正则的标志位出现在字面量末尾或 new RegExp(pattern, flags) 第二参数。理解每个标志位的真实行为是写出正确正则的前提。

标志名称行为
gglobal全局匹配,找出全部匹配项;不带 g 时只匹配第一个
iignoreCase忽略大小写,[a-z] 也能匹配 A-Z
mmultiline多行模式,^$ 匹配每行首尾而非整个字符串首尾
sdotAll. 能匹配换行符 \n(默认不能)
uunicodeUnicode 模式,正确处理中文、Emoji 等多字节字符
ysticky粘滞匹配,从 lastIndex 开始且必须紧贴该位置

实战对比

// 不带 g:只匹配第一个
'hello world hello'.match(/hello/)  // ['hello']

// 带 g:匹配全部
'hello world hello'.match(/hello/g) // ['hello', 'hello']

// 不带 i:大小写敏感
'Hello'.match(/hello/)   // null

// 带 i:忽略大小写
'Hello'.match(/hello/i)  // ['Hello']

// 不带 m:^ $ 匹配整个字符串首尾
'a\nb'.match(/^b/)        // null

// 带 m:^ $ 匹配每行首尾
'a\nb'.match(/^b/m)       // ['b']

// 不带 s:. 不匹配换行
'a\nb'.match(/a.b/)       // null

// 带 s:. 匹配换行
'a\nb'.match(/a.b/s)      // ['a\nb']

// 不带 u:[\u4e00-\u9fa5] 仍能匹配中文,但 . 把 Emoji 当作两个码元
'🎉'.match(/./)           // ['\uD83C'](半截 surrogate pair)

// 带 u:. 正确匹配整个 Emoji
'🎉'.match(/./u)          // ['🎉']

在线正则测试工具 中可逐个勾选标志位观察匹配变化,无需手写代码。

捕获组与非捕获组

用圆括号 () 包裹的子模式形成捕获组,按左括号出现顺序编号为 $1$2$3……在替换字符串与回调中可引用。

// 捕获邮箱的用户名、域名、顶级域
const re = /(\w+)@(\w+)\.(\w+)/;
const m = 'dev@example.com'.match(re);
// m[0] = 'dev@example.com'(整体匹配)
// m[1] = 'dev'($1 用户名)
// m[2] = 'example'($2 域名)
// m[3] = 'com'($3 顶级域)

// 在替换中用 $1 $2 $3 引用
'dev@example.com'.replace(re, '[$1 at $2.$3]')
// '[dev at example.com]'

非捕获组 (?:...) 仅分组不捕获,不消耗 $N 编号,性能略好:

// 用 (?:...) 仅分组,不产生 $1
'2026-07-03'.match(/(?:\d{4})-(?:\d{2})-(?:\d{2})/)
// ['2026-07-03'],无捕获组

命名捕获组 (?<name>...) 是 ES2018 引入的语法,给捕获组起名字,可读性远胜编号:

const re = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const m = '2026-07-03'.match(re);
// m.groups.year  = '2026'
// m.groups.month = '07'
// m.groups.day   = '03'

// 替换中用 ${name} 引用
'2026-07-03'.replace(re, '$<day>/$<month>/$<year>')
// '03/07/2026'

替换:String.replace 与 $ 引用

String.prototype.replace 接受正则与替换字符串,替换字符串支持以下特殊写法:

写法含义
$1 $2$9第 N 个捕获组
$<name>命名捕获组
$$字面 $
$&整体匹配文本
$`匹配文本左侧的内容
$'匹配文本右侧的内容
// 把 kebab-case 转 camelCase
'foo-bar-baz'.replace(/-(\w)/g, (_, c) => c.toUpperCase())
// 'fooBarBaz'

// 把每个单词首字母大写
'hello world foo'.replace(/\b(\w)/g, (_, c) => c.toUpperCase())
// 'Hello World Foo'

// 用 $& 在数字前后加方括号
'a1b2c3'.replace(/\d/g, '[$&]')
// 'a[1]b[2]c[3]'

在线正则测试工具 切换到「替换」Tab,输入模式 (\w+)@(\w+)\.(\w+) 与替换 [$1 at $2.$3],可即时看到替换结果。

零宽断言:往前看与往后看

零宽断言(lookaround)匹配位置而非字符,类似 \b 但更灵活。

写法名称含义
(?=...)正向先行断言右侧必须匹配 ...
(?!...)负向先行断言右侧必须不匹配 ...
(?<=...)正向后行断言(ES2018)左侧必须匹配 ...
(?<!...)负向后行断言(ES2018)左侧必须不匹配 ...
// 匹配数字后面的单位(数字本身不消费)
'30px 20em 10rem'.match(/\d+(?=px|em|rem)/g)
// ['30', '20', '10']

// 匹配不在 $ 后的数字
'price: $100, count: 50'.match(/(?<!\$)\d+/g)
// ['50']($100 被排除)

// 匹配 Markdown 链接文字
'[Google](https://google.com)'.match(/(?<=\[)[^\]]+(?=\])/)
// ['Google']

常用正则速查

以下模式收录在 在线正则测试工具 的「常用」速查栏,可一键载入:

// 邮箱(简化版,RFC 5322 完整版极长不实用)
/[\w.+-]+@[\w-]+\.[\w.-]+/g

// URL(http/https)
/https?:\/\/[\w.-]+(?:\/[\w./-]*)?/g

// IPv4
/\b(?:\d{1,3}\.){3}\d{1,3}\b/g

// 中国大陆手机号
/1[3-9]\d{9}/g

// 中文字符
/[\u4e00-\u9fa5]+/g

// HTML 标签
/<[^>]+>/g

// 整数(含负数)
/-?\d+/g

// 浮点数
/-?\d+\.\d+/g

上述模式适合 90% 的日常场景。生产环境校验邮箱、URL 应使用专门的库(如 validator.js),简化版可能漏判边界情况。

ReDoS:正则的暗坑

正则表达式可能遭受 ReDoS(Regular Expression Denial of Service) 攻击:恶意构造的输入让正则引擎指数级回溯,导致浏览器/服务器卡死。

危险模式

// 危险:嵌套量词 + 重叠交替
const evil = /^(a+)+$/;
evil.test('aaaaaaaaaaaaaaaaaaaaaaaaaaa!');  // 卡顿数十秒

危险信号:

  1. 嵌套量词(a+)+ (a*)* (a|b)*
  2. 重叠交替(a|a)* (\d|\w)+
  3. 大重复次数a{1,1000} 与后续必须失败的项

防护策略

  1. 避免嵌套量词,用具体字符类替代 .,如 [a-z] [\w-]
  2. 优先使用非捕获组 (?:...) 减少状态保存
  3. 使用原子组或占有量词(JavaScript 暂不支持,可用 re2 库替代)
  4. 对超时敏感场景设置超时,如 Node.js 的 re2 模块基于 Google RE2 引擎,线性时间保证不回溯
  5. 本地工具设输入长度上限,本工具对输入长度(10000 字)与匹配数(5000 个)设上限保护浏览器

安全的正则示例

// 危险:^(a+)+$
// 安全:直接用 ^a+$ 等价且不回溯
/^a+$/.test('aaaaaaaa!');  // 立即返回

// 危险:邮箱 ^(.*@.+)$
// 安全:用具体字符类
/^[\w.+-]+@[\w-]+\.[\w.-]+$/.test('dev@example.com');

实战技巧

1. 调试正则时用 source 属性查看模式

const re = /\d{4}-\d{2}-\d{2}/;
re.source;  // '\\d{4}-\\d{2}-\\d{2}'
re.flags;   // ''

2. 用 exec 循环遍历所有匹配(含捕获组)

const re = /(\w+)=(\d+)/g;
const text = 'a=1, b=2, c=3';
let m;
while ((m = re.exec(text)) !== null) {
  console.log(`键: ${m[1]}, 值: ${m[2]}, 位置: ${m.index}`);
}
// 键: a, 值: 1, 位置: 0
// 键: b, 值: 2, 位置: 5
// 键: c, 值: 3, 位置: 10

3. 注意 lastIndex 陷阱

g 标志的 RegExp 对象有状态,重复使用 test() 会在匹配间游走:

const re = /\d/g;
re.test('1');  // true,  lastIndex = 1
re.test('1');  // false, lastIndex = 0(找不到下一个)
re.test('1');  // true,  lastIndex = 1

// 解决:用前 reset,或不用 g 标志
re.lastIndex = 0;

4. 用 String.matchAll 一次性拿到所有匹配(ES2020)

const text = 'a=1, b=2, c=3';
const matches = [...text.matchAll(/(\w+)=(\d+)/g)];
// matches[0].index = 0
// matches[0][1] = 'a'
// matches[0][2] = '1'

速记小结

  • g 全局 / i 忽略大小写 / m 多行 / s dotAll / u Unicode / y 粘滞
  • (...) 捕获组编号为 $1 $2 ...(?:...) 非捕获组不编号
  • 命名捕获组 (?<name>...) 在替换中用 $<name> 引用
  • 替换字符串中 $$ 转义为字面 $
  • ReDoS 危险信号:嵌套量词 (a+)+、重叠交替 (a|a)*
  • 生产环境校验邮箱/URL 用专门库,简化正则可能漏判边界

打开在线正则测试工具亲手试一试本文的每个示例,正则语法在「敲一遍」之后才会真正掌握。