2.5 正则表达式与文本处理
正则表达式(Regular Expression,简称Regex/RegExp)是文本处理的强大工具,它使用一种模式语法来匹配、查找、替换符合特定规则的字符串。掌握正则表达式能极大提升文本处理的效率。
正则表达式的基本语法
1. 普通字符
普通字符就是字面意义上的字符,直接匹配对应的字符:
- 例如:
abc匹配字符串 “abc” - 大小写敏感,
A和a是不同的
2. 元字符
元字符是正则表达式中有特殊含义的字符,需要转义(前面加\)才能匹配字面意义:
| 元字符 | 含义 |
|---|---|
. | 匹配除换行符以外的任意单个字符 |
* | 匹配前面的子表达式零次或多次 |
+ | 匹配前面的子表达式一次或多次 |
? | 匹配前面的子表达式零次或一次,或表示非贪婪匹配 |
^ | 匹配字符串的开始位置 |
$ | 匹配字符串的结束位置 |
\ | 转义字符,用于匹配元字符本身 |
| ` | ` |
() | 分组,将括号内的表达式作为一个整体 |
[] | 字符类,匹配方括号中的任意一个字符 |
{} | 量词,指定匹配的次数 |
3. 量词
量词用来指定前面的子表达式匹配多少次:
| 量词 | 含义 |
|---|---|
{n} | 精确匹配n次 |
{n,} | 至少匹配n次 |
{n,m} | 匹配n到m次 |
* | 等价于{0,} |
+ | 等价于{1,} |
? | 等价于{0,1} |
4. 字符类
方括号[]用来定义字符类,匹配其中的任意一个字符:
| 写法 | 含义 |
|---|---|
[abc] | 匹配a、b、c中的任意一个字符 |
[^abc] | 匹配除了a、b、c以外的任意字符(取反) |
[a-z] | 匹配任意小写字母 |
[A-Z] | 匹配任意大写字母 |
[0-9] | 匹配任意数字 |
[a-zA-Z0-9] | 匹配任意字母或数字 |
5. 预定义字符类
正则表达式提供了一些常用的预定义字符类:
| 预定义 | 含义 | 等价于 |
|---|---|---|
\d | 匹配数字 | [0-9] |
\D | 匹配非数字 | [^0-9] |
\w | 匹配单词字符(字母、数字、下划线) | [a-zA-Z0-9_] |
\W | 匹配非单词字符 | [^\w] |
\s | 匹配空白字符(空格、制表符、换行符等) | [ \t\n\r\f\v] |
\S | 匹配非空白字符 | [^\s] |
\b | 匹配单词边界 | |
\B | 匹配非单词边界 |
正则表达式高级技巧
1. 贪婪匹配 vs 非贪婪匹配
- 贪婪匹配:默认情况下,量词
*、+、?、{n,m}会尽可能多的匹配字符 - 非贪婪匹配:在量词后面加
?,会尽可能少的匹配字符
示例:
对于字符串 <div>hello</div><div>world</div>
- 贪婪模式:
<div>.*</div>匹配整个字符串 - 非贪婪模式:
<div>.*?</div>匹配第一个<div>hello</div>
2. 分组与捕获
用括号()可以将表达式分组,分组匹配的内容可以被捕获和引用:
// 匹配日期格式,捕获年、月、日
const regex = /(\d{4})-(\d{2})-(\d{2})/;
const match = regex.exec('2023-12-25');
console.log(match[1]); // 2023(第一个分组)
console.log(match[2]); // 12(第二个分组)
console.log(match[3]); // 25(第三个分组)
命名分组
很多正则引擎支持命名分组,给分组起个名字,更方便使用:
const regex = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const match = regex.exec('2023-12-25');
console.log(match.groups.year); // 2023
非捕获分组
如果不需要捕获分组的内容,可以用(?:)表示非捕获分组,只用来分组,不捕获内容:
// 匹配http或https,但不捕获协议部分
const regex = /(?:http|https):\/\/(\w+\.\w+)/;
3. 反向引用
可以用\1、\2等引用前面分组匹配到的内容:
示例:匹配重复的单词
// 匹配连续重复的单词,比如"hello hello"
const regex = /\b(\w+)\s+\1\b/;
regex.test('hello hello'); // true
regex.test('hello world'); // false
4. 环视(零宽断言)
环视用来匹配位置,而不是匹配字符,匹配的内容不包含在结果中:
| 类型 | 语法 | 含义 |
|---|---|---|
| 正向前瞻 | (?=pattern) | 匹配后面是pattern的位置 |
| 负向前瞻 | (?!pattern) | 匹配后面不是pattern的位置 |
| 正向后顾 | (?<=pattern) | 匹配前面是pattern的位置 |
| 负向后顾 | (?<!pattern) | 匹配前面不是pattern的位置 |
示例:
// 匹配"密码"后面跟着的数字,不包含"密码"本身
const regex = /(?<=密码:)\d+/;
regex.exec('密码:123456')[0]; // 123456
// 匹配不是以139开头的手机号
const regex = /^(?!139)\d{11}$/;
常见文本处理场景的正则实现
1. 手机号验证
^1[3-9]\d{9}$
解释:以1开头,第二位是3-9,后面跟着9位数字,总共11位。
2. 邮箱验证
^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$
解释:用户名部分可以包含字母、数字和特殊字符,@后面是域名,最后是后缀。
3. URL验证
^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([\/\w .-]*)*\/?$
4. 身份证号验证
^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$
5. 提取HTML标签中的内容
// 提取a标签中的链接
<a[^>]+href="([^"]+)"[^>]*>
// 提取标签之间的文本
<div[^>]*>(.*?)<\/div>
6. 替换敏感词
// 将文本中的"敏感词"替换为***
const text = '这是包含敏感词的文本';
const result = text.replace(/敏感词/g, '***');
正则表达式的性能优化
正则表达式如果写得不好,很容易出现性能问题,甚至导致回溯风暴(Catastrophic Backtracking),让程序卡住。
1. 避免不必要的回溯
- 尽量减少嵌套的量词,比如
(a+)*这种写法非常容易导致回溯风暴 - 非贪婪匹配要谨慎使用,可能会导致大量回溯
- 能用普通字符串匹配的就不要用正则
2. 优化匹配范围
- 尽量使用精确的字符类,而不是用
.* - 例如:匹配引号中的内容用
"[^"]*"比".*?"性能好很多
3. 提前终止匹配
- 用
^和$明确匹配的开始和结束位置,避免不必要的搜索 - 从左到右尽可能早地排除不匹配的情况
4. 复用正则表达式
- 不要在循环中重复创建正则表达式对象,提前创建好复用
- 开启合适的修饰符(如
i忽略大小写,g全局匹配,m多行模式)
5. 避免过度复杂的正则
- 过于复杂的正则表达式不仅难维护,性能也差
- 复杂的文本处理可以拆分成多个步骤,而不是写一个超级复杂的正则
正则表达式的常见坑点
- 点号
.不匹配换行符:默认情况下.不匹配换行符,如果需要匹配要使用s修饰符(单行模式) - 字符类中的元字符不需要转义:在
[]中,除了^、-、]之外,其他元字符都不需要转义 - 量词默认是贪婪的:不注意的话会匹配到意料之外的内容
- 零宽断言的支持:不是所有正则引擎都支持后顾断言,特别是JavaScript在ES2018之后才支持
- 不同语言的正则实现有差异:Python、Java、JavaScript等语言的正则引擎支持的特性略有不同
最佳实践
- 正则不是万能的:不要什么都用正则解决,简单的字符串处理用语言内置的字符串方法更高效
- 写注释:复杂的正则表达式要写注释,说明每个部分的作用
- 测试边界情况:测试正则的时候要覆盖各种边界情况,包括异常输入
- 优先使用现成的正则:常用的验证正则(手机号、邮箱等)优先使用经过验证的现成方案,不要自己瞎写
- 在线工具调试:可以用regex101.com等在线工具调试正则表达式,可视化地看匹配过程
思考问题
- 正则表达式的贪婪匹配和非贪婪匹配有什么区别?分别适合什么场景?
- 写一个正则表达式,匹配中国大陆的固定电话号码(格式:010-12345678 或 021-12345678)
- 什么是正则的回溯风暴?怎么避免?
- 你用正则表达式解决过什么复杂的文本处理问题?