
1. 先说点实在的正则表达式(二)要攻克什么很多人学完正则表达式的第一课就放下了觉得字符类、量词、分组、那几个方法我都会了。真正到了项目里才发现写完的匹配不是错位就是失灵要么就是页面卡死。正则表达式(二)要解决的就是这个断层从看得懂到写得出能抗生产的表达式。这一篇我会把非捕获组、零宽断言、replace高阶回调、灾难性回溯这些进阶点全部过一遍并且结合手机号校验、URL验证、字符串包含判断、数据脱敏等常见场景手把手拆给你看。这篇文章适合这样的读者已经知道\d、*、、[]、()是什么但面对密码强度校验怎么写去掉字符串里所有的HTML标签把数字格式化成千分位这类实际需求时还是要翻半天文档。读完这篇你能直接把每一个正则拿到浏览器控制台里跑并且理解它为什么这么写。2. 核心设计思路为什么这些进阶语法值得学2.1 基础语法根本不够用几个绕不过去的真实场景先摆几个我这几年来在项目里反反复复写、每次都要调半天的需求。第一个字符串包含判断。很多新手第一反应是indexOf但如果你要判断这个文本里是否同时包含数字和字母用正则加断言写一行就完了。第二个手机号脱敏。数据库里存的是13812345678接口返回给前端要显示成138****5678用replace加分组引用三秒钟搞定。第三个URL有效性判断。前端拿用户输入的链接去跳转不能直接信得先校验协议、域名、路径这块正则写法非常讲究。你会发现这些需求有一个共性它们都要看清楚文本里面的结构关系不只是找一段相等的字符。这就是正则表达式(二)要讲的所谓进阶语法真正发挥作用的地方。断言能让你表达某个字符后面必须跟着什么但又不消费掉这个位置命名分组让你不用数括号的序号非捕获组则让整体结构更清晰。没有这些能力很多需求要么写不出来要么写出来的正则长到没人敢改。2.2 设计取舍断言、命名分组与普通分组的选择拿普通分组()来说它有两个作用一个是改变优先级另一个是捕获匹配到的内容供后续引用。但很多场景里我们只需要第一个作用这时如果用了普通分组会产生不必要的捕获影响性能还容易数错分组序号。非捕获组(?:)就是干这个用的。比如说要匹配一个连续的单词或者数字但要整体作为一个单元去量词化/(?:ab)/你根本不需要ab被单独捕获。命名分组(?name...)则是ES2018才进标准的东西它的好处是彻底解决第几个分组这种脆弱写法。代码里写match.groups.username比写match[3]直观得多而且改动正则结构时不用担心下标移位。在代码审查的时候我看到别人用命名分组基本就能判断这人写过一段时间生产代码。断言的逻辑更微妙。正则是按位置推进匹配的普通字符匹配会吃掉字符让匹配位置往后走而断言只检查当前位置前后是否符合条件不占用任何字符。“向前断言(?...)”检查右侧“向后断言(?...)”检查左侧。它们最大的价值是让你表达某某之后/之前的那个位置。2.3 本期方案的适用范围与潜在风险我得先泼盆冷水先进语法不等于任何时候都好用。命名分组和向后断言在老旧浏览器里是个大坑尤其国内还要照顾某些古董浏览器的话建议先用Babel或者检查兼容矩阵再上线。而且正则本身可读性很差——写得越高级后人维护的时候越容易崩溃所以必须在表达式旁边写注释或用清晰命名。另外正则表达式不是万能的字符串处理工具。比如解析HTML或者处理大量JSON用正则硬上一定会出事前几年网上各种著名的HTML正则黑洞就是教训。判断一个文本是不是合法URL、一个字符是不是数字这类需求正则很合适但解析出页面所有嵌套标签的层级结构这种需求请直接上DOM解析器。合理选型比炫技重要得多。3. 高级分组与零宽断言从匹配字符到匹配位置3.1 非捕获组与命名捕获组的正确用法非捕获组的用处我在前面提了一嘴这里直接放对比代码。假设我要把2026-05-20这样的日期格式转成2026年05月20日。const dateStr 2026-05-20; // 普通分组为了拿到三个数字段必须捕获 const withCapturing dateStr.replace( /^(\d{4})-(\d{2})-(\d{2})$/, $1年$2月$3日 ); console.log(withCapturing); // 2026年05月20日 // 如果只需要匹配不需要捕获用非捕获组更干净 const withNonCapturing /^(?:\d{4})-(?:\d{2})-(?:\d{2})$/; console.log(withNonCapturing.test(2026-05-20)); // true看到差别了吗第二段正则里(?:...)的出现纯粹是告诉引擎这里是一个整体按我这个结构去匹配但别给我存匹配结果。当你只是验证一个字符串符不符合规则时用非捕获组能省掉无谓的内存分配也让正则一眼看清哪些是需要取用的分组。命名分组让取用的代码可读性直接上一个台阶const datePattern /^(?year\d{4})-(?month\d{2})-(?day\d{2})$/; const match datePattern.exec(2026-05-20); console.log(match.groups.year); // 2026 console.log(match.groups.month); // 05 console.log(match.groups.day); // 20注意一个细节命名分组在replace里的引用写法是$year不是$1。如果项目里大量涉及replace命名分组会让替换逻辑像写业务代码一样清晰。比如把日期顺序改成日/月/年const reordered 2026-05-20.replace( /^(?year\d{4})-(?month\d{2})-(?day\d{2})$/, $day/$month/$year ); console.log(reordered); // 20/05/20263.2 零宽断言向前看与向后看先说向前断言。一个非常经典的需求给一串数字加上千分位分隔符。新手写法是从左往右硬插逗号但那样会错得离谱。正确思路是从右往左每隔三位数字的位置插入一个逗号——这个位置就必须用断言来定位。业界流传的写法是这样的function formatThousands(num) { return String(num).replace(/\B(?(\d{3})(?!\d))/g, ,); } console.log(formatThousands(1234567.89)); // 1,234,567.89注意小数部分不会被误加逗号拆解一下\B匹配非单词边界确保不是字符串的开头(?(\d{3})(?!\d))是向前断言表示这个位置的右侧是一个或多个连续三位数字而且这三位数字的右侧不再是数字。这个正则理解透了千分位、二进制分组、任意按位分组的格式化都通了。向后断言(?...)在某些场景特别爽。比如我要提取一个文本里所有以符号开头的金额数字const text 本月支出1200.5交通89餐饮5600; const amounts text.match(/(?)\d(\.\d)?/g); console.log(amounts); // [1200.5, 89, 5600]这个正则的意思是匹配一个数字串但这个数字串前面的位置必须正好是符号。本身没被当成匹配的一部分我是说匹配结果里不包含。这样一个数组直接就可以拿去求和或渲染省掉了用match拿到1200.5再手动slice掉第一个字符的步骤。负向前瞻(?!...)和负向后瞻(?!...)则用来描述后面不要跟着什么或前面不要出现什么在过滤敏感词、排除特定上下文时非常实用。3.3 断言组合实例一行正则完成密码强度校验密码校验是断言的最佳演示场景。常见的规则是至少8位包含大写字母、小写字母、数字、特殊字符中的至少三种。用普通写法你要写一大堆if。用断言组合一个正则就解决const strongPassword /^(?.*[a-z])(?.*[A-Z])(?.*\d)(?.*[^A-Za-z0-9]).{8,}$/; console.log(strongPassword.test(Abc12345)); // false没有特殊字符 console.log(strongPassword.test(Abc12345)); // true这里的原理值得拆明白(?.*[a-z])检查从当前位置开始一直到任意末尾是否能找到一个小写字母.*把位置推进到任意可能的地方断言只负责检查不消费字符。四个断言的检查范围互相独立最后{8,}才真正消费正则主体。四个断言全部为真整体才算通过。如果规则改成至少包含三种类型就需要枚举四种组合方式或者更优雅一点用计数函数来配合。我一般会在项目里做一层封装把这堆正则抽到常量里加注释说明这是密码强度校验。因为这类正则实在太怪了三个月后不看注释你自己都认不出来。4. 从匹配到改造replace、matchAll与数据脱敏4.1 replace高阶用法回调函数与反向引用replace的第二个参数除了字符串还可以传一个函数。这个函数会拿到整个匹配、分组、偏移量等参数返回什么就替换成什么。这比一堆$1拼接灵活得多尤其在做逻辑复杂的文本替换时。举个例子。某天产品说用户发表的评论里手机号要自动打码保留前三位和后四位。你当然可以用两段replace但一个正则加回调就够了const maskPhone (text) { return text.replace(/(?!\d)(1[3-9]\d{9})(?!\d)/g, (match, phone) { return phone.slice(0, 3) **** phone.slice(7); }); }; console.log(maskPhone(联系我13812345678或者打13812340001)); // 联系我138****5678或者打138****0001注意我用了负向后瞻(?!\d)和负向前瞻(?!\d)是为了避免误匹配一个13位数字中的某一段。这个细节很关键很多数据脱敏事故都出在这里。回调函数里我可以随意对phone做处理再返回打码结果逻辑比字符串替换清晰太多了。再提供一个特别常用的例子把文本里的日期从2026-05-20替换为2026年5月20日并且去掉前导零。你需要对每个分组分别处理这时回调函数几乎就是唯一选择const normalizeDate 2026-05-20.replace( /^(\d{4})-0?(\d{1,2})-0?(\d{1,2})$/, (_, year, month, day) ${year}年${Number(month)}月${Number(day)}日 ); console.log(normalizeDate); // 2026年5月20日4.2 高频场景速写URL验证与字符串包含再来看网络热词里大家都关心的问题。第一个js判断字符串是否包含。如果只判断包含某个固定字符确实用includes就行。但业务里经常是包含任意数字包含邮箱格式这种就必须用正则的testconst hasDigit /\d/.test(abc123); // true const hasEmailLike /[\w.-][\w-]\.[\w.]/.test(userexample.com); // truetest方法返回布尔值语义清晰性能也优秀是判断是否存在匹配的首选。如果只是判断一个字符串是否包含另一个字符串别滥用正则直接includes更高效这个判断我平时写代码也很注意。第二个高频场景URL有效性验证。这个正则写法非常多但生产环境我建议分级做。先做协议和粗格式校验再做域名和路径的加强校验最后再发一个真实请求去判断能否访问。以下是我常用的基础校验函数function isValidUrl(str) { const pattern /^https?:\/\/[\w\-](\.[\w\-])([\w\-.,?^%:/~#]*[\w\-?^%/~#])?$/; return pattern.test(str); } console.log(isValidUrl(https://example.com/path?name1)); // true console.log(isValidUrl(ftp://example.com)); // false console.log(isValidUrl(example.com)); // false少了协议这里的思路是先把http://或https://限定死再要求至少出现一段域名和点号后面允许路径、查询参数和锚点但结尾不要以特殊符号结束。严格说URL的完整RFC规范极其复杂那个规范要是真写成正则几千个字符都不一定收得住所以生产环境取一个平衡即可。4.3 matchAll与全局匹配遍历过去用match(/.../g)拿到的是一组匹配字符串但当你需要同时拿到每个匹配的分组信息时就得用exec循环。现在String.prototype.matchAll已经是标准方法直接返回一个迭代器每个元素都是完整的匹配结果对象优雅得多。const text 订单号A20260501联系人B20260502备注无; const orderPattern /([A-Z])(\d{8})/g; for (const match of text.matchAll(orderPattern)) { console.log(字母: ${match[1]}, 数字: ${match[2]}); } // 字母: A, 数字: 20260501 // 字母: B, 数字: 20260502matchAll有个隐性好处迭代器是惰性的文本很大的时候不会一次性把所有匹配结果都塞进数组内存占用更友好。如果你用的是老环境可以先用Array.from(text.matchAll(pattern))把它转成标准数组再走普通的数组方法。这东西在很多代码库升级之后已经变成了标配建议尽早用起来。5. 性能与陷阱正则在生产环境里的真实面貌5.1 灾难性回溯页面卡死的元凶正则引擎在失败匹配时要回溯尝试所有可能的路径如果表达式写得不好可能产生指数级的回溯量直接让页面假死。经典的反例是嵌套量词。比如/(a)$/去匹配一个不含a的长字符串引擎要尝试的次数会爆炸。我踩过最深刻的一个坑是把一段大日志用正则/^.*error.*$/逐行去匹配排查问题日志文件几千行跑下来整个页面直接没响应。后来定位到原因是.*过多加上引擎在找不到error时疯狂回溯。解决方式是重写为正则更限定范围或者干脆按行切分再用indexOf过滤。保证正则性能的几个实用原则避免同一个表达式里叠多个可选的量词比如(.*)*尽量少用.*来跨越长距离改用更具体的字符类使用非捕获组用原子级方法锁定边界比如在已知边界的地方用^和$锚定。大厂代码里还常见一种做法是给复杂正则加超时限制用AbortController或Promise.race包一层让失控的正则在几百毫秒内被主动终止。5.2 正则编译与缓存别再重复造轮子new RegExp()和字面量/.../的区别值得多说一句。字面量在代码加载时只编译一次而new RegExp()每次执行都会重新编译消耗更大。如果一个正则要在一个高频循环里跑一万次绝对不要写在循环体内部。正确做法是把正则提升到模块作用域或者在函数外部缓存成常量。// 不推荐每次 check 都在编译 function check(text) { return new RegExp(^1[3-9]\\d{9}$).test(text); } // 推荐正则常量化 const PHONE_PATTERN /^1[3-9]\d{9}$/; function check(text) { return PHONE_PATTERN.test(text); }另一个隐藏的坑是test和exec在带g标志时会修改正则对象的lastIndex。同一个带g的正则对象反复test可能第一次返回true第二次返回false因为匹配位置被推进到末尾了。排查工作里见到过好几个人栽在这上面。解决办法是使用不带g的正则或者每次调用前手动重置lastIndex 0。5.3 调试正则的三个实用手段正则写错不可怕可怕的是不知道错在哪。我的调试三板斧第一用在线工具可视化匹配过程看每一步引擎怎么移动位置第二把正则拆成小块逐段测试确认每一段单独都能匹配预期第三写一个最小的复现用例把文本缩短到十几二十个字符缩小排查范围。浏览器控制台里最简单的方式是直接console.log一个正则的source属性确认你有没有被转义弄晕。比如\d在字符串里得写成\\d这个坑是新人高频事故。我建议在项目里统一用正则字面量少用字符串拼正则可以避开至少一半的转义问题。6. 常见问题与排查技巧实录我在不同项目里反复遇到的正则相关问题和对应的解决思路整理成一张速查表方便你直接照着排查。常见问题特征排查思路test 第二次返回 false带 g 标志的正则对象复用了 lastIndex去掉 g 或用 match 方法手机号校验漏掉 199、166 等号段正则写死 3、5、8 开头改为1[3-9]\d{9}覆盖新号段replace 只替换了第一处忘了加全局标志 g确认是否写成了/.../而不是/.../g匹配 HTML 标签却把内容也吞了贪婪量词导致跨越多个标签用非贪婪写法/[^]/g数字千分位加在错误位置没考虑小数部分用\B(?(\d{3})(?!\d))并在整数部分应用特殊字符没转义导致匹配错误(、.、等当成语法了特殊字符前面加\或统一用\转义大量文本匹配卡死多层嵌套量词引起灾难性回溯减少.*、拆分成多个小正则、加超时提供两个查错时极其好用的土办法。第一把目标文本和正则在浏览器控制台里输给match看返回的数组里有没有你预期的那一段。第二对拿不准的空白字符直接输出它的charCodeAt()再回到正则里用\s还是空格来匹配验证结果一目了然。正则这东西一旦你用拆开验证、最小复现、定位推进的思路去处理多数疑难杂症都难不住人了。最后再分享一个小技巧给正则加注释直接用//写不了但可以在正则字面量的末尾用x扩展模式或者在正式代码上面用普通注释解释意图。我常常写这样的结构// 匹配中国内地手机号1开头第二位3-9后面9位数字 // 同时使用负向断言防止长数字被截断误判 const CN_MOBILE /(?!\d)1[3-9]\d{9}(?!\d)/g;这种注释也许看起来多余但确实能救几个月之后的自己。只要记住正则本身只是工具真正重要的是你能不能在复杂文本里理清结构、用对语法、避开性能地雷。希望这篇正则表达式(二)能帮你把这块硬骨头啃下来下次再看到正则不是皱眉而是心平气和地拆开细看。