Skip to content

约 7063 字大约 24 分钟

javascript

2026-02-10

本文是对 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.sourcere1.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, '\\$&') 更可靠:手写版本必须同时兼容 uv 两套转义规则, 很容易漏 —— 例如 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分支:匹配 xy
[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 是退格,不是边界。
\dASCII 数字,等价 [0-9]即使开启 u 也仍是 ASCII)。
\D非 ASCII 数字,等价 [^0-9]
\wASCII 单字字符,等价 [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
\0NUL 字符(后面不能紧跟数字,否则会被当成八进制/反向引用)。
\xhh匹配编码为两位十六进制的字符。
\uhhhh匹配编码为四位十六进制的字符。
\u{h…h}匹配码点(需要 uv 标志),可表示大于 U+FFFF 的字符。
\cX控制字符,例如 \cJ 等价于 \n
\1 \2反向引用,匹配第 n 个捕获组此前捕获到的内容。
\k<n>反向引用,匹配命名捕获组 n 的内容。
\p{...}Unicode 属性转义(需要 uv 标志),匹配属于该属性的字符。
\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 的 reUNICODE 模式、.NET)最显著的差异之一:

/\d/.test('٣')        // false —— 阿拉伯-印度数字 3
/\d/.test('')        // 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)标志的主要作用:

  1. 码点而非 UTF-16 码元解析模式,正确处理 😀 这类由代理对组成的字符;
  2. 启用 \u{...} 码点转义与 \p{...} 属性转义;
  3. 让正则严格化:非法转义、孤立的量词会直接抛 SyntaxError,而不是被当作字面量。这是好事,能提前暴露错误。
/^.$/.test('😀')    // false,. 只吃掉一个码元
/^.$/u.test('😀')   // true,u 模式下 . 匹配一个码点
/^\u{1F600}$/u.test('😀') // true

4.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

vu 的超集,但不能用它来「顺便兼容」: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 ? 的两种身份

? 有两种完全不同的含义,取决于它出现的位置:

  1. 作为量词:跟在原子后面,表示重复 0 或 1 次 —— ab? 可匹配 aab
  2. 作为懒惰后缀:跟在另一个量词(*+?{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 {} quantifier

5.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']

三个必须知道的细节:

  1. 断言中的捕获组仍会被捕获。这一点常被忽略:
/(?=(b))a?/.exec('ab') // ['', 'b'] —— 前瞻里的 (b) 贡献了第 1 个捕获组
  1. 后行断言(lookbehind)自 ES2018 起已成为标准,主流运行时全面支持。
  2. 「固定长度」限制是历史遗留: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 标志、matchAllreplace 回调等场景下的行为不直观。
// ❌ 不要这样写
/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)全景

标志名称引入版本作用
gglobalES3全局查找所有匹配(配合 lastIndex 有状态)
iignoreCaseES3忽略大小写
mmultilineES3^ $ 按行匹配
uunicodeES2015Unicode 码点模式,启用 \u{}\p{}
ystickyES2015粘性匹配,只从 lastIndex 位置开始,不向前搜索
sdotAllES2018. 匹配包括行终止符在内的任意字符
dhasIndicesES2022额外返回 indices,含每个分组的起止下标
vunicodeSetsES2024u 的超集,字符类集合运算与字符串属性

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 开始向后搜索,找到一个匹配即可;
  • ylastIndex 处精确匹配,失败即失败,不向后扫描。
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:...),可用标志为 ims

九、常用 API 与方法选择

多数正则的 bug 不是模式写错,而是方法选错。先看对照表:

需求方法关键点
是否存在匹配(布尔)re.test(str)g/y 时依赖 lastIndex,有状态
取第一个匹配的完整信息re.exec(str)str.match(re)gexec 会推进 lastIndex
取所有匹配的捕获组str.matchAll(re)必须带 g,否则抛 TypeError
取所有匹配(不要捕获组)str.match(re)g只返回完整匹配,丢失分组
替换(首个 / 全部)replace / replaceAllreplaceAll 的正则必须带 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 上的 matchmatchAllreplacereplaceAllsearchsplit 并不是硬编码只认 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 判定

这带来两个直接结论:

  1. 任何对象只要实现了对应的 Symbol 方法,就能被 String 方法接受 —— 这是实现自定义匹配器(惰性编译、流式匹配、代理上报)的标准扩展点。
  2. RegExp 自身的行为也正是由 RegExp.prototype[Symbol.*] 定义的,所以子类化时覆写这些方法就能改变语义,而不必去 hack 内部实现。

IsRegExpSymbol.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']

四个容易踩的契约细节

  1. 协议属性必须「可调用或为 undefined。写成 { [Symbol.match]: false } 再交给 str.match,会因为「存在但不可调用」在 GetMethod 处直接抛 TypeErrorincludes 这类只做布尔判定的方法不受影响)。
  2. matchAll 有一道额外的 flags 检查:若参数按 IsRegExp 判定为正则(即定义了 Symbol.match),它会读取 flags 并要求其中含 g,否则抛 TypeError: The .flags property ... cannot be null or undefined。自定义对象因此必须暴露一个含 gflags 属性。
  3. 协议方法的返回值不做类型校验'a1'.match({ [Symbol.match]: () => 42 }) 的结果就是 42 —— 类型正确性完全由你的实现负责。
  4. 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 至今仍在持续产生。

工程防护清单:

  1. 永远不要把不可信输入直接当作模式。用户提供模式时,用 node-re2(RE2 线性引擎)或 re2js;仅提供「关键字」时用 RegExp.escape()
  2. 警惕嵌套量词与「万能 .*.*a.*b 已是二次复杂度;用更具体的字符类替代 .,例如用 [^"]* 代替 .*
  3. 明确边界:能加 ^...$ 就加上,避免引擎在无关位置上反复尝试。
  4. 限制输入长度:在进入正则前做长度上限校验,把爆炸的规模挡在门外。
  5. 可用前置断言替代「回溯试探」,例如用 (?!...) 快速排除。
  6. 注意工具链里的正则:glob 转换、路由匹配、语法高亮、lint 规则都可能引入 ReDoS —— 历年的相关 CVE 中,相当一部分来自 bracesmicromatch 这类 glob 转换依赖。

前沿:V8 的线性匹配实验

V8 提供实验性的线性匹配引擎(需启动参数 --enable-experimental-regexp-engine,并在正则后添加 /l 标志),可在不支持反向引用和断言子集时保证线性时间。TC39 也在 2026 年提出了「线性时间标志」的 Stage 0 提案。

需要明确:这还不是标准,也不应依赖。当前生产环境的稳妥做法仍是上面 6 条 + RE2 类引擎。

不要用正则做的事

  • 解析 HTML / XML:正则无法处理嵌套结构。用 DOMParser 或专用解析器。
  • 严格校验邮箱:RFC 5322 的完整正则既不可读也不可靠。工程上只做「宽松格式检查」,最终以「发送验证邮件」为准。
  • 解析 JSON / CSV:请用 JSON.parse 与成熟的 CSV 解析库。
  • 校验身份证/银行卡号:校验位需要算术,正则只能验长度和字符集。

参考资料