JavaScript 正则表达式完全指南
本文是对 2018 年那篇 《正则表达式使用手册》 的重写,结合 2026 年的现状做了全面更新。
这些年 JavaScript 正则表达式发生了不小的变化:ES2018 带来了命名捕获组、后行断言、Unicode 属性转义和 s 标志;ES2022 增加了 d 标志;ES2024 增加了 v 标志;ES2025 又补上了 RegExp.escape()、内联修饰符和重复命名捕获组。
本文基于 ECMAScript 2025 规范与当前主流运行时(Node.js 24 LTS、Chrome/Edge、Safari、Firefox 的最新版本)编写,目标是:准确、完整、可用于 2026 年的工程实践。
语言范围
正则表达式的方言差异极大。本文只讨论 JavaScript(ECMAScript) 的 RegExp,不要想当然地套用到 Python、PCRE、Go 或 Rust —— 它们的转义规则、锚点语义、Unicode 行为都不完全相同。文中会标注若干易混淆的差异点。
一、正则表达式是什么
正则表达式(Regular Expression)是一种描述文本模式的形式化语言,用来在字符串中查找、校验、提取或替换符合规则的子串。
一个最小例子:
const text = `name:Mark tel:13800138000
name:Jhon tel:13800138888`
const result = text.match(/tel:(1\d{10})/)
// ["tel:13800138000", "13800138000", index: 0, ...]
console.log(result[1]) // 13800138000/tel:(1\d{10})/ 就是正则表达式,其中 () 表示「捕获」匹配到的内容,\d 表示数字,{10} 表示重复 10 次。
但需要先明确一点:JavaScript 的 RegExp 是回溯(backtracking)引擎。它功能强、语法丰富(支持反向引用、任意断言),代价是最坏情况下可能产生指数级的时间复杂度(见第十节)。
二、创建正则的两种方式
2.1 字面量与构造函数
// 1. 字面量
const re1 = /\d+/g
// 2. 构造函数
const re2 = new RegExp('\\d+', 'g')二者生成的正则对象语义完全一致,re1.source === re2.source、re1.flags === re2.flags。区别在于:
| 维度 | 字面量 /\d+/g | 构造函数 new RegExp() |
|---|---|---|
| 适用场景 | 模式静态、写在代码里 | 模式需要动态拼接 |
| 转义成本 | 低,所见即所得 | 高,反斜杠要写两遍 |
| 语法错误时机 | 解析期报错,构建时就能发现 | 运行期抛 SyntaxError |
// 构造函数需要双重转义,容易写错
const re = new RegExp('\\d{4}-\\d{2}')
// 推荐写法:用 String.raw 避免双重转义
const re2 = new RegExp(String.raw`\d{4}-\d{2}`)2.2 一个常见的误解
一个流传很广的说法是「正则字面量在编译期就完成了,性能更好」。这需要澄清:
- 从
ES5开始,正则字面量每次求值都会创建一个新的RegExp对象,并不存在「只编译一次」的对象复用:
function make() {
return /a/
}
console.log(make() === make()) // false,两个不同的对象- 引擎确实会缓存编译后的模式,所以重复使用字面量的匹配开销很低;但如果你需要跨调用累积
lastIndex(典型场景是exec循环遍历),就必须复用同一个对象,应该把它提取成模块级常量:
// 推荐:模块顶层只创建一次
const RE_DATE = /\d{4}-\d{2}-\d{2}/g
RE_DATE.exec('2026-09-10 与 2025-01-01')[0] // '2026-09-10'
RE_DATE.exec('2026-09-10 与 2025-01-01')[0] // '2025-01-01',靠 lastIndex 递增2.3 动态构造用户输入:RegExp.escape()
如果你把用户输入拼进正则,必须转义,否则用户输入里的 .、*、( 会改变模式语义,甚至直接抛 SyntaxError:
// ❌ 危险:输入 "1+1" 会变成非法正则
const keyword = searchInput // 来自用户
new RegExp(keyword)
// ✅ ES2025:安全地把任意字符串转成字面量模式
new RegExp(RegExp.escape(keyword), 'g')RegExp.escape() 已进入 ES2025,是 Baseline 2025 特性。 它比手写的 replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 更可靠:手写版本必须同时兼容 u 与 v 两套转义规则, 很容易漏 —— 例如 RegExp.escape('a-b') 返回的是 '\\x61\\x2db',连 a 都被转成了 \x61, 正是为了避免 - 与相邻字符被解析成范围。不要自己造轮子。
三、元字符速查表
正则表达式由元字符与普通字符组成。元字符需要按特殊含义解释,普通字符按字面匹配。
| 字符 | 含义 |
|---|---|
\ | 转义符。\d 表示「数字」而非字母 d;\. 表示「普通的点字符」。 |
^ | 匹配输入的开头(开启 m 时为每行开头)。 |
$ | 匹配输入的结尾(开启 m 时为每行结尾)。⚠️ 与 Python/Perl 不同,JS 不加 m 时 不会在末尾换行符之前匹配。 |
* | 前一个原子重复 0 次或多次。 |
+ | 前一个原子重复 1 次或多次。 |
? | 前一个原子重复 0 次或 1 次;跟在量词之后时表示「懒惰」(见第五节)。 |
. | 匹配除行终止符外的任意字符。行终止符不止 \n:还包括 \r、\u2028(行分隔符)、\u2029(段分隔符)。加 s 后可匹配所有字符。 |
x|y | 分支:匹配 x 或 y。 |
[xyz] | 字符类,匹配集合中任意一个字符。可用 - 指定范围。 |
[^xyz] | 反向字符类,匹配不在集合中的任意一个字符。 |
{n} | 前一个原子恰好重复 n 次。 |
{m,n} | 前一个原子重复 m 到 n 次(尽量多)。{m,} 表示至少 m 次。⚠️ JS 不支持 {,n} 简写(见 5.4)。 |
(x) | 捕获分组,匹配并保存 x,同时分配一个编号(从 1 开始)。 |
(?:x) | 非捕获分组,只分组不保存。 |
(?<n>x) | 命名捕获分组,保存为 groups.n。 |
x(?=y) | 正向前瞻:x 后面必须是 y,但 y 不消耗。 |
x(?!y) | 负向前瞻:x 后面不能是 y。 |
(?<=y)x | 正向后行断言:x 前面必须是 y。 |
(?<!y)x | 负向后行断言:x 前面不能是 y。 |
\b | 单词边界(\w 与非 \w 之间的位置)。 |
\B | 非单词边界。 |
[\b] | 退格字符 U+0008。注意:在字符类内 \b 是退格,不是边界。 |
\d | ASCII 数字,等价 [0-9](即使开启 u 也仍是 ASCII)。 |
\D | 非 ASCII 数字,等价 [^0-9]。 |
\w | ASCII 单字字符,等价 [A-Za-z0-9_](即使开启 u 也仍是 ASCII)。 |
\W | 非 ASCII 单字字符。 |
\s | 空白字符,等价 [\f\n\r\t\v\u0020\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]。 |
\S | 非空白字符。 |
\n | 换行 U+000A。 |
\r | 回车 U+000D。 |
\t | 水平制表符 U+0009。 |
\v | 垂直制表符 U+000B。 |
\f | 换页符 U+000C。 |
\0 | NUL 字符(后面不能紧跟数字,否则会被当成八进制/反向引用)。 |
\xhh | 匹配编码为两位十六进制的字符。 |
\uhhhh | 匹配编码为四位十六进制的字符。 |
\u{h…h} | 匹配码点(需要 u 或 v 标志),可表示大于 U+FFFF 的字符。 |
\cX | 控制字符,例如 \cJ 等价于 \n。 |
\1 \2 | 反向引用,匹配第 n 个捕获组此前捕获到的内容。 |
\k<n> | 反向引用,匹配命名捕获组 n 的内容。 |
\p{...} | Unicode 属性转义(需要 u 或 v 标志),匹配属于该属性的字符。 |
\P{...} | Unicode 属性转义的反集。 |
3.1 字符类内的转义规则
在 [...] 内部,元字符的「特殊身份」会发生变化:
| 字符 | 在 [] 内是否需要转义 | 说明 |
|---|---|---|
- | 需要(作为普通字符时) | 否则会被当成范围,如 [a-z] |
] \ ^ | 需要 | ^ 仅在第一个位置特殊,其余位置是普通字符 |
. * + ? | 不需要 | 在 [] 内失去特殊含义,写不写 \ 都行 |
v 模式下更严格
上表适用于非 v 模式。开启 v 标志后,字符类内的保留字符更多:( ) [ ] { } / - \ | 都必须转义,&&、~~ 这类「双标点」也被保留给集合运算。所以 /[()]/u 合法,而 /[()]/v 会直接抛 SyntaxError。
// 匹配连字符、右括号和反斜杠本身
const re = /[\]\\-]/3.2 关于 \s 的一个过时细节
很多资料会把 \u180e(蒙古文元音分隔符)算进 \s。这是过时的:自 Unicode 6.3 起 U+180E 已被重新归类为非空白字符,规范中的 \s 集合也已将它移除。可以用 /\s/.test('\u180e') 验证,结果为 false。
四、Unicode:JS 正则最容易踩坑的地方
4.1 \d、\w 永远只匹配 ASCII
这是 JavaScript 与其他语言(如 Python 的 re 带 UNICODE 模式、.NET)最显著的差异之一:
/\d/.test('٣') // false —— 阿拉伯-印度数字 3
/\d/.test('3') // false —— 全角数字 3
/\d/u.test('٣') // false —— 加了 u 也一样!
/\w/.test('中') // false —— 汉字不算「单字字符」u 标志只改变解析规则与码点处理方式,不改变 \d/\w 的字符集合。要匹配 Unicode 语义下的「数字」「字母」,必须用属性转义:
/\p{Decimal_Number}/u.test('٣') // true
/\p{Letter}/u.test('中') // true
/^\p{Letter}\p{Mark}*$/u.test('e\u0301') // true —— 字母 + 组合音标4.2 u 标志
u(unicode)标志的主要作用:
- 按码点而非 UTF-16 码元解析模式,正确处理
😀这类由代理对组成的字符; - 启用
\u{...}码点转义与\p{...}属性转义; - 让正则严格化:非法转义、孤立的量词会直接抛
SyntaxError,而不是被当作字面量。这是好事,能提前暴露错误。
/^.$/.test('😀') // false,. 只吃掉一个码元
/^.$/u.test('😀') // true,u 模式下 . 匹配一个码点
/^\u{1F600}$/u.test('😀') // true4.3 v 标志(ES2024):Unicode 集合运算
v(unicodeSets)是 u 的超集,二者互斥(new RegExp('a', 'uv') 会抛 SyntaxError)。它带来三样东西:
// 1. 集合运算:交集 &&、差集 --
/[\p{Letter}--[a-z]]/v.test('中') // true,是非 ASCII 的字母
/[\p{Letter}--[a-z]]/v.test('a') // false
/[\p{Script=Greek}&&\p{Letter}]/v.test('α') // true
// 2. 嵌套字符类
/[[a-z]--[aeiou]]/v.test('b') // true
// 3. 字符类中的字符串字面量与「字符串属性」
/[\q{ab|c}]/v.test('ab') // true,\q{} 里是字符串候选
/^\p{RGI_Emoji}$/v.test('😀') // true,属性可以是「字符串集合」而非单字符在 v 模式下,字符类的转义规则比 u 更严格(见 3.1 的说明),升级时要注意兼容性。
该用 u 还是 v?
v 是 u 的超集,但不能用它来「顺便兼容」:v 模式下部分在 u 下合法的模式会报错。策略上:只用 u 即可满足需求时优先 u(兼容面更广);确实需要集合运算或字符串属性时再用 v。目标运行时较老(如需支持 2022 年前的浏览器)时,可用 Regex+、regexpu 之类的工具降级编译。
五、量词、贪婪与懒惰
5.1 量词作用于「前一个原子」
量词 * + ? {n,m} 只作用于它前面紧邻的那一个原子(一个字符、一个字符类、一个分组或一个断言):
/ab*/.exec('abbbbbc')[0] // 'abbbbb',* 只作用于 b
/(ab)*/.exec('ababab')[0] // 'ababab',* 作用于整个分组5.2 ? 的两种身份
? 有两种完全不同的含义,取决于它出现的位置:
- 作为量词:跟在原子后面,表示重复 0 或 1 次 ——
ab?可匹配a或ab; - 作为懒惰后缀:跟在另一个量词(
*、+、?、{m,n})后面,把该量词从贪婪改为懒惰 ——ab??、a*?。
5.3 贪婪 vs 懒惰
默认是贪婪的:在能满足整体匹配的前提下,尽可能多地匹配。
const greedy = /<div>.*<\/div>/
const lazy = /<div>.*?<\/div>/
const str = '<div>aaa</div>bbb<div></div>ccc'
str.match(greedy)[0] // '<div>aaa</div>bbb<div></div>'
str.match(lazy)[0] // '<div>aaa</div>'懒惰量词的语义归纳:
| 写法 | 最少匹配 | 最多匹配 |
|---|---|---|
x*? | 0 个 | 不限 |
x+? | 1 个 | 不限 |
x?? | 0 个 | 1 个 |
x{m,n}? | m 个 | n 个 |
但要注意:懒惰只影响「优先尝试多少」,不改变匹配能力。如果整体匹配要求「必须多」,懒惰量词仍会退让到多:
// 懒惰不会改变「能否匹配」,只改变「优先尝试多短」
'aaab'.match(/a+?b/)[0] // 'aaab' —— a+? 必须扩展到 3 个 a,整体才能匹配
'<div>aaa</div>'.match(/<div>.*?<\/div>/)[0] // '<div>aaa</div>' —— 这里「最短」即可成功,所以只吃到第一个 </div>5.4 注意:{,n} 不是合法量词
{,1} 常被误认为表示「0 次到 1 次」,这在 JavaScript 中是错误的。ECMAScript 只定义了三种量词形式:{n}、{n,}、{n,m},没有 {,m}。
/a{,1}/.test('a') // false
/a{,1}/.test('a{,1}') // true —— {,1} 被当成了字面量字符
// 只有在 u / v 模式下,{,1} 才会因为「不完整的量词」直接抛错
new RegExp('a{,1}', 'u') // SyntaxError: Incomplete quantifier另外,{m,n} 中若 m > n 会直接抛错:
new RegExp('a{2,1}') // SyntaxError: numbers out of order in {} quantifier5.5 原子组与占有量词在原生 JS 中不存在
(?>...)(原子组)与 a++、a*+(占有量词)能有效防止回溯爆炸,但原生 JavaScript 至今没有实现(截至 2026 年仍未进入标准)。PCRE / Java / .NET 用户迁移到 JS 时,需要改用断言或重构模式,或使用 Regex+ 库以编译期展开的方式模拟。
六、断言(零宽匹配)
断言只检验「某个位置附近的条件」,不消耗字符,因此不计入匹配结果,也不会推进匹配位置。
// 前瞻
/\d+(?=元)/.exec('价格 100元') // ['100'],只匹配数字,'元' 被断言但未消耗
/(?<=\$)\d+/.exec('价格 $100') // ['100'],后行断言
// 否定
/\d+(?!元)/.exec('价格 100块') // ['100']
/(?<!\$)\b\d+\b/.exec('价格 100块') // ['100']三个必须知道的细节:
- 断言中的捕获组仍会被捕获。这一点常被忽略:
/(?=(b))a?/.exec('ab') // ['', 'b'] —— 前瞻里的 (b) 贡献了第 1 个捕获组- 后行断言(lookbehind)自 ES2018 起已成为标准,主流运行时全面支持。
- 「固定长度」限制是历史遗留:V8 曾要求后行断言内为定长模式,该限制早已解除,现代 JS 允许变长后行断言(如
/(?<=\d+)[a-z]/)。
七、分组、捕获与反向引用
7.1 编号分组
const re = /a(bc)d(ef)/
// 第 1 组 (bc),第 2 组 (ef)在模式内部用 \1、\2 反向引用;在替换串里用 $1、$2:
// 模式内部反向引用:简化重复
/a(bc)d\1/.test('abcdbc') // true,\1 = 'bc'
// 替换串引用
'apple pear'.replace(/(\w+)\s(\w+)/, '$2 $1') // 'pear apple'分组编号按左括号出现顺序,从 1 开始,包括嵌套分组;非捕获组 (?:) 不占编号。
7.2 命名分组(推荐)
从 ES2018 起,编号之外还可以给分组命名,可读性远好于 $1:
const re = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/
const m = '2026-09-10'.match(re)
m.groups.year // '2026'
m.groups.month // '09'
// 反向引用
/(?<word>\w+)\s+\k<word>/.exec('hello hello')[0] // 'hello hello'
// 替换
'2026-09-10'.replace(re, '$<day>/$<month>/$<year>') // '10/09/2026'7.3 重复命名分组(ES2025)
同一模式的不同分支(|)中允许复用同一个组名,规范会保证同一时刻只有其中一个分支参与匹配:
// ⚠️ 分支顺序仍然重要:若把 \d+ 写在前面,'2026-09-10' 会先被匹配成 '2026'
const re = /(?<value>\d{4}-\d{2}-\d{2})|(?<value>\d+)/
re.exec('2026-09-10').groups.value // '2026-09-10'
re.exec('2026').groups.value // '2026'这解决了「解析两种格式、希望统一取 value」的长期痛点。注意限制:只有互斥的分支才能同名,(?<a>x)(?<a>y) 这种会抛 SyntaxError。
7.4 不要再用 RegExp.$1
RegExp.$1~$9 不应该再被使用:
- 它们是
Annex B的遗留特性,属于规范附录中的「兼容性包袱」,不出现在正式规范正文; - 它们是全局可变状态,任何一次正则匹配都会覆写,模块间会相互污染,调试极其困难;
- 在
g标志、matchAll、replace回调等场景下的行为不直观。
// ❌ 不要这样写
/b(c)/.test('abc')
console.log(RegExp.$1) // 'c' —— 依赖全局副作用
// ✅ 用匹配结果本身
const m = /b(c)/.exec('abc')
console.log(m[1]) // 'c'
console.log(m?.groups?.x) // 命名分组更佳八、标志(flags)全景
| 标志 | 名称 | 引入版本 | 作用 |
|---|---|---|---|
g | global | ES3 | 全局查找所有匹配(配合 lastIndex 有状态) |
i | ignoreCase | ES3 | 忽略大小写 |
m | multiline | ES3 | ^ $ 按行匹配 |
u | unicode | ES2015 | Unicode 码点模式,启用 \u{}、\p{} |
y | sticky | ES2015 | 粘性匹配,只从 lastIndex 位置开始,不向前搜索 |
s | dotAll | ES2018 | 让 . 匹配包括行终止符在内的任意字符 |
d | hasIndices | ES2022 | 额外返回 indices,含每个分组的起止下标 |
v | unicodeSets | ES2024 | u 的超集,字符类集合运算与字符串属性 |
8.1 m 的准确含义
m 改变了 ^ 和 $ 的行为:它们开始识别行终止符(\n、\r、\u2028、\u2029),在每行的开头/结尾也能匹配。
/^b/m.test('a\nb') // true
/^b/m.test('a\u2028b') // true顺带一个跨语言陷阱:JS 的 $ 在不加 m 时不会在末尾换行符之前匹配,这与 Python、Perl 不同:
/a$/.test('a\n') // false —— $ 只认整个输入的末尾
/a$/m.test('a\n') // true —— 加了 m 之后按行匹配8.2 y(sticky)与 g 的区别
两者都依赖 lastIndex,区别在于:
g:从lastIndex开始向后搜索,找到一个匹配即可;y:只在lastIndex处精确匹配,失败即失败,不向后扫描。
const re = /foo/y
re.lastIndex = 2
re.test('xxfoo') // true —— 下标 2 处正好是 'foo'
re.lastIndex = 0
re.test('xxfoo') // false —— 粘性匹配不会向后搜索
// 对比:g 标志会向后搜索
const reG = /foo/g
reG.test('xxfoo') // true更典型的用途是分词/流式解析:用 y 可以把多个小正则串成词法分析器,逐字符推进:
const tokens = [/\d+/y, /[a-z]+/y, /\s+/y]
const input = '12abc 34'
const out = []
let pos = 0
while (pos < input.length) {
const start = pos
for (const re of tokens) {
re.lastIndex = pos
const m = re.exec(input)
if (m) {
if (!/^\s+$/.test(m[0]))
out.push(m[0])
pos = re.lastIndex
break
}
}
// 没有任何词法单元匹配时,跳过该字符,避免死循环
if (pos === start)
pos++
}
console.log(out) // ['12', 'abc', '34']8.3 s(dotAll)
/a.b/.test('a\nb') // false,. 不匹配行终止符
/a.b/s.test('a\nb') // true,s 让 . 匹配一切8.4 d(hasIndices)
开启后,exec / match 的结果会带 indices 数组,给出每个捕获组在原字符串中的 [start, end) 下标,对语法高亮、编辑器定位、增量批注极其有用:
const m = '2026-09-10'.match(/(?<year>\d{4})-(?<month>\d{2})/d)
m.indices[0] // [0, 7],整体匹配
m.indices.groups.year // [0, 4]
m.indices.groups.month // [5, 7]8.5 标志组合的限制
new RegExp('a', 'gg') // SyntaxError:重复标志
new RegExp('a', 'uv') // SyntaxError:u 与 v 互斥8.6 内联修饰符(ES2025)
ES2025 起可以把标志作用到局部,而不必全局打开:
// 只让中间这一段忽略大小写
/^(?i:hello) world$/.test('HELLO world') // true
// 也可以关闭:在 i 标志已全局开启时,局部关闭 i
/^(?-i:a)$/i.test('A') // false语法为 (?flags:...) 或 (?flags-flags:...),可用标志为 i、m、s。
九、常用 API 与方法选择
多数正则的 bug 不是模式写错,而是方法选错。先看对照表:
| 需求 | 方法 | 关键点 |
|---|---|---|
| 是否存在匹配(布尔) | re.test(str) | 带 g/y 时依赖 lastIndex,有状态 |
| 取第一个匹配的完整信息 | re.exec(str) 或 str.match(re) | 带 g 时 exec 会推进 lastIndex |
| 取所有匹配的捕获组 | str.matchAll(re) | 必须带 g,否则抛 TypeError |
| 取所有匹配(不要捕获组) | str.match(re) | 带 g 时只返回完整匹配,丢失分组 |
| 替换(首个 / 全部) | replace / replaceAll | replaceAll 的正则必须带 g |
| 查找下标 | str.search(re) | 不支持 y 的粘性语义,忽略 lastIndex |
| 切分 | str.split(re) | 带捕获组时,捕获内容会进入结果数组 |
| 字符串内是否含子串 | str.includes() | 纯文本匹配不要用 test 构造正则 |
三个高频陷阱:
// 陷阱 1:match + g 会丢弃捕获组
'2026-09'.match(/(\d{4})-(\d{2})/)
// ['2026-09', '2026', '09']
'2026-09'.match(/(\d{4})-(\d{2})/g)
// ['2026-09'] —— 分组没了!需要遍历时请用 matchAll
// 陷阱 2:复用带 g 的正则,lastIndex 会「记住」上次位置
const re = /a/g
re.test('a') // true,lastIndex = 1
re.test('a') // false!从下标 1 开始只剩空串 —— 经典 bug
re.test('a') // true,lastIndex 归零后重新开始
// 修复:不要跨调用复用带状态的正则;或每次显式重置
re.lastIndex = 0
// 或者干脆用无状态的写法
/a/.test('a')// 陷阱 3:split 的捕获组会出现在结果里
'a1b2c'.split(/(\d)/) // ['a', '1', 'b', '2', 'c']
'a1b2c'.split(/\d/) // ['a', 'b', 'c'] —— 想要这个结果就不要加捕获组9.1 replace 的替换模式
replace / replaceAll 的第二个参数可以是字符串或函数。
字符串替换模式中的特殊记号:
| 记号 | 含义 |
|---|---|
$$ | 插入一个 $ |
$& | 插入整个匹配 |
$` | 插入匹配之前的子串 |
$' | 插入匹配之后的子串 |
$n | 插入第 n 个捕获组($1~$99) |
$<name> | 插入命名捕获组 |
// 函数形式:可做任意计算,且能拿到 offset(配合 d 标志还能拿到 indices)
const masked = '13800138000'.replace(
/(\d{3})\d{4}(\d{4})/,
(_, head, tail) => `${head}****${tail}`
)
// '138****8000'回调参数个数不固定
函数形式的基础签名是 (match, p1, p2, ..., offset, string)。如果模式里含有命名分组,末尾还会多出一个 groups 对象;捕获组数量不同,参数个数也不同。所以不要按位置硬编码 —— 用 rest 参数收集捕获组,或直接用命名分组。
9.2 自定义匹配行为:Symbol.* 协议
String.prototype 上的 match、matchAll、replace、replaceAll、search、split 并不是硬编码只认 RegExp。规范里它们的做法是:先读取参数上的协议方法,存在就委托调用,不存在才走内部的 RegExpCreate 兜底。
String 方法 | 读取的协议键 | 委托调用形式 |
|---|---|---|
str.match(x) | x[Symbol.match] | x[Symbol.match](str) |
str.matchAll(x) | x[Symbol.matchAll] | x[Symbol.matchAll](str) |
str.replace(x, r) | x[Symbol.replace] | x[Symbol.replace](str, r) |
str.replaceAll(x, r) | 同上 | 同上 |
str.search(x) | x[Symbol.search] | x[Symbol.search](str) |
str.split(x, l) | x[Symbol.split] | x[Symbol.split](str, l) |
str.includes / startsWith / endsWith(x) | x[Symbol.match] | 不调用,只用于 IsRegExp 判定 |
这带来两个直接结论:
- 任何对象只要实现了对应的
Symbol方法,就能被String方法接受 —— 这是实现自定义匹配器(惰性编译、流式匹配、代理上报)的标准扩展点。 RegExp自身的行为也正是由RegExp.prototype[Symbol.*]定义的,所以子类化时覆写这些方法就能改变语义,而不必去 hack 内部实现。
IsRegExp 与 Symbol.match = false
includes / startsWith / endsWith 会先做一次 IsRegExp 判定,判定入口正是 Symbol.match:只要这个属性不是 undefined,就取它的真值来决定「算不算正则」。
'abc'.includes(/b/)
// TypeError: First argument to String.prototype.includes must not be a regular expression
// 把 Symbol.match 设为 false,就不再被当作正则
const re = /foo/
re[Symbol.match] = false
'/foo/'.startsWith(re) // true
'foo'.startsWith(re) // false —— 注意:这里比较的是 toString()别被这个技巧误导
Symbol.match = false 只是让 String 方法「不把它当正则」,随后仍按普通字符串处理 —— ToString(re) 得到的是 /foo/(RegExp.prototype.toString() 的结果),不是 re.source。
如果确实想按 source 做字面量查找,必须连同 toString 一起覆盖:
const re2 = /foo/
re2[Symbol.match] = false
re2.toString = () => re2.source
'a foo b'.includes(re2) // true自定义匹配器:惰性编译 + 复用协议
一个实用场景:模块导入时不立刻编译正则(或在统一封装的 Pattern 对象里延迟编译),只要实现对应 Symbol 就能无缝接入原生 API:
function keywordPattern(keyword, flags = 'g') {
let re // 延迟编译:首次被使用时才创建
const get = () => (re ??= new RegExp(keyword, flags))
return {
// matchAll 会先按 IsRegExp 判定,再读取 flags 并要求其中含 'g'
flags,
[Symbol.match]: str => get()[Symbol.match](str),
[Symbol.matchAll]: str => get()[Symbol.matchAll](str),
[Symbol.replace]: (str, repl) => get()[Symbol.replace](str, repl),
}
}
const p = keywordPattern(String.raw`\d+`)
'abc123def456'.match(p) // ['123', '456'] —— 带 g 时 match 返回全部完整匹配
[...'abc123def456'.matchAll(p)].map(m => m[0]) // ['123', '456']
'abc123'.replace(p, '#') // 'abc#'
'abc123'.replaceAll(p, '#') // 'abc#' —— 自定义对象不受 replaceAll 的 g 校验限制覆写 Symbol.split:让分隔符保留在结果里
String.prototype.split 会丢弃分隔符。通过子类覆写 [Symbol.split] 可以改变这一点:
class KeepSeparator extends RegExp {
[Symbol.split](str, limit) {
return String(str).split(new RegExp(`(${this.source})`, this.flags), limit)
}
}
'a,b,c'.split(new KeepSeparator(',')) // ['a', ',', 'b', ',', 'c']四个容易踩的契约细节
- 协议属性必须「可调用或为
undefined」。写成{ [Symbol.match]: false }再交给str.match,会因为「存在但不可调用」在GetMethod处直接抛TypeError(includes这类只做布尔判定的方法不受影响)。 matchAll有一道额外的flags检查:若参数按IsRegExp判定为正则(即定义了Symbol.match),它会读取flags并要求其中含g,否则抛TypeError: The .flags property ... cannot be null or undefined。自定义对象因此必须暴露一个含g的flags属性。- 协议方法的返回值不做类型校验。
'a1'.match({ [Symbol.match]: () => 42 })的结果就是42—— 类型正确性完全由你的实现负责。 replaceAll对原生正则要求g,但对自定义对象不做校验(读到Symbol.replace后就提前返回了)。也就是说,只要实现了[Symbol.replace],即使flags里没有g也能被replaceAll接受 —— 这是特性,也意味着实现写错时更难被发现。
十、性能与安全:ReDoS
这是生产环境中最要紧、也最容易被忽略的一节。
回溯引擎的最坏时间复杂度是指数级的。当模式中存在嵌套量词(如 (a*)*、(a+)+、(.*)*)且输入不匹配时,引擎会尝试所有可能的组合爆炸式回溯:
// 危险模式:嵌套量词,输入全是 a 且不含 b 时必然全量回溯
const evil = /^(a*)*b$/
evil.test('a'.repeat(12)) // 0ms
evil.test('a'.repeat(20)) // 约 16ms
evil.test('a'.repeat(24)) // 约 250ms
evil.test('a'.repeat(28)) // 约 4s
// ⚠️ 长度每多 2 个字符,耗时约变为 4 倍 —— 不要在生产代码里尝试更长的输入按这个趋势外推,'a'.repeat(35) 就已经是分钟级了。也就是说:攻击者只需要几十个字符的输入,就能把单核 CPU 完全占满。这也是 Cloudflare、Stack Overflow 等大型服务历史上出现过的线上故障原因,相关 CVE 至今仍在持续产生。
工程防护清单:
- 永远不要把不可信输入直接当作模式。用户提供模式时,用
node-re2(RE2 线性引擎)或re2js;仅提供「关键字」时用RegExp.escape()。 - 警惕嵌套量词与「万能
.*」。.*a.*b已是二次复杂度;用更具体的字符类替代.,例如用[^"]*代替.*。 - 明确边界:能加
^...$就加上,避免引擎在无关位置上反复尝试。 - 限制输入长度:在进入正则前做长度上限校验,把爆炸的规模挡在门外。
- 可用前置断言替代「回溯试探」,例如用
(?!...)快速排除。 - 注意工具链里的正则:glob 转换、路由匹配、语法高亮、lint 规则都可能引入 ReDoS —— 历年的相关 CVE 中,相当一部分来自
braces、micromatch这类 glob 转换依赖。
前沿:V8 的线性匹配实验
V8 提供实验性的线性匹配引擎(需启动参数 --enable-experimental-regexp-engine,并在正则后添加 /l 标志),可在不支持反向引用和断言子集时保证线性时间。TC39 也在 2026 年提出了「线性时间标志」的 Stage 0 提案。
需要明确:这还不是标准,也不应依赖。当前生产环境的稳妥做法仍是上面 6 条 + RE2 类引擎。
不要用正则做的事
- 解析 HTML / XML:正则无法处理嵌套结构。用
DOMParser或专用解析器。 - 严格校验邮箱:RFC 5322 的完整正则既不可读也不可靠。工程上只做「宽松格式检查」,最终以「发送验证邮件」为准。
- 解析 JSON / CSV:请用
JSON.parse与成熟的 CSV 解析库。 - 校验身份证/银行卡号:校验位需要算术,正则只能验长度和字符集。
