D
开发工具箱

正则表达式实战:从入门到不再恐惧

正则表达式 2026年6月15日 约 1 分钟阅读

什么是正则表达式?

正则表达式(Regular Expression,简称 regex)本质上是一种用字符描述字符模式的语言。它不关心你处理的数据是什么,只关心数据里有没有符合某个”形状”的片段。

我第一次正经学正则是在大学做爬虫作业的时候。需求很简单——从一堆 HTML 里把所有邮箱地址抠出来。我当时写了大概五十行 Python,用 find()split()、循环嵌套循环,代码丑得自己都不想看第二遍。室友看了一眼,敲了一行 [a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,},跑完结果一模一样。那一刻的感觉怎么说呢——像是用手锯锯了一天的木头,别人掏出电锯三十秒搞定。

说白了,正则就是字符串处理的”电锯”。用好了效率惊人,用不好伤到自己也是常有的事。

不过话说回来,正则最让人头疼的地方不在于学不会,而在于写完就忘。一个正则表达式你花半小时调通了,两周后回头再看——这堆斜杠和括号是什么意思来着?所以这篇文章不只是教语法,还会聊怎么让正则可维护、可调试。

正则表达式的工作原理

正则引擎底层做的事其实不复杂:从左到右,一个字符一个字符地在目标文本里找匹配。但关键在于”怎么找”——这涉及正则引擎的两种流派。

DFA 与 NFA:两种引擎

  • DFA(确定性有限自动机):引擎每个状态下只有一条确定的路径。速度快,只返回一个匹配结果,不支持捕获组和回溯。grepawk 用的就是这种。
  • NFA(非确定性有限自动机):引擎在一个状态下可能有多条路径可走,它会尝试其中一条,不行就退回来换另一条。支持捕获组、反向引用、环视等高级特性。几乎所有现代编程语言的正则引擎都是 NFA。

NFA 的”退回来换另一条”就是回溯。回溯是正则世界里一切灾难的源头,后面会细说。

正则引擎的骨架:元字符

把正则比作乐高的话,元字符就是那些基础积木块。下面是几个最核心的,别的都是从它们组合出来的:

元字符含义例子
.匹配任意单个字符(换行除外)h.t 匹配 “hat”、“hit”、“h3t”
*前面的东西出现 0 次或多次ab*c 匹配 “ac”、“abc”、“abbbc”
+前面的东西出现 1 次或多次ab+c 匹配 “abc”、“abbbc”,不匹配 “ac”
?前面的东西出现 0 次或 1 次colou?r 匹配 “color” 和 “colour”
|或者cat|dog 匹配 “cat” 或 “dog”
()分组 + 捕获(ab)+ 匹配 “ab”、“abab”、“ababab”
[]字符类,匹配括号里的任意一个字符[aeiou] 匹配任意一个元音字母
[^]否定字符类[^0-9] 匹配任意一个非数字字符
\d数字,等价 [0-9]\d{3} 匹配三位数字
\w单词字符,等价 [a-zA-Z0-9_]\w+ 匹配一个单词
\s空白字符(空格、制表符、换行等)a\sb 匹配 “a b”

量词 {n,m} 是通用形式:{n} 精确 n 次,{n,} 至少 n 次,{n,m} n 到 m 次。*+? 本质上是 {0,}{1,}{0,1} 的语法糖。

贪婪匹配 vs 懒惰匹配

这是正则初学者最容易掉进去的坑。看这个例子:

待匹配文本:<div>hello</div><div>world</div>
正则 A:<.*>
正则 B:<.*?>

正则 A(贪婪)会匹配整个字符串——.* 能吞多少吞多少,一直吃到最后一个 >。正则 B(懒惰)只匹配 <div>——.*? 每次只吞一个字符然后检查后面的 > 到了没有。

默认为贪婪。在量词后面加个 ? 就变成了懒惰模式。还有第三种叫”独占模式”(量词后面加 +,如 .*+),匹配时绝不回溯,但只有 Java、PCRE 等少数引擎支持。

