正则表达式实战指南:语法拆解、多语言应用与文本提取案例
正则表达式这东西我第一次认真学它是被一道题逼的。当时要清理一份几万行的用户日志里面混着各种ID、标签、时间戳我需要把中间某段数字和#号后面的状态标识单独抽出来统计。用替换和分割函数硬写写了四十分钟还有一堆边界情况处理不掉最后换成正则十分钟跑完还一个不差。从那以后我就明白凡是跟文本提取、校验、清洗沾边的活儿正则都是一项绕不开的核心技能。这篇内容我打算按自己的理解来讲不讲那些教科书式的空话而是先带你弄清楚正则到底是什么、常见的语法怎么拆解然后分别在 JavaScript、Python、Java、C# 这几种主流语言里演示最常用的玩法最后给出 20 个可以直接抄的常用正则并完整拆解提取中间数字及#符号后的字符串这个实战场景。无论你是刚接触正则的新手还是已经会写一点但经常被奇怪的匹配结果折磨的老手这篇文章都值得从头到尾走一遍。1. 先把概念搞清楚——正则表达式到底是干什么的1.1 用生活中的例子理解正则正则表达式Regular Expression常见缩写为 regex 或 regexp本质上是一套描述字符串长什么样的规则语言。你不用把每个可能的文本都列出来只要告诉程序我只要符合这种形状的内容它就能在一大堆文本里把目标全部找出来。举个例子你想从一段混乱的文本里找出所有手机号。人工翻的话几万字翻下来眼睛直接花掉。但你要是写出1[3-9]\d{9}这个正则程序几秒钟就能把所有以1开头、第二位是3到9、后面跟着9位数字的完整11位号码全部提取出来。这就是正则最核心的价值用很短的规则描述一类文本然后批量处理。我在实际项目里用到正则的场景基本可以归成三类一是校验比如表单里的邮箱、手机号、密码强度判断二是提取从日志、网页源码、接口返回里把关键数字、ID、链接抓出来三是替换清洗比如把文本里的多余空格去掉、把全角数字转成半角、把敏感信息做脱敏处理。这三类几乎覆盖了日常开发里九成以上的字符串处理需求。1.2 一个正则由哪些部分组成正则看起来符号很多容易劝退新人但拆开看核心组件其实就五大类匹配单个字符的元字符与字符类、控制出现次数的量词、用来组合与捕获的分组、用来定位位置的锚点以及控制整体匹配行为修饰符。任何一个复杂的正则都是这几类组件的组合。拿我之前用过的区号匹配来拆个例子\b\d{3}-\d{8}\b。\b是单词边界锚点\d表示任意数字{3}表示前面的数字必须出现3次中间的-是普通字符后面的\d{8}表示再跟8位数字最后\b再收一个边界。整串合起来就是三或四开头模式下的电话号码片段。你不必一开始就认识所有符号只要理解每类组件的职责再复杂的正则也能一步步拆着读。我给所有初学者的建议是拿到陌生正则先按符号切成最小单元把每个单元翻译成一句自然语言再拼起来看整体描述的是什么模式。这种拆解式阅读练上几周你就不会再觉得正则像天书了。2. 正则语法核心拆解——从元字符到量词再到分组2.1 普通字符与元字符最基础的匹配单元正则里最容易理解的是普通字符。你写cat它就匹配文本里连续出现的c、a、t三个字母。但纯粹按字面找文本根本不够因为实际需求总带各种模糊条件于是就有了元字符。元字符就是那些在正则语法里有特殊含义的符号包括. ^ $ * ? { } [ ] \ | ( )这一串。如果你要匹配它们本身的字面意思必须用反斜杠转义比如想匹配一个点号就要写成\.。这个规则在所有语言里基本一致属于正则的地基之一。日常使用频率最高的是这几个预定义字符类\d匹配任意数字等价于[0-9]\w匹配字母、数字、下划线等价于[A-Za-z0-9_]\s匹配空白字符包括空格、Tab、换行。对应的大写形式\D、\W、\S则匹配非数字非单词字符非空白字符。这一组符号在几乎所有语言的正则引擎里都是通用的把它们吃透你就能处理一大半的常见匹配。2.2 量词与贪婪匹配控制匹配的长度单个字符类只能匹配一个字符但真实需求往往是匹配连续的一串。这时候就要靠量词。*表示0个或多个表示1个或多个?表示0个或1个{n}表示恰好n个{n,m}表示n到m个。量词跟在字符类或分组后面控制的是前一个单元重复多少次。这里有一个几乎所有初学者都会踩的大坑贪婪匹配。正则里的*、这类量词默认都是贪婪的它会尽可能地往长了匹配。比如你用.去匹配文本h1标题/h1直觉上你可能觉得会匹配出h1但实际上正则引擎会从第一个一路吃到最后一个匹配出整串h1标题/h1因为中间的.可以匹配任意字符而又贪婪地想要吃更多。解决思路很简单在量词后面再加一个问号改成.?它就会变成懒惰匹配能少匹配就少匹配。懒惰模式和贪婪模式的选择直接决定了你提取结果的范围大小这一点在你写任何提取型正则时都要反复确认。2.3 分组与断言处理复杂结构的进阶工具分组用括号()表示核心作用是把一段正则当成一个整体。最典型的是配合量词使用比如(ab)表示的是ab这个完整片段出现1次或多次而不是只有b重复。带括号的分组还会产生捕获结果方便你后续提取子串或者用\1反向引用前面匹配到的内容。如果你只是想把表达式组织起来但不需要捕获内容可以写成(?:ab)这种非捕获分组既省内存结果列表也更干净。断言是另一类强大但不太好理解的结构。它不消耗字符只是检查当前位置的前后是否符合条件。四个最常用的断言分别是(?...)表示后面必须跟着什么(?!...)表示后面不能跟着什么(?...)表示前面必须是什么(?!...)表示前面不能是什么。举个例子你想在price: 50中提取冒号后的数字直接写\d也能匹配到 50但如果前面还有其他数字quantity: 3, price: 50直接匹配就会把 3 也带进来。这时候用(?price: )\d就能精准地只取price:后面的数字。断言在日志解析、上下文相关的提取场景里非常有用值得你花时间专门练一下。3. 主流语言里的正则实操——JS / Python / Java / C#3.1 JavaScript前端最常用的正则玩法在 JavaScript 里正则有两种创建方式。一种是字面量写法/pattern/在代码加载阶段就完成解析另一种是构造函数new RegExp(pattern)适用于正则模式本身是动态变量的场景比如根据用户配置拼接规则。日常建议优先用字面量性能更好写法也更干净。JS 里常用的正则方法我需要一个个说清楚。regex.test(str)返回布尔值用来判断是否匹配str.match(regex)返回匹配结果数组不带g修饰符时只返回第一个完整匹配以及所有分组带g时返回所有完整匹配但没有分组详情str.matchAll(regex)是 ES2020 之后推荐的方式必须配合g使用返回所有匹配的迭代器且每个匹配都包含分组信息str.replace(regex, replacement)用来替换str.search(regex)返回第一个匹配的位置。还有一个老式的regex.exec(str)可以循环调用逐个提取匹配但在有matchAll的今天我不太推荐新手再走这条路。修 饰符方面g表示全局匹配i表示忽略大小写m让^和$在多行模式下分别匹配每行的开头和结尾s让.能匹配换行符。这四个是日常使用频率最高的。看一个实际提取的例子。假设一行日志是user id123 pending #done你要把 id 后的数字取出来const line user id123 pending #done; const nums [...line.matchAll(/id(\d)/g)].map(m m[1]); console.log(nums); // [123]matchAll配上g以后返回的每个匹配对象都可以用m[1]拿到第一个分组。如果你的正则本身没有分组那m[0]就是完整匹配用起来也很顺手。3.2 Pythonre模块的实用套路Python 的正则都集中在re模块里核心函数不多但每个都值得记清楚。re.match()只从字符串开头匹配开头对不上就直接返回 Nonere.search()在整个字符串中查找第一个匹配re.findall()返回所有匹配的列表如果正则有分组就返回分组元组re.finditer()返回匹配对象的迭代器适合处理大文本时逐条读取re.sub()做替换re.split()按正则切割字符串。写 Python 正则时有一个必须养成的习惯用原始字符串r...。原因是 Python 的普通字符串会把\n、\t这类转义先处理一遍正则里的\d如果不加r可能被字符串层吃掉或者产生完全不同的含义轻则匹配不上重则直接抛出转义错误。我在帮人看代码时见到的 Python 正则 bug 有一大半都是这个原因。看提取数字的代码import re line user id123 pending #done nums [m.group(1) for m in re.finditer(rid(\d), line)] print(nums) # [123]这里re.finditer返回的是一个个 match 对象m.group(1)取第一个分组。如果你想同时拿到多个分组group(1)、group(2)依次取就行group(0)是完整匹配。3.3 Java 与 C#后端场景的差异点Java 的正则在java.util.regex包里。标准流程是先Pattern.compile()编译正则再用matcher(input)拿到 Matcher 对象。之后find()方法会不断寻找下一个匹配group(1)获取第一个捕获组的内容。Java 里还有个容易混淆的matches()方法它要求整个输入字符串完全匹配正则而不是包含匹配。这一点和 JavaScript 的test、Python 的match语义都不一样跨语言写代码时特别容易踩坑。另外 Java 源码里的字符串转义也是个常见痛点。正则里的\d在 Java 源代码里必须写成\\d因为 Java 字符串先处理一层转义正则引擎拿到的才是一个反斜杠加 d。我见过太多新手在 Java 里写\d跑起来直接报非法转义错误。C# 的Regex类用起来更接近脚本语言。Regex.IsMatch()判断是否匹配Regex.Match()取第一个匹配Regex.Matches()取所有匹配Regex.Replace()做替换。C# 还支持命名分组(?name...)在字段多、结构复杂的场景里用名字比用数字编号直观得多。我整理了一个跨语言对照表方便你从一种语言切到另一种语言时快速定位业务动作JavaScriptPythonJavaC#判断是否匹配regex.test(str)re.search()matcher.find()Regex.IsMatch()取第一个匹配str.match(regex)re.search()matcher.group()Regex.Match()取所有匹配str.matchAll(regex)re.findall()/finditer()while(matcher.find())Regex.Matches()替换str.replace()re.sub()matcher.replaceAll()Regex.Replace()说实话各种语言的正则底层引擎大同小异语法兼容度相当高真正麻烦的从来不是语法本身而是我脑子里想匹配的内容到底能用哪种正则写法准确描述出来。这个能力只能靠多写多练来积累。4. 高频实战——20个常用正则与数字和#号提取完整拆解4.1 20个拿来就能用的常用正则速查我把自己日常项目里反复使用的正则整理成了一张速查表。这些写法在 JS、Python、Java、C# 里基本可以直接使用个别受语言差异影响比如 Java 里的转义要多写一个反斜杠使用时要自行换算匹配场景正则模式邮箱地址宽匹配^[\w.%-][\w.-]\.[A-Za-z]{2,}$手机号中国大陆^1[3-9]\d{9}$URL^https?:\/\/[\w\-](\.[\w\-])[/#?]?.*$IPv4 地址^(\d{1,3}\.){3}\d{1,3}$日期 YYYY-MM-DD^\d{4}-(0[1-9]时间 HH:MM:SS^([01]\d用户名 4-16位^[A-Za-z0-9_]{4,16}$密码含英文和数字^(?.*[A-Za-z])(?.*\d)[A-Za-z\d]{8,}$数字可负可小数^-?\d(\.\d)?$十六进制颜色^#?([0-9A-Fa-f]{6}身份证号15或18位^\d{15}$邮政编码^\d{6}$纯英文^[A-Za-z]$中文字符[\u4e00-\u9fa5]HTML标签[^]空白字符\s换行符\r?\n空行\n\s*\rMarkdown链接\[([^\]])\]\(([^)])\)连续数字\d这里要提醒一句这些正则多数是业务宽匹配版本适合校验和提取的一般场景。如果你的业务有硬性要求比如邮箱后缀必须是特定域名或者手机号必须匹配号段白名单那还需要在正则之外再叠加一层业务过滤。网上动不动就号称史上最强百分百准确的正则我建议你保持怀疑上线前一定拿真实业务数据跑几轮测试再决定用不用。4.2 案例实操同时提取中间数字和#符号后的字符串接下来我把一个完整的实战场景从头到尾拆一遍。假设你的应用在记录用户操作时日志生成了类似这样的格式user id2358 status #pub review order id8821 status #draft review item id123 #a #b review现在需求是把每一行id后面的连续数字提取出来同时把第一个#符号后面的字母串提取出来。预期结果是2358 - pub 8821 - draft 123 - a先拆解需求。目标一是id后面的连续数字正则可以写成id(\d)(\d)是要捕获的数字部分。目标二是#后、空格前的字母串可以写成#([A-Za-z])。既然这两块出现在同一行我们可以把它们合并到一个正则里同时捕获写成id(\d).*?#([A-Za-z])中间的.*?是关键。这里我用的是懒惰匹配而不是.*。如果写成.*这种贪婪版本遇到这一行里有多个 # 符号的情况正则引擎会从id123一路吃到最后一个#才停导致第二组捕获到错误的内容。我帮你重现一下现场对第三行item id123 #a #b review贪婪写法会把#b捕获为第二组而懒惰写法只会取到第一个#后的a。这就是提取型正则里最值得警惕的差别。JavaScript 实现如下const logs [ user id2358 status #pub review, order id8821 status #draft review, item id123 #a #b review ]; for (const line of logs) { const m line.match(/id(\d).*?#([A-Za-z])/); if (m) { console.log(数字为: ${m[1]}#符号后为: ${m[2]}); } }运行结果数字为: 2358#符号后为: pub 数字为: 8821#符号后为: draft 数字为: 123#符号后为: aPython 版本几乎等价import re logs [ user id2358 status #pub review, order id8821 status #draft review, item id123 #a #b review, ] pat re.compile(rid(\d).*?#([A-Za-z])) for line in logs: m pat.search(line) if m: print(f数字为: {m.group(1)}#符号后为: {m.group(2)})pat.search会在整行里找第一个符合的位置然后group(1)、group(2)分别取两个捕获组。如果你之后想把所有行的结果统一存成结构化数据用finditer加列表推导式会更方便result [ (m.group(1), m.group(2)) for line in logs for m in [pat.search(line)] if m ] print(result) # [(2358, pub), (8821, draft), (123, a)]这种写法的好处是循环和匹配逻辑集中在一起可读性更好。4.3 高频问题排查与避坑心得正则写多了难免遇到奇奇怪怪的问题。我把这几年来踩过的、也帮别人排查过的典型问题集中列一下每个都是血的教训。转义层级混乱。这是跨语言开发最常见的坑。JS 字面量里\d直接写就行Python 用r...原始字符串就不会被二次转义Java 和 C# 的源码里必须写成\\d因为字符串编译阶段会把\\还原成\正则引擎拿到的才正确。如果你发现某个正则单独在在线工具里能匹配放进项目就报错第一步就该检查是不是转义层数写少了或写多了。贪婪匹配导致提取范围过大。判断方法很简单看提取结果是不是比你预期多了一截。比如你想提取div里的内容结果把/div后面的标签也带出来了那基本就是贪婪量词惹的祸。把对应的*或改成*?或?多半就能修正。写提取类正则时我建议条件反射般地问自己一句我这段的贪婪量词会不会吞掉后面的内容中文和特殊字符匹配不上。\w在绝大多数语言默认只匹配 ASCII 的字母、数字、下划线不覆盖中文。要匹配中文可以用[\u4e00-\u9fa5]这种明确的 Unicode 范围或者在支持的语言里开着 Unicode 模式。另外用户输入里的全角空格、不可见字符肉眼看不出来建议先在数据里用十六进制视图看一眼再写正则否则你会被为什么明明有内容却匹配不到折磨半天。性能与灾难性回溯。正则确实方便但不要为了炫技写太复杂的模式。嵌套量词这类写法比如(a)在遇到特别长的输入且不匹配时可能引发灾难性回溯把程序活活卡死。生产环境的日志处理、用户输入校验都算高并发场景我一般的原则是能用简单正则解决的就不写复杂的一条正则搞不定就拆成两步处理。正则不是越短越好也不是越复杂越好而是刚好能准确覆盖业务边界最好。排查思路我总结成一套固定流程先拿一小段真实文本在本地脚本或在线工具里验证匹配范围和分组输出确认无误后再放进业务代码上线前再用几组边缘数据测试比如空字符串、超长字符串、带特殊字符的字符串。这套流程能帮你躲掉大部分正则导致的线上事故。最后分享一点个人习惯。我见过很多开发者把正则当成查完就忘的工具每次遇到文本处理都从零搜语法效率很低。其实正则更像一门值得系统学一遍的小型语言你只需要集中花一两个晚上把字符类、量词、分组、断言这四个基础模块吃透再加上修饰符和语言层转义的差异就已经能覆盖绝大多数的实战需求了。我到现在写正则也依然保持着先想清楚边界再动手的习惯哪些文本该匹配、哪些文本绝对不能匹配、哪些部分需要单独提取全部想明白之后再写模式。你要是也被正则折磨过不妨按这个思路试试看写出来的表达式大概率比凭感觉拼出来的靠谱得多。