JavaScript 正则表达式

深入解析 JavaScript 正则表达式的创建方式、捕获组与环视、贪婪 vs 非贪婪匹配,以及实际场景中的输入验证与文本提取。

为什么正则表达式是文本处理的利器

正则表达式是处理文本的瑞士军刀。从表单验证到数据提取,从 URL 解析到日志分析,正则表达式无处不在。但正则也是出了名的难读难写——/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/ 这种表达式让很多人望而却步。

这篇文章让你真正理解正则表达式,从基础到高级,学会写出可维护的正则。


一、正则表达式基础

1.1 创建正则表达式

// 字面量方式(推荐)
const regex1 = /hello/;
const regex2 = /hello/i; // i = 不区分大小写
const regex3 = /hello/g; // g = 全局匹配
const regex4 = /hello/gi; // 组合使用

// 构造函数方式(动态构建)
const pattern = 'hello';
const regex5 = new RegExp(pattern, 'gi');

// 需要转义的字符
const escaped = new RegExp('file/special\\.txt', 'g');

1.2 基础匹配

// 精确匹配
/hello/.test('hello world'); // true

// 元字符
.   // 任意字符(换行除外)
\d  // 数字 [0-9]
\D  // 非数字 [^0-9]
\w  // 单词字符 [a-zA-Z0-9_]
\W  // 非单词字符
\s  // 空白字符
\S  // 非空白字符

// 边界
^   // 字符串开头
$   // 字符串结尾
\b  // 单词边界
\B  // 非单词边界

// 字符类
[abc]   // 匹配 a、b 或 c
[^abc]  // 匹配除 a、b、c 外的字符
[a-z]   // 匹配 a 到 z
[0-9]   // 匹配 0 到 9

二、捕获组

2.1 基本捕获组

// 圆括号创建捕获组
const date = '2024-04-08';
const regex = /(\d{4})-(\d{2})-(\d{2})/;
const match = date.match(regex);

console.log(match[0]); // '2024-04-08' — 完整匹配
console.log(match[1]); // '2024' — 第一组
console.log(match[2]); // '04' — 第二组
console.log(match[3]); // '08' — 第三组
console.log(match.groups); // undefined — 未命名组

2.2 命名捕获组

// ES2018 引入命名捕获组
const regex = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const match = '2024-04-08'.match(regex);

console.log(match.groups.year);   // '2024'
console.log(match.groups.month);  // '04'
console.log(match.groups.day);    // '08'

// 在 replace 中使用命名组
const formatted = '2024-04-08'.replace(
  /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/,
  '$<month>/$<day>/$<year>'
);
console.log(formatted); // '04/08/2024'

2.3 非捕获组

// (?:...) 不创建捕获组,更高效
const regex1 = /(\d{4})-(?:\d{2})-(?:\d{2})/; // 非捕获组
const match1 = '2024-04-08'.match(regex1);
console.log(match1[1]); // '2024' — 只有年份被捕获

// (?:...)? 可选非捕获组
/(?:https?)?(?::\/\/)?/.test('http://example.com'); // true

三、环视(Lookahead / Lookbehind)

3.1 前置断言(Lookahead)

// 正向前瞻 (?=...) — 后面跟着
const regex1 = /\d+(?=px)/;
console.log('100px'.match(regex1)); // '100'
console.log('100em'.match(regex1)); // null

// 负向前瞻 (?!...) — 后面不跟着
const regex2 = /\d+(?!px)/;
console.log('100em'.match(regex2)); // '100'
console.log('100px'.match(regex2)); // null

3.2 后置断言(Lookbehind)

// 正向后顾 (?<=...) — 前面是
const regex1 = /(?<=\$)\d+/;
console.log('$100'.match(regex1)); // '100'
console.log('€100'.match(regex1)); // null

// 负向后顾 (?<!...) — 前面不是
const regex2 = /(?<!\$)\d+/;
console.log('€100'.match(regex2)); // '100'

3.3 实际应用

// 密码验证:至少8位,必须包含数字和字母
const passwordRegex = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/;

// 提取汇率数值(跳过货币符号)
const extractAmount = /(?<=USD )\d+\.?\d*/;
'Price: USD 99.99'.match(extractAmount); // '99.99'

// 驼峰转蛇形
const toSnakeCase = str => str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
toSnakeCase('helloWorld'); // 'hello_world'

四、贪婪 vs 非贪婪

4.1 贪婪匹配

// 默认贪婪:匹配尽可能多
'hello world'.match(/hello.*world/); // ['hello world']

// 数字匹配
'123abc456'.match(/\d+/g); // ['123', '456'] — 贪婪但不会跨域

4.2 非贪婪匹配

// 加 ? 变成非贪婪:匹配尽可能少
'hello world'.match(/hello.*?world/); // ['hello world']

// 提取 HTML 标签内容
'<div>content</div><span>more</span>'.match(/<(\w+)>.*?<\/\1>/g);
// ['<div>content</div>', '<span>more</span>']

// 常用场景:从 JSON 中提取值
const jsonStr = '{"name":"Alice","age":30}';
jsonStr.match(/"(\w+)":/g); // ['"name":', '"age":']

五、exec 与 lastIndex

5.1 exec 方法

// exec 返回详细匹配信息,支持捕获组
const regex = /(\w+)@(\w+)\.(\w+)/;
const result = regex.exec('[email protected]');

console.log(result[0]); // '[email protected]'
console.log(result[1]); // 'user'
console.log(result[2]); // 'example'
console.log(result[3]); // 'com'
console.log(result.index); // 0 — 匹配位置
console.log(result.input); // '[email protected]'

5.2 带 g 标志的 lastIndex

// 带 g 标志时,exec 会维护 lastIndex
const regex = /\w+/g;
const str = 'hello world';

console.log(regex.exec(str)); // ['hello', index: 0]
console.log(regex.lastIndex); // 5

console.log(regex.exec(str)); // ['world', index: 6]
console.log(regex.lastIndex); // 11

console.log(regex.exec(str)); // null — 结束
console.log(regex.lastIndex); // 0 — 重置

// 常用模式:循环匹配
const allMatches = [];
let match;
while ((match = regex.exec(str)) !== null) {
  allMatches.push(match[0]);
}

六、实际场景

6.1 表单验证

// 邮箱
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

// URL
const isUrl = /^https?:\/\/[\w\-]+(\.[\w\-]+)+[/#?]?.*$/;

// 手机号(中国)
const isPhoneCN = /^1[3-9]\d{9}$/;

// 身份证(中国)
const isIdCardCN = /^[1-9]\d{5}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/;

6.2 文本提取

// 提取 URL
const extractUrls = text => text.match(/https?:\/\/[^\s]+/g) || [];

// 提取 @mention
const extractMentions = text => text.match(/@\w+/g) || [];

// 提取 hashtag
const extractHashtags = text => text.match(/#[^\s#]+/g) || [];

// 提取 HTML 标签
const stripHtml = html => html.replace(/<[^>]+>/g, '');

// 提取数字
const extractNumbers = text => text.match(/-?\d+\.?\d*/g) || [];

6.3 字符串替换

// 清除多余空格
'  hello   world  '.replace(/\s+/g, ' ').trim(); // 'hello world'

// 首字母大写
'hello world'.replace(/\b\w/g, c => c.toUpperCase()); // 'Hello World'

// 千分位分隔符
'1234567'.replace(/\B(?=(\d{3})+(?!\d))/g, ','); // '1,234,567'

七、延展阅读