老实说,我刚学正则那会儿,起码有十次被贪婪匹配坑过。每次都以为 .* 会在合适的地方停下来,结果它一路把整行都吞了。后来养成习惯:写 .* 之前先想三秒——这里到底该贪婪还是懒惰。

环视:不消费字符的断言

环视(lookaround)是正则里最”反直觉”但也是最强大的功能之一。它匹配的是一个位置,而不是字符本身。

  • 正向先行断言 (?=pattern):当前位置后面必须是 pattern
  • 负向先行断言 (?!pattern):当前位置后面不能是 pattern
  • 正向后行断言 (?<=pattern):当前位置前面必须是 pattern
  • 负向后行断言 (?<!pattern):当前位置前面不能是 pattern

举个例子:要找字符串里所有在美元符号后面的数字,但不包括美元符号本身——(?<=\$)\d+\$ 前面的 (?<=...) 检查这个位置前面有没有美元符号,有的话才让 \d+ 匹配数字。美元符号本身不参与匹配结果。

环视最常见的实战场景是密码复杂度校验。比如”必须同时包含大小写字母和数字,且长度 8-20”:

^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,20}$

^$ 分别是字符串开头和结尾的锚点。三个 (?=...) 并列在 ^ 后面,表示”开头的后面、同时、分别、存在一个小写字母、一个大写字母、一个数字”。注意它们消费的字符为零——检查完之后指针还在开头,最后由 .{8,20} 匹配整段。

捕获组与反向引用

括号除了分组优先级之外,还会捕获匹配到的内容。捕获组按左括号的位置从 1 开始编号,可以之后用 \1\2 反向引用。

最常见的例子是找重复单词:

\b(\w+)\s+\1\b

\b 是单词边界,(\w+) 捕获一个单词,\s+ 跳过空白,\1 引用第一个捕获组的内容。所以 “the the” 和 “hello hello” 能被匹配,但 “the hello” 不能。

非捕获组用 (?:pattern),它能分组但不捕获,省内存、效率高。如果你不需要反向引用,一律用非捕获组是个好习惯。

核心特性

特性说明
声明式语法用模式描述文本形状,而非过程式地 step-by-step 查找
引擎流派NFA(功能丰富,有回溯)vs DFA(快速,无捕获)
贪婪默认*+{} 默认贪婪匹配,加 ? 变懒惰
零宽断言环视匹配位置而非字符,不消费输入
捕获组() 记录匹配子串,支持反向引用和替换操作
回溯陷阱NFA 引擎在复杂模式上可能产生指数级回溯,导致 ReDoS

实际应用场景

1. 表单验证

前端表单校验是正则最高频的场景。邮箱、手机号、身份证号……用户输入的合法性检查几乎都在靠正则。一个常用的邮箱校验:

^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$

不过要说一句,这个正则只检查格式,并不验证邮箱是否真实存在。验证邮箱唯一可靠的方式是发一封验证邮件。正则管的是”看起来对不对”,不是”存不存在”。

2. 日志分析和数据提取

Apache/Nginx 日志、应用日志、CSV 数据——这些半结构化文本用正则提取字段简直是天生一对。

有一次线上出了个诡异的 500 报错,我需要从上万行 Nginx 日志里把所有返回码是 500 且请求时间超过 3 秒的 URL 筛出来。用 grep 配合正则:

" 500 .* [3-9]\.[0-9]+$|" 500 .* [1-9][0-9]+\.[0-9]+$

这个正则虽然写得不优雅,但它让问题定位从”翻半天日志”变成了”秒出结果”。

3. 代码搜索与重构

IDE 的”在文件中查找”几乎都支持正则。用正则进行跨文件重命名、找特定模式的代码、替换 API 调用——比手动一个个改快不知道多少倍。

举个例子,把项目里所有 var 声明换成 let(前提是确认了兼容性):

查找:\bvar\s+(\w+)\s*=
替换:let $1 =

4. URL 路由匹配

后端框架(Express、Django、Flask)和前端路由(React Router、Vue Router)的路径匹配底层几乎都依赖正则。你写的 /user/:id 在框架内部会被编译成类似 \/user\/([^\/]+) 的正则。

