Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

2.5 正则表达式与文本处理

正则表达式(Regular Expression,简称Regex/RegExp)是文本处理的强大工具,它使用一种模式语法来匹配、查找、替换符合特定规则的字符串。掌握正则表达式能极大提升文本处理的效率。

正则表达式的基本语法

1. 普通字符

普通字符就是字面意义上的字符,直接匹配对应的字符:

  • 例如:abc 匹配字符串 “abc”
  • 大小写敏感,Aa 是不同的

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. 避免过度复杂的正则

  • 过于复杂的正则表达式不仅难维护,性能也差
  • 复杂的文本处理可以拆分成多个步骤,而不是写一个超级复杂的正则

正则表达式的常见坑点

  1. 点号.不匹配换行符:默认情况下.不匹配换行符,如果需要匹配要使用s修饰符(单行模式)
  2. 字符类中的元字符不需要转义:在[]中,除了^-]之外,其他元字符都不需要转义
  3. 量词默认是贪婪的:不注意的话会匹配到意料之外的内容
  4. 零宽断言的支持:不是所有正则引擎都支持后顾断言,特别是JavaScript在ES2018之后才支持
  5. 不同语言的正则实现有差异:Python、Java、JavaScript等语言的正则引擎支持的特性略有不同

最佳实践

  1. 正则不是万能的:不要什么都用正则解决,简单的字符串处理用语言内置的字符串方法更高效
  2. 写注释:复杂的正则表达式要写注释,说明每个部分的作用
  3. 测试边界情况:测试正则的时候要覆盖各种边界情况,包括异常输入
  4. 优先使用现成的正则:常用的验证正则(手机号、邮箱等)优先使用经过验证的现成方案,不要自己瞎写
  5. 在线工具调试:可以用regex101.com等在线工具调试正则表达式,可视化地看匹配过程

思考问题

  1. 正则表达式的贪婪匹配和非贪婪匹配有什么区别?分别适合什么场景?
  2. 写一个正则表达式,匹配中国大陆的固定电话号码(格式:010-12345678 或 021-12345678)
  3. 什么是正则的回溯风暴?怎么避免?
  4. 你用正则表达式解决过什么复杂的文本处理问题?