Appearance
JavaScript 正则表达式 — W3C 标准视角
本文从 ECMAScript 规范与浏览器实现角度梳理 JavaScript 正则表达式,区别于基础语法讲解,重点关注规范定义的内部机制、Symbol 方法协议以及与浏览器环境的交互。
1. RegExp 在 ECMAScript 规范中的定义
1.1 规范结构
ECMAScript 规范(tc39.es/ecma262)将 RegExp 定义为内置对象,其行为由 RegExp 构造函数、RegExp.prototype 以及多个内部槽(Internal Slots) 决定。核心章节为 "21.2 RegExp (Regular Expression) Objects"。
关键内部槽:
| 内部槽 | 含义 |
|---|---|
[[OriginalSource]] | 正则模式原始字符串(含斜杠分隔符外的全部内容) |
[[OriginalFlags]] | 原始 flags 字符串 |
[[RegExpMatcher]] | 执行匹配的内部算法实现 |
[[LastIndex]] | 上一次匹配结束的位置(用于 g/y flag) |
[[Groups]] | 具名捕获组元信息(ES2018+) |
1.2 字面量语法解析
js
/pattern/flags字面量语法 /.../ 由 词法扫描器(Scanner) 在解析 JavaScript 源码时处理:
- 遇到
/后进入 PatternToken 状态 - 持续读取字符直到遇到匹配的结束
/ - 期间对
\进行特殊处理(转义),但不解释正则元字符 - 结束后读取 flags(允许的字符:
d,g,i,m,s,u,v,y) - 构造
RegExp对象,[[OriginalSource]]保存原始模式字符串
规范细节:字面量解析发生在语法分析阶段,比构造函数
new RegExp()的解析(运行时)更早,且不执行字符串转义。
1.3 构造器 vs 字面量
字面量 /pattern/flags | new RegExp(pattern, flags) | |
|---|---|---|
| 解析时机 | 编译时(源码扫描) | 运行时 |
pattern 类型 | 字面量源码字符 | 字符串 |
\ 转义 | 不解释(保留原样) | 需双写 \\ 转义 |
| 相同模式对象 | 共享(规范要求共享字面量对象,浏览器通常复用) | 每次 new 创建新对象 |
2. String.prototype 与正则的交互(规范算法)
ECMAScript 规范为每个 String 方法定义了与 RegExp 交互的标准算法,通过 Well-Known Symbol 触发内部方法。
2.1 核心 Symbol 方法对应关系
| Symbol 方法 | 触发来源 | 返回值 |
|---|---|---|
RegExp.prototype[@@match] | String.prototype.match() | Array 或 null |
RegExp.prototype[@@replace] | String.prototype.replace() | 替换后字符串 |
RegExp.prototype[@@search] | String.prototype.search() | 匹配起始索引 |
RegExp.prototype[@@split] | String.prototype.split() | 分割后的字符串数组 |
RegExp.prototype[@@matchAll] | String.prototype.matchAll() | 可迭代的 Match 对象 |
2.2 String.prototype.match() 算法(规范 §21.1.3.10)
text
String.prototype.match(regexp)
1. If regexp is undefined or null, return String(this) match against /\/*s*/.
2. Let rx be ? ToObject(regexp).
3. Let matcher be ? GetMethod(rx, @@match).
4. Return ? matcher.call(rx, this).关键点:
- 如果传入的不是对象(或没有
@@match方法),规范不会自动回退到正则字面量匹配 @@match方法的存在本身作为正则身份标识:若@@match === undefined(非正则对象),某些操作会抛出TypeError
2.3 String.prototype.replace() 与 @@replace
@@replace 算法(规范 §21.1.3.17)核心流程:
- 从左到右执行所有匹配(
gflag) - 对每个匹配调用 Replacer Algorithm:解析
$1,$<name>等替换占位符 - 捕获组结果通过
MatchRecord传递
js
// 具名捕获组在 replace 中的使用
"2026-04-01".replace(/(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/u,
"$<day>/$<month>/$<year>"
)
// "01/04/2026"3. Unicode 模式(u flag)与 Code Point
3.1 UTF-16 与 JavaScript 字符串
JavaScript 字符串采用 UTF-16 编码:
- Basic Multilingual Plane(BMP,U+0000–U+FFFF):1 个代码单元 = 1 个代码点
- 增补平面(Supplementary,U+10000+):2 个代码单元(surrogate pair)
js
"😄".length // 2 — 是两个 UTF-16 代码单元
[..."😄"].length // 1 — 展开后是 1 个代码点3.2 u flag 的规范效果
启用 u flag 后,正则引擎行为发生根本变化:
| 行为 | 无 u flag | 有 u flag |
|---|---|---|
. 匹配粒度 | UTF-16 代码单元 | Unicode 代码点 |
\u{xxxx} 语法 | 语法错误 | 匹配代码点 U+xxxx |
\p{...} Unicode 属性 | 语法错误 | 匹配 Unicode 属性 |
| 字符类范围 | 按代码单元排序 | 按代码点排序 |
| 量词作用范围 | 1 个代码单元 | 1 个代码点 |
js
// 无 u flag:超出 BMP 的字符被截断处理
/./.test("😄") // false — . 不匹配 0xD83D(surrogate half)
// 有 u flag:正确匹配整个表情符号
/./u.test("😄") // true — . 匹配代码点 U+1F6043.3 \p{...} Unicode 属性逃逸(ES2018+)
需要 u flag 才能使用:
js
// 匹配所有 Unicode 字母(含中文)
/\p{L}/u.test("中") // true
/\p{L}/u.test("A") // true
// 匹配 Unicode 数字
/\p{N}/u.test("5") // true(全角数字)
// 匹配 Emoji
/\p{Emoji}/u.test("😄") // true
// 匹配 Unicode 脚本(Script)
/\p{Script=Han}/u.test("乐") // true — 汉字
/\p{Script=Latin}/u.test("a") // true3.4 v flag(ES2024)
v flag 是 u 的扩展,支持:
- 集合操作:
[[a-z]&&[m-p]]交集、[[a-z]--[m-p]]差集 - 字符串属性:如
\p{Script_Extensions=Hiragana}
js
// v flag 下可以使用字符串属性和集合操作
/\p{RGI_Emoji_Set}/v.test("😄") // true注意:
u和v不能同时使用,会报SyntaxError。
4. lastIndex、Sticky(y)与 Global(g)flag
4.1 lastIndex 内部槽
lastIndex 是 RegExp.prototype 的一个可写属性,映射到内部槽 [[LastIndex]]。它只在 g 或 y flag 存在时对匹配行为产生影响。
4.2 g flag — 全局匹配
g flag 下,@@match(对应 String.prototype.match())会循环调用 RegExp.prototype.exec(),直到返回 null:
js
const re = /ab/g;
re.lastIndex = 0; // 初始化
const str = "_ab_ab_";
const matches = [];
let result;
while ((result = re.exec(str)) !== null) {
matches.push(result[0]);
// lastIndex 自动推进到上一次匹配结束位置
}
console.log(matches); // ["ab", "ab"]关键规范行为:
exec()成功 →lastIndex设为matchEndexec()失败 →lastIndex重置为0match()调用@@match,内部按同样逻辑处理
4.3 y flag — Sticky(粘滞)匹配
y flag 保证匹配严格从 lastIndex 位置开始,不向前扫描:
js
const re = /ab/y;
re.lastIndex = 1;
re.test("_ab_") // true — 从 index 1 开始匹配
re.test("_ab_") // false — lastIndex=3,但字符串[3]="_",无法匹配"ab"4.4 g 与 y 的组合
规范规定:当 g 和 y 同时存在时,exec() 的行为优先遵循 sticky 语义(即只从 lastIndex 位置匹配,不循环):
js
const re = /ab/gy;
re.lastIndex = 1;
re.exec("_ab_ab_") // null — index 1 是 "_",不是 "ab" 的起点
re.lastIndex = 3;
re.exec("_ab_ab_") // ["ab", index: 3] — 从 index 3 匹配4.5 lastIndex 推进规则
规范 §21.2.2.2(RegExpBuiltinExec)定义的推进规则:
| 匹配结果 | lastIndex 行为 |
|---|---|
| 成功,非空匹配 | 推进到 endIndex(匹配内容末尾) |
| 成功,空匹配 | u flag → 推进 1 个代码点;无 u → 推进 1 个代码单元 |
| 失败 | 重置为 0 |
5. 具名捕获组(Named Capture Groups / NFE)
5.1 规范背景
ES2018 引入 Named Capture Groups,语法 (?<name>pattern)。结果对象通过 groups 属性访问:
js
const re = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/u;
const result = re.exec("2026-04-01");
result.groups // { year: "2026", month: "04", day: "01" }
result.groups.year // "2026"5.2 @@replace 中的具名反向引用
js
// 交换年月日顺序
"2026-04-01".replace(
/(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/u,
"$<day>.$<month>.$<year>"
)
// "01.04.2026"5.3 具名反向引用 \k<name>
在模式内部引用具名组(Named Backreference):
js
// 匹配带引号的字符串(单引号或双引号)
/(?<quote>["'])(?<content>.*?)\k<quote>/.exec('"hello"')
// ["\"hello\"", '"', 'hello', index: 0, ...]5.4 浏览器兼容性
| 特性 | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
(?<name>...) 具名捕获组 | 64+ | 78+ | 11.1+ | 79+ |
groups 属性 | 64+ | 78+ | 11.1+ | 79+ |
\k<name> 具名反向引用 | 64+ | 78+ | 11.1+ | 79+ |
$<name> 替换占位符 | 64+ | 78+ | 11.1+ | 79+ |
来源:MDN & Can I Use。IE 全系不支持。移动端 iOS Safari 11.1+、Android Chrome 64+ 均已支持。
6. RegExp.prototype[@@match] 内部槽协议
6.1 Symbol 作为内部方法
ECMAScript 规范使用 Well-Known Symbols 表示内部方法的钩子:
js
RegExp.prototype[Symbol.match] // 规范定义的内部 [[Match]] 方法
RegExp.prototype[Symbol.replace] // 规范定义的内部 [[Replace]] 方法
RegExp.prototype[Symbol.search] // 规范定义的内部 [[Search]] 方法
RegExp.prototype[Symbol.split] // 规范定义的内部 [[Split]] 方法
RegExp.prototype[Symbol.matchAll] // 规范定义的内部 [[MatchAll]] 方法6.2 Symbol 方法的意义
- 子类化:可以继承
RegExp并覆盖这些 Symbol 方法改变匹配行为 - 身份标识:
Symbol.match属性用于判断对象是否"像正则" - 正则化对象:让非 RegExp 对象也能参与正则协议
js
// 自定义一个"正则化"对象
const obj = {
[Symbol.match](str) {
return str.includes("foo") ? ["foo"] : null;
}
};
"hello-foo-world".match(obj) // ["foo"]6.3 @@matchAll 与 Match Iterator
ES2020 引入 Symbol.matchAll,返回 Match Iterator:
js
const re = /(\w)-(\d)/g;
const str = "a-1 b-2 c-3";
for (const match of str.matchAll(re)) {
console.log(match[0], match[1], match[2], match.groups);
}
// "a-1" "a" "1" { undefined, undefined }
// (无命名组则 groups 为 undefined)注意:
matchAll要求正则必须带gflag,否则抛出TypeError。
7. 浏览器的 RegExp 兼容性(Can I Use)
以下为主要 RegExp 特性的浏览器支持情况:
| 特性 | Chrome | Firefox | Safari | Edge | IE |
|---|---|---|---|---|---|
| 基本 RegExp | ✅ 1+ | ✅ 1+ | ✅ 1+ | ✅ 12+ | ✅ 5.5+ |
lastIndex(g/y) | ✅ 39+ | ✅ 25+ | ✅ 8+ | ✅ 12+ | ❌ |
u flag | ✅ 50+ | ✅ 29+ | ✅ 10+ | ✅ 79+ | ❌ |
s flag (dotAll) | ✅ 62+ | ✅ 78+ | ✅ 11.1+ | ✅ 79+ | ❌ |
| 具名捕获组 | ✅ 64+ | ✅ 78+ | ✅ 11.1+ | ✅ 79+ | ❌ |
断言 (?<=) (?<!) | ✅ 62+ | ✅ 78+ | ✅ 11.1+ | ✅ 79+ | ❌ |
v flag (ES2024) | ✅ 122+ | ✅ 127+ | ✅ 17.4+ | ✅ 122+ | ❌ |
\p{...} Unicode 属性 | ✅ 64+ | ✅ 78+ | ✅ 11.1+ | ✅ 79+ | ❌ |
d flag (indices) | ✅ 126+ | ✅ 126+ | ✅ 16.4+ | ✅ 126+ | ❌ |
dflag:ES2022 引入,给匹配结果添加indices数组,表示每个捕获组在原字符串中的起始/结束位置。
8. 实际应用:从 W3C 角度理解
8.1 URL 正则的陷阱
解析 URL 时,按 W3C URL 规范(WHATWG URL Standard),正则处理需要考虑:
js
// ❌ 常见错误:假设 URL 只有一个"."或不含 Unicode
// /https?:\/\/[^\/]+/.test("https://example.com/")
// ✅ 正确处理 Unicode 域名和路径
const URL_REGEX = /^(?<protocol>https?):\/\/(?<host>[\w.-]+)(?<path>\/.*)?$/u;
// ✅ 匹配百分号编码的 Unicode
/%[0-9A-Fa-f]{2}/.test("https://example.com/%E4%B8%AD%E6%96%87")W3C 角度:URL 的合法字符集在 WHATWG URL Standard 中定义,正则只是验证工具。真正的 URL 解析应使用 new URL() API,而非纯正则。
8.2 HTML 标签匹配
js
// 匹配自闭合标签(SVG、MathML 等)
const SELF_CLOSING_TAG = /<([a-z][a-z0-9-]*)\s*([^>]*)\/?>/gi;
// 匹配带命名空间的标签
const NS_TAG = /<([a-zA-Z][a-zA-Z0-9]*):([a-zA-Z][a-zA-Z0-9]*)\s*([^>]*)>/gi;
// 匹配 HTML 注释(条件注释不适用)
const COMMENT = /<!--[\s\S]*?-->/g;规范注意:HTML 规范中的标签名必须遵循 HTML5 标签名规则,正则只能做近似验证。
8.3 CSS 选择器转正则(简化)
很多场景需要将 CSS 选择器转为正则进行 DOM 查询:
js
/**
* 将简单 CSS 选择器转为正则(支持 class、id、属性选择器)
* 仅用于理解原理,实际应使用 DOM API(querySelector)
*/
function cssToRegex(selector) {
let pattern = selector
// 转义正则特殊字符
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
// class 选择器 .foo → 匹配 class 属性中的 foo
.replace(/\.([a-zA-Z_-]+)/g, '(?:.*\\s)?$1(?:\\s|$)')
// id 选择器 #foo
.replace(/#([a-zA-Z_-]+)/g, '(?:id=["\']?)$1(?:["\']?\\s|$)');
return new RegExp(`^${pattern}`, 'i');
}
cssToRegex('.card') // /^ (?:.*\s)?card(?:\s|$) /i
cssToRegex('#header') // /^ (?:id=["\']?)header(?:["\']?\s|$) /i8.4 表单验证(实际业务场景)
js
// 符合 W3C HTML5 验证规范的输入模式
const patterns = {
// 邮箱(简化版,真实场景用 type="email")
email: /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zZ0-9-]{0,61}[a-zA-Z0-9])?)*$/,
// URL(简化版)
url: /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/,
// 中国手机号(宽松匹配)
phoneCN: /^(?:\+86)?1[3-9]\d{9}$/,
// IPv4 地址
ipv4: /^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/,
};9. RegExp.prototype.exec() 规范算法要点
规范 §21.2.2.2(RegExpBuiltinExec)定义了 exec() 的标准行为:
- 初始化:
lastIndex从this.lastIndex读取 - 位置校验:如果
lastIndex > str.length,执行失败,重置lastIndex→0,返回null - 匹配尝试:从
lastIndex位置开始尝试模式匹配 - Sticky 检查:若
stickyflag 存在且匹配位置 ≠lastIndex,返回null - 结果记录:匹配成功后记录
startIndex、endIndex,并填充捕获组 - 推进:
lastIndex设为endIndex - 空匹配处理:若匹配长度为 0,推进 1 个字符单位(见 §4.5 规则)
js
// 演示空匹配推进行为
const re = /a?/g; // 匹配 "a" 或 空串
const r = /a?/gu; // 同上,但空匹配按代码点推进
"abc".match(re) // ["a", "", "b", "", "c", ""] — 6 个结果