了解这一点之后,做路由排错时多了一个手段——直接看框架生成的正则长什么样,有时候能秒破”为什么这个 URL 没匹配上”的玄学问题。

5. 数据清洗与 ETL

从一个奇怪的格式里把数据抠出来、清洗掉特殊字符、统一电话号码或日期格式——这类一次性的数据清洗任务,写脚本的时候正则的参与度极高。

坦白讲,我写过的数据清洗脚本里,十行有七行都带正则。虽然有人说什么”正则可读性差”,但比起为每一种奇形怪状的格式单独写解析逻辑,正则加几行注释是效率最高的选择。

常见误区

误区一:正则能解析 HTML

这是一个流传了十几年的经典笑话,经典到 StackOverflow 上有一个广为流传的回复专门用了一大段夸张的措辞来解释为什么不能用正则解析 HTML/Zalgo。

原因是 HTML 是上下文无关文法(甚至严格来说是上下文有关),而正则只能处理正则文法。嵌套标签、属性引号内的 >、CDATA——随便一样就能让你的正则失效。解析 HTML 请用专门的解析器(如 Python 的 BeautifulSoup、Java 的 Jsoup、浏览器里的 DOMParser)。

误区二:正则写得越长越精确越好

一个二十行的正则确实可能精确匹配目标,但也几乎不可维护。写正则跟写代码一样需要可读性。方法是把复杂的正则拆开,用变量或注释拼接。

比如说 JavaScript 里可以用 RegExp 构造函数配合变量:

const username = '[a-zA-Z0-9._%+-]+';
const domain = '[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}';
const emailRegex = new RegExp(`^${username}@${domain}$`);

也可以开启正则的”自由空格”模式(x 标志),在模式里加注释。关键原则是:写出来的正则,三周后的自己能看懂

误区三:. 匹配一切

. 默认不匹配换行符 \n。很多人写 .* 然后纳闷为什么跨不了行。要让 . 匹配换行,需要:

  • JavaScript:没有原生单行标志让 . 匹配 \n,传统做法用 [\s\S]*[^]* 来匹配任意字符(包括换行)
  • Python:re.DOTALL 标志
  • PCRE/Java/PHP:s 标志(dotall/single-line mode)

[\s\S] 是一个经典 trick——\s 是空白(含换行),\S 是非空白,两个合起来就是所有字符。

误区四:找到了匹配就说明正则对了

正则引擎找到的是第一个可能的匹配,是不是正确的那个匹配则完全取决于你的模式写得准不准。

我遇到过最坑的一个例子:写了一个正则抓 JSON 里的某个字段值,本地测试数据跑得完美。上了生产才发现,当 JSON 里嵌套了同名 key 或者字符串里有转义引号的时候,正则抓到的内容完全错位。教训是——测试数据必须包含边界情况。一个正则跑通了一百条正常数据,不如跑通一条刻意构造的恶意数据。

灾难性回溯与 ReDoS

如果说正则有一个话题必须单独拿出来讲,那就是灾难性回溯(Catastrophic Backtracking)。

它长什么样

看一个经典的反面教材:(a+)+b。用这个正则去匹配 “aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaac”(30 个 a 后面跟一个 c)。

引擎的思路是这样的:第一组 a+ 匹配了 30 个 a,第一组 + 让它尝试匹配多次,同时第二组 a+ 又要分走一些 a……引擎在”把 a 分给第一组还是第二组”之间疯狂回溯,路径数以组合爆炸的方式增长。加了 1 个 a,回溯量可能翻倍;加到 30 个 a,引擎直接卡死。

现实世界的 ReDoS 攻击案例

2019 年 Cloudflare 出了一次全球宕机,根因就是一条 WAF 规则里的正则引发了灾难性回溯。那是一个很长的手写正则,用来检测某种攻击模式。当请求里的某个字段符合该模式的前半段但后半段不匹配时,引擎陷入了无穷无尽的回溯循环,CPU 直接被打满。

