正则表达式入门
正则表达式(Regular Expression,简称 regex 或 regexp)是一种用于匹配字符串模式的强大工具。它本质上是一门专门描述文本规则的微型编程语言,几乎所有现代编程语言都支持正则表达式。
什么是正则表达式
想象你需要在一篇文章中找出所有的邮箱地址,或者验证用户输入的手机号格式是否正确。这些任务如果用手动方式逐个检查,既繁琐又容易出错。正则表达式就是为解决这类问题而生的——它让你用简洁的语法描述文本模式,然后由程序自动完成匹配工作。
正则表达式的核心思想是:用特定的字符组合来描述一类字符串的共同特征。例如,中国手机号码 这个模式可以匹配所有以 1 开头、第二位是 3-9 的数字、总共 11 位的号码。
为什么要学习正则表达式
在实际开发中,正则表达式的应用场景非常广泛:
- 表单验证:邮箱、手机号、身份证号、密码强度等格式校验
- 数据提取:从 HTML、日志文件、API 响应中提取特定信息
- 文本替换:批量替换、格式化、清理数据
- 路由匹配:Web 框架中的 URL 路由规则
- 语法高亮:代码编辑器中的关键字识别
- 搜索引擎:分词、关键词匹配
掌握正则表达式能显著提升你处理文本数据的效率。
第一个正则表达式
让我们从一个简单的例子开始。假设你想判断一个字符串是否包含 "hello":
// JavaScript 示例
/hello/.test("hello world"); // true
/hello/.test("hi there"); // false
# Python 示例
import re
re.search(r"hello", "hello world") # 匹配成功
re.search(r"hello", "hi there") # None
这里的 hello 就是一个正则表达式,它匹配字符串中连续出现的 h-e-l-l-o 这五个字母。这种直接匹配字符的方式称为字面量匹配。
创建正则表达式的方式
不同编程语言创建正则表达式的方式略有不同,但基本思路一致:
JavaScript
// 方式一:正则表达式字面量(推荐)
const regex1 = /abc/gi;
// 方式二:RegExp 构造函数
const regex2 = new RegExp("abc", "gi");
字面量方式在脚本加载时就完成编译,性能更好;构造函数方式适合需要动态构建模式的场景。
Python
import re
# 方式一:使用原始字符串(推荐)
pattern1 = re.compile(r"abc", re.IGNORECASE)
# 方式二:直接使用模块函数
match = re.search(r"abc", "abcdef", re.IGNORECASE)
Python 中推荐使用原始字符串(r"..."),这样可以避免反斜杠的转义问题。
正则表达式的工作原理
理解正则表达式的工作原理有助于你写出更高效的表达式。简单来说,正则引擎会:
- 编译:将正则表达式字符串编译成内部数据结构
- 匹配:从目标字符串的起始位置开始,尝试用模式进行匹配
- 回溯:当某条路径匹配失败时,回退到之前的状态尝试其他可能
- 返回:找到匹配时返回结果,或遍历完所有可能后返回失败
这个过程涉及有限自动机理论,但作为使用者,你只需要知道正则引擎会智能地尝试各种匹配可能,直到找到成功匹配或确定不存在匹配。
特殊字符与转义
正则表达式中有一些字符具有特殊含义,称为元字符:
. ^ $ * + ? { } [ ] \ | ( )
如果你想匹配这些字符本身,需要使用反斜杠进行转义:
// 匹配字符串 "a*b"
/a\*b/.test("a*b"); // true
// 匹配字符串 "price: $10"
/price: \$10/.test("price: $10"); // true
# 匹配字符串 "a.b"
import re
re.search(r"a\.b", "a.b") # 匹配成功
在字符类 [...] 中,大部分元字符会失去特殊含义,变成普通字符。例如 [.*+] 匹配的就是点号、星号或加号本身。
字符类
字符类用方括号 [] 表示,用于匹配方括号内的任意一个字符:
// 匹配元音字母
/[aeiou]/.test("apple"); // true,因为包含 'a'
/[aeiou]/.test("xyz"); // false,没有元音
// 匹配数字
/[0123456789]/.test("abc5def"); // true
范围表示法
连续的字符可以用范围简写:
/[a-z]/ // 所有小写字母
/[A-Z]/ // 所有大写字母
/[0-9]/ // 所有数字
/[a-zA-Z]/ // 所有字母
/[a-zA-Z0-9_]/ // 字母、数字和下划线
否定字符类
在字符类开头使用 ^ 表示否定,匹配不在方括号内的字符:
/[^aeiou]/.test("a"); // false,'a' 是元音
/[^aeiou]/.test("b"); // true,'b' 不是元音
// 匹配非数字字符
/[^0-9]/.test("123abc"); // true,因为有字母
预定义字符类
为了简化常用模式,正则表达式提供了预定义的字符类:
| 字符类 | 含义 | 等价于 |
|---|---|---|
. | 任意字符(除换行符) | [^\n] |
\d | 数字 | [0-9] |
\D | 非数字 | [^0-9] |
\w | 单词字符 | [a-zA-Z0-9_] |
\W | 非单词字符 | [^a-zA-Z0-9_] |
\s | 空白字符 | [ \t\n\r\f\v] |
\S | 非空白字符 | [^ \t\n\r\f\v] |
/\d/.test("5"); // true
/\w/.test("_"); // true
/\s/.test(" "); // true
/\s/.test("\t"); // true(制表符)
// 实际应用:匹配电话号码
/\d{3}-\d{4}-\d{4}/.test("138-1234-5678"); // true
在 Unicode 模式下(JavaScript 的 u 标志,Python 的 re.UNICODE),\w 会匹配更多字符,包括中文、日文等 Unicode 字母。如果需要严格限制为 ASCII 字符,需要显式指定。
量词
量词用于指定前面元素出现的次数,是正则表达式中非常强大的特性:
基本量词
| 量词 | 含义 | 说明 |
|---|---|---|
* | 零次或多次 | {0,} |
+ | 一次或多次 | {1,} |
? | 零次或一次 | {0,1} |
// * 匹配零个或多个 'a'
/ca*t/.test("ct"); // true(零个 a)
/ca*t/.test("cat"); // true(一个 a)
/ca*t/.test("caaat"); // true(三个 a)
// + 匹配一个或多个 'a'
/ca+t/.test("ct"); // false(至少需要一个)
/ca+t/.test("cat"); // true
// ? 匹配零个或一个 'a'
/colou?r/.test("color"); // true(美式拼写)
/colou?r/.test("colour"); // true(英式拼写)
精确量词
| 量词 | 含义 |
|---|---|
{n} | 恰好 n 次 |
{n,} | 至少 n 次 |
{n,m} | n 到 m 次 |
/\d{4}/.test("2024"); // true,恰好 4 位数字
/\d{4}/.test("24"); // false,不够 4 位
/\d{2,}/.test("1"); // false,至少需要 2 位
/\d{2,}/.test("12345"); // true
/\d{2,4}/.test("12"); // true
/\d{2,4}/.test("1234"); // true
/\d{2,4}/.test("12345"); // true(匹配前 4 位)
/\d{2,4}/.test("1"); // false
贪婪与非贪婪匹配
默认情况下,量词是贪婪的——它们会尽可能多地匹配字符。有时这会导致意外的结果:
const html = "<div>内容1</div><div>内容2</div>";
// 贪婪匹配
html.match(/<div>.*<\/div>/);
// 结果: ["<div>内容1</div><div>内容2</div>"]
// 解释: .* 匹配了从第一个 <div> 到最后一个 </div> 的所有内容
如果你只想匹配第一个 <div> 标签内的内容,需要使用非贪婪量词,在量词后加 ?:
// 非贪婪匹配
html.match(/<div>.*?<\/div>/);
// 结果: ["<div>内容1</div>"]
// 解释: .*? 匹配尽可能少的字符,遇到第一个 </div> 就停止
| 贪婪量词 | 非贪婪量词 |
|---|---|
* | *? |
+ | +? |
? | ?? |
{n} | {n}? |
{n,} | {n,}? |
{n,m} | {n,m}? |
// 实际案例:提取引号内的内容
const text = '"hello" and "world"';
// 贪婪(错误)
/text.match(/".*"/); // [""hello" and "world""]
// 非贪婪(正确)
/text.match(/".*?"/); // [""hello""]
位置锚点
锚点用于匹配字符串中的特定位置,而不是具体字符:
| 锚点 | 含义 |
|---|---|
^ | 字符串开头 |
$ | 字符串结尾 |
\b | 单词边界 |
\B | 非单词边界 |
// ^ 匹配开头
/^hello/.test("hello world"); // true
/^hello/.test("say hello"); // false
// $ 匹配结尾
/world$/.test("hello world"); // true
/world$/.test("world peace"); // false
// ^ 和 $ 组合进行全匹配
/^hello$/.test("hello"); // true
/^hello$/.test("hello world"); // false
单词边界
单词边界 \b 匹配单词字符(\w)和非单词字符之间的位置:
/\bcat\b/.test("the cat sat"); // true,cat 是独立单词
/\bcat\b/.test("concatenate"); // false,cat 是单词的一部分
/\bcat\b/.test("category"); // false
// 实际应用:替换单词
"the cat sat".replace(/\bcat\b/g, "dog"); // "the dog sat"
"concatenate".replace(/\bcat\b/g, "dog"); // "concatenate"(不变)
使用 ^ 和 $ 组合可以确保整个字符串符合模式,而不仅仅是字符串中包含模式。这在表单验证时特别有用。
分组与捕获
圆括号 () 用于创建分组,分组有两个主要用途:
1. 捕获分组
分组可以捕获匹配的子字符串,供后续使用:
const date = "2024-03-15";
const regex = /(\d{4})-(\d{2})-(\d{2})/;
const match = date.match(regex);
console.log(match[0]); // "2024-03-15" - 完整匹配
console.log(match[1]); // "2024" - 第一个分组(年)
console.log(match[2]); // "03" - 第二个分组(月)
console.log(match[3]); // "15" - 第三个分组(日)
捕获的分组可以在替换中使用:
// 日期格式转换:2024-03-15 -> 15/03/2024
"2024-03-15".replace(/(\d{4})-(\d{2})-(\d{2})/, "$3/$2/$1");
// 结果: "15/03/2024"
2. 非捕获分组
如果你只需要分组功能而不需要捕获,可以使用 (?:...):
const str = "abc123";
const regex = /(?:abc)(\d+)/;
const match = str.match(regex);
console.log(match[0]); // "abc123"
console.log(match[1]); // "123" - 只有一个分组
// match[2] 不存在,因为 (?:abc) 是非捕获分组
非捕获分组在性能上略优于捕获分组,且使分组编号更简单。
3. 命名分组
现代正则表达式支持为分组命名,使代码更易读:
const date = "2024-03-15";
const regex = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const match = date.match(regex);
console.log(match.groups.year); // "2024"
console.log(match.groups.month); // "03"
console.log(match.groups.day); // "15"
// 命名分组也可以用于替换
"2024-03-15".replace(regex, "$<day>/$<month>/$<year>");
// 结果: "15/03/2024"
命名分组在 JavaScript 2018 (ES9) 和 Python 3.6+ 中得到支持。如果需要兼容旧版本,请使用索引分组。
选择结构
竖线 | 表示"或"的关系,用于匹配多个模式中的任意一个:
/(cat|dog|bird)/.test("I have a cat"); // true
/(cat|dog|bird)/.test("I have a fish"); // false
// 结合分组使用
/(大|小)(红|白)猫/.test("大红猫"); // true
/(大|小)(红|白)猫/.test("小白猫"); // true
选择结构的优先级较低,通常需要配合分组使用:
// 错误:这会匹配 "gray" 或 "grey"
/gr|ay/.test("gray"); // true
/gr|ay/.test("grey"); // true
/gr|ay/.test("ay"); // true(不是我们想要的)
// 正确:匹配 "gray" 或 "grey"
/gr(a|e)y/.test("gray"); // true
/gr(a|e)y/.test("grey"); // true
/gr(a|e)y/.test("ay"); // false
标志(修饰符)
标志用于修改正则表达式的匹配行为:
| 标志 | 含义 | JavaScript | Python |
|---|---|---|---|
i | 忽略大小写 | ✓ | re.I |
g | 全局匹配 | ✓ | - |
m | 多行模式 | ✓ | re.M |
s | dotAll 模式(. 匹配换行符) | ✓ | re.S |
u | Unicode 模式 | ✓ | 默认 |
y | 粘性匹配 | ✓ | - |
// i: 忽略大小写
/hello/i.test("HELLO"); // true
// g: 全局匹配(找到所有匹配,而不是第一个)
"hello hello".match(/hello/g); // ["hello", "hello"]
// m: 多行模式(^ 和 $ 匹配每行开始和结束)
/^hello$/m.test("world\nhello"); // true
// s: dotAll 模式(. 匹配换行符)
/hello.world/s.test("hello\nworld"); // true
import re
# 忽略大小写
re.search(r"hello", "HELLO", re.IGNORECASE)
# 多行模式
re.search(r"^hello$", "world\nhello", re.MULTILINE)
# dotAll 模式
re.search(r"hello.world", "hello\nworld", re.DOTALL)
基础练习
尝试写出匹配以下模式的正则表达式:
- 匹配邮箱地址:包含
@符号,前面是用户名,后面是域名 - 匹配手机号:11 位数字,以 1 开头,第二位是 3-9
- 匹配 IPv4 地址:四个 0-255 的数字,用点号分隔
- 匹配日期格式:YYYY-MM-DD 格式
- 匹配 HTML 标签:匹配
<tag>或</tag>格式
点击查看参考答案
// 1. 简化版邮箱
/^[\w.-]+@[\w.-]+\.\w{2,}$/
// 2. 中国手机号
/^1[3-9]\d{9}$/
// 3. IPv4 地址(简化版)
/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/
// 4. 日期格式
/^\d{4}-\d{2}-\d{2}$/
// 5. HTML 标签
/<\/?[\w]+>/
小结
本章介绍了正则表达式的基础知识:
- 字面量匹配:直接匹配字符
- 字符类:
[]匹配一组字符中的任意一个 - 预定义字符类:
\d、\w、\s等简写形式 - 量词:
*、+、?、{n,m}控制匹配次数 - 贪婪与非贪婪:默认贪婪,加
?变非贪婪 - 锚点:
^、$、\b匹配位置 - 分组:
()用于捕获和分组 - 选择结构:
|表示"或" - 标志:
i、g、m等修改匹配行为
掌握了这些基础概念后,你已经可以写出实用的正则表达式了。下一章我们将学习更高级的断言、反向引用等特性。