2016 年 Node.js 的 string-kit 包也爆出过 ReDoS 漏洞,某个看似无害的正则在特定输入下跑了几十秒。攻击者只需要构造一个几十字节的字符串发给你,就能让你的服务器 CPU 跑满——不需要 DDoS 僵尸网络,一个 HTTP 请求就够了。

这种攻击叫 ReDoS(Regex Denial of Service)。它不是靠流量打垮你,而是用一段精心构造的输入触发你的正则回溯爆炸。防御手段包括:

  • 避免量词嵌套(如 (a+)+(a*)*
  • 量词后面的模式不要和量词里的模式有交集(如 \d+,\d+ 不如改成 \d+,\d+ 是没有问题的,但 \d+\d 就有问题——\d+ 吃完了后面的 \d 没东西吃,回溯重新分配一个数字给第二个 \d
  • 使用原子组((?>...))或占有量词(++*+)阻止回溯(如果引擎支持)
  • 对用户输入的匹配设置超时(某些语言的 regex API 支持 timeout 参数)
  • 用专门的 ReDoS 检测工具扫描代码里的正则

常用正则模式速查

以下是经过验证的常用正则,可以直接拿去用,但别忘了根据实际情况微调。

URL

https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)

中国大陆手机号

^1[3-9]\d{9}$

IPv4 地址

^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$

日期(YYYY-MM-DD,简单校验)

^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$

十六进制颜色值

^#?([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$

正则 vs 替代方案

正则字符串方法(split/replace等)专用解析器AI/LLM 提取
适用场景模式匹配、模糊搜索固定分隔符、精确查找结构化语言(HTML/JSON/SQL)非结构化自然语言
性能中等(取决于复杂度)高(但解析开销大)极低(API 延迟)
精确度高(如果写对了)精确匹配完美(符合语法规范)不稳定
可维护性差(默认不加注释)取决于 prompt
学习曲线陡峭平缓中等

说白了,正则不是银弹。固定格式的字符串拆分直接用 split 比写正则快。HTML 解析交给 DOMParser。但对于那些”类似某种模式但又不会一模一样”的场景——日志、用户输入、配置文件——正则仍然无可替代。

常见问题

Q: 学正则到底要多久?

一周能写,一月能调,一年能写出生产级安全的正则。核心不在记语法(语法随时查),而在于训练对回溯的直觉。这个直觉只能在调 bug 的过程中慢慢积累。

Q: 最常用的正则标志有哪些?

  • i:忽略大小写(case-insensitive)
  • g:全局匹配(找所有匹配,而非停在第一个)
  • m:多行模式(^$ 匹配每行的行头和行尾,而非整个字符串的头尾)
  • s:单行模式(. 匹配换行符)
  • u:Unicode 模式(正确匹配 Unicode 字符,尤其是 emoji 和 CJK 字符)

不同语言支持程度不同,用之前查文档。

Q: 怎么写一个同时包含字母和数字的正则?

用环视:^(?=.*[a-zA-Z])(?=.*\d).+$。开头两个正向先行断言分别在字符串里查找字母和数字的存在,确认后 . 再匹配整体。

Q: 正则里怎么匹配特殊字符本身?

用反斜杠转义。需要转义的字符有:. * + ? ^ $ { } [ ] ( ) | \ /。在字符类 [] 内部,规则稍有不同——大部分元字符失去特殊含义,但 ]\^(在开头时)、-(在中间时)仍需要转义。

Q: 正则匹配中文怎么做?

Unicode 模式下用 \p{Script=Han}(PCRE/Java/Ruby)或 \p{Unified_Ideograph}(ECMAScript 2018+)。老派做法是用 Unicode 范围:[一-鿿] 覆盖 CJK 统一表意文字基本块。不过这个范围不包含扩展区和生僻字。严肃的中文匹配建议用 Unicode property。

Q: 如何避免在生产中引入 ReDoS?

三条铁律:不写量词嵌套的正则;对用户提供的输入跑正则时加超时限制(JavaScript 目前没有原生 timeout,但可以用 worker_threads 或第三方库模拟);所有涉及用户输入的正则在 Code Review 时当作安全代码来审。