写正则写多了难免会碰到几个让我破防的瞬间。第一次在项目里校验手机号随手写了/^\d{11}$/当时觉得逻辑很完整——11位数字全匹配多简单。直到测试同事拿着12345678901这种号码也顺利通过校验再到产品经理甩过来一句要支持86开头的13位号码我才意识到自己从始至终没有认真对待过正则表达式。后来每次遇到javascript运行时报错、正则把页面卡死、或者一个表达式在不同浏览器里表现不一致我都得重新翻一遍语法折腾得够呛。这篇文章没有打算写成一部正则百科全书而是想围绕JavaScript正则表达式把日常项目里真正逃不掉的那部分梳理清楚手机号这类高频校验怎么写才严谨、标点符号到底怎么匹配、RegExp对象方法和字符串方法怎么配合、正则导致的性能问题怎么排查、以及运行时报错怎么反推回来修。适合刚接触正则的初学者也适合写了很多年、但每次用正则都靠临时搜的熟练工。1. 先搞清楚正则表达式在JavaScript里到底解决什么问题1.1 三个核心动作匹配、提取、替换正则表达式说穿了就是一个模式匹配引擎你给它一套规则它在字符串里去完成三件事判断字符串是否符合规则、从字符串里把符合规则的部分捞出来、把符合规则的部分替换成新的内容。举个例子一段用户输入的内容const input 联系方式13800138000备用13912345678;想判断里面有没有手机号用test()做匹配const hasPhone /1[3-9]\d{9}/.test(input); console.log(hasPhone); // true想把所有手机号捞出来用match()配合全局标志做提取const phones input.match(/1[3-9]\d{9}/g); console.log(phones); // [13800138000, 13912345678]想脱敏处理用replace()做替换const masked input.replace(/1[3-9]\d{9}/g, ****);就这么三个动作覆盖了正则九成以上的使用场景。很多人在这一层没想清楚拿到需求直接就开写结果一个表达式又要校验又要提取又要替换最后写出来又臭又长还容易出bug。我的建议很简单动手之前先问自己一句这次到底是匹配、提取还是替换。目的定了再用对应的API思路一下就清晰了。1.2 JavaScript正则和Python正则的差异别踩混了python正则表达式这个词反复出现在搜索热词里说明不少人是在不同语言之间来回切换的。JavaScript正则和Python正则表面上差不多但细节上有几处必须注意不然换个语言就翻车。Python的re模块默认按行处理^和$匹配的是整个字符串的开头和结尾除非使用re.MULTILINE标志。JavaScript的正则默认同样如此但JavaScript里没有re.DOTALL那种名字对应的叫s标志dotAll让.匹配换行符。更常见的一个坑是命名分组的写法。Python里是(?Pname...)JavaScript里是(?name...)少了一个P。如果拿着Python的习惯直接搬到JavaScript控制台立刻给你报Invalid regular expression。// JavaScript 命名分组 const re /(?year\d{4})-(?month\d{2})-(?day\d{2})/; const match re.exec(2025-06-01); console.log(match.groups.year); // 2025还有一点Python的re.match()是从字符串开头匹配而JavaScript没有对应的函数只有test()和exec()并且exec()不会自动锚定到开头。如果你需要从开头匹配的语义必须自己加上^。这类语言差异说大不大但混着用的时候最容易栽跟头。1.3 字面量和RegExp构造函数不只是书写习惯的区别创建正则有两种方式// 字面量 const regex1 /\d/g; // 构造函数 const regex2 new RegExp(\\d, g);字面量写法更简洁也是大多数人的首选。但构造函数有一个字面量给不了的能力正则表达式是动态拼出来的。比如用户在前端输入了一个过滤关键词你需要在后端返回的数据里高亮它关键词是变量正则就必须用构造函数创建const keyword userInput.trim(); const highlightRegex new RegExp((${escapeRegExp(keyword)}), g);这里有一个老手才会在意的细节用户输入的内容里可能自带正则的特殊字符比如(、[、*直接拼进去会让正则行为完全失控。所以动态构造正则时必须先对输入做转义处理function escapeRegExp(str) { return str.replace(/[.*?^${}()|[\]\\]/g, \\$); }这一行转义正则我记了很多年每次动态正则前都会贴上去杜绝了大部分由用户输入引发的运行时报错。2. 高频语法地图日常90%的需求只需要掌握这些2.1 字符与量词从一个字符到一串字符正则的最基础单位是字符。普通字符匹配自己比如/abc/匹配字符串里的abc元字符则拥有特殊含义比如\d匹配数字、\w匹配字母数字下划线、\s匹配空白符。量词解决的是多少个的问题。*表示0个或多个表示1个或多个?表示0个或1个{n}表示恰好n个{n,}表示至少n个{n,m}表示n到m个。日常最容易出错的组合是.*?和.?。这两个看起来差不多实际上一个是任意字符的0个或多个懒惰模式一个是任意字符的1个或多个懒惰模式。在处理HTML标签或者JSON片段时两者的语义差别直接影响提取结果。举个真实场景要从一段HTML里提取所有title标签的内容const html title首页/titletitle关于我们/title; const titles html.match(/title(.*?)\/title/g); console.log(titles); // 匹配到两个title标签这里必须用.*?的懒惰模式让匹配在遇到第一个/title就停下来。如果写成.*贪婪模式会一口气吞到最后一个/title结果一个表达式匹配了整个字符串。这个贪婪vs懒惰的问题值得单独拿出来说。2.2 贪婪与懒惰量词藏着的性能分水岭先看一个对比const str b加粗/b和i斜体/i; // 贪婪 console.log(str.match(/.*/)); // 匹配到 b加粗/b和i斜体/i // 懒惰 console.log(str.match(/.*?/)); // 匹配到 b贪婪模式下*会尽可能多地吞字符直到最后发现不满足条件才逐步回退懒惰模式相反有一个满足条件就算数立刻停下。行为差异直接影响提取结果。性能问题也出在这里。一个处理不当的贪婪量词嵌套可能引发灾难性回溯catastrophic backtracking让正则匹配从微秒级直接飙升到秒级甚至让页面卡死。这类问题放在后面性能章节细说这里先记住一个原则能明确边界就用懒惰模式尤其是在处理HTML、JSON这类结构复杂的长字符串时。2.3 分组捕获与反向引用括号不只是为了分组括号在正则里有两个作用一是分组把多个字符绑定成一个整体二是捕获把匹配到的内容单独存下来供后续使用。比如匹配重复单词const re /\b(\w)\s\1\b/; console.log(re.test(hello hello)); // true console.log(re.test(hello world)); // false\1就是反向引用引用的是第一个分组捕获到的内容。这在处理重复词检测成对标签匹配时非常有用。提取数据的时候命名分组比数组下标直观得多const dateRegex /(?year\d{4})-(?month\d{2})-(?day\d{2})/; const matcher dateRegex.exec(2025-06-01); if (matcher) { console.log(matcher.groups.year); // 2025 console.log(matcher.groups.month); // 06 }命名分组还有一个好处重构正则时分组的顺序调整了match.groups.xxx的引用方式不受影响。用数组下标match[1]的话一调整顺序就得连带改代码。如果只是想分组、不想捕获用(?:...)。这个非捕获组的写法在性能上有微小优势更重要的是语义清晰告诉后来的人这里只是为了把规则组合起来。2.4 断言与边界不消费字符也能做判断断言是正则里很特别的一个存在。它不消费字符只判断当前位置是否符合某个条件。常用的有四个(?pattern) // 正向先行断言后面必须跟什么 (?!pattern) // 负向先行断言后面不能跟什么 (?pattern) // 正向后行断言前面必须是什么 (?!pattern) // 负向后行断言前面不能是什么举个例子提取价格数字但只提取人民币符号¥后面的数字const prices 价格¥199美元价$99; const cny prices.match(/(?¥)\d/g); console.log(cny); // [199]这个(?...)后行断言在以前的JavaScript版本里兼容性不好但现在主流浏览器都已经支持了可以放心用。还有两个经常被忽略的边界符\b匹配单词边界^和$匹配字符串开头和结尾。记住一个关键区别^和$默认匹配的是整个字符串的首尾不是每一行的首尾。要实现按行匹配得加上mmultiline标志。3. 实战拆解从13位手机号到标点符号处理3.1 13位数字手机号码正则表达式怎么写的完整推导搜索热词里有一条很典型13位数字手机号码正则表达式怎么写。先说结论如果你在中国大陆做移动端表单手机号通常指11位号码第一位是1第二位是3-9。但如果你要兼容86或0086前缀那么整个号码串就会变成13位或更多。先写11位的基础版本const phoneRegex /^1[3-9]\d{9}$/;拆开解释^和$锚定字符串的首尾强制整串匹配防止AAAA13800138000这种字符串蒙混过关。1第一位必须是1。[3-9]第二位是3到9之间的任意一个数字。早期手机号第二位只有3、5、8后来放开了4、6、7、9所以现在用3-9覆盖最稳妥。\d{9}后面跟9位数字。1 1 9 11位。如果要兼容86和0086前缀const phoneWithPrefixRegex /^(?:\?86|0086)?1[3-9]\d{9}$/;这里(?:\?86|0086)?是可选的非捕获分组86、86、0086三种写法都覆盖了。整体限制后纯数字部分是13位当使用86前缀时。实际项目中我还会配合replace先把用户输入里的空格、横线去掉再校验因为用户总是习惯写成138 0013 8000或者138-0013-8000function validatePhone(input) { const cleaned input.replace(/[\s-]/g, ); return /^(?:\?86|0086)?1[3-9]\d{9}$/.test(cleaned); }这个[\s-]字符类就是匹配任意空白符或者短横线的意思也是处理用户输入时最高频的清洗手段之一。3.2 标点符号到底怎么匹配正则表达式代表标点符号是什么这个问题也上了热搜。很多人的第一反应是用[。]手动罗列遇到英文标点再补一组。这在业务逻辑简单的场景下能用但一旦需要覆盖全场景就出问题。JavaScript正则里有一个更聪明的做法用Unicode属性转义// ES2018 支持 const punctuationRegex /[\p{P}\p{S}]/u;\p{P}匹配所有Unicode标点符号Punctuation\p{S}匹配所有符号Symbol包括货币符号、数学符号等。加一个u标志启用Unicode模式。测试一下const texts [你好世界, Hello, world!, 价格¥199包含运费]; texts.forEach(text { console.log(punctuationRegex.test(text)); // 全部为 true });这个正则同时覆盖了中英文标点、全角半角符号省去了手动罗列的一长串字符。需要注意的是Unicode属性转义在旧浏览器里不支持如果目标环境比较老还是要退回手动罗列的方案。3.3 表单校验正则不是唯一的校验手段表单验证是正则的重灾区。很多人把所有校验逻辑都堆在正则里一个表达式写了上百个字符既难读又难维护。我的经验是三层配合格式层用正则校验格式比如手机号、邮箱、身份证号。正则只负责格式对不对。逻辑层用JavaScript判断业务规则比如开始日期不能晚于结束日期、两次输入的密码必须一致。这类逻辑用正则写会非常别扭。服务端层前端校验只是用户体验优化真正的安全校验必须在服务端再做一次。前端正则拦截的是误操作不是恶意攻击。举一个例子邮箱校验的常见正则const emailRegex /^[^\s][^\s]\.[^\s]$/;这个正则看起来简单但它拦住了最经典的错误没有符号、后面没有点、或者包含空格。真正复杂的邮箱格式比如带引号的局部、IP字面量域名在业务里根本不需要匹配因为那些地址用户自己都记不住。function validateEmail(email) { if (!email || email.length 254) return false; return /^[^\s][^\s]\.[^\s]$/.test(email); }加一个长度上限判断是为了防止超长输入导致正则引擎压力过大。这个细节是我在排查线上问题时学到的一个超长字符串进入一个复杂的正则很容易触发性能瓶颈。4. RegExp方法和String方法怎么配合才顺手4.1 六个高频方法各自的主场JavaScript里正则跟字符串的交集集中在六个方法上方法所属对象作用常用场景test()RegExp判断是否匹配表单校验、条件判断exec()RegExp提取匹配结果的详细信息处理分组捕获、循环提取match()String提取匹配的字符串数组快速提取内容matchAll()String提取所有匹配含分组信息需要分组数据的批量提取search()String查找匹配位置索引判断字符串中是否存在匹配片段replace()String替换匹配内容格式化、脱敏、高亮很多人分不清exec()和match()。简单记忆match()主要用于拿到匹配的字符串结果而exec()更底层能拿到匹配的详细位置、分组信息和索引。如果只需要判断字符串里有没有匹配永远用test()性能最好。如果既要判断又要拿到匹配内容用match()配合g标志。如果需要逐个处理每个匹配包括分组数据用matchAll()或者循环调用exec()。matchAll()返回的是一个迭代器适合用for...of消费const str 订单号A1001、A1002、A1003; const regex /A(\d)/g; for (const match of str.matchAll(regex)) { console.log(订单 ${match[0]}数字部分是 ${match[1]}); } // 订单 A1001数字部分是 1001 // 订单 A1002数字部分是 1002 // 订单 A1003数字部分是 1003这里\d是分组捕获用matchAll()可以直接拿到每一轮的捕获结果不需要像exec()那样自己维护lastIndex循环。4.2 replace回调函数替换操作的进阶玩法replace()的第二个参数除了可以是字符串还可以是一个回调函数。回调函数的写法在需要根据匹配内容动态生成替换结果时非常有用。比如把一段文本里的年份统一加一年const text 版本发布2024年再版于2024年。; const updated text.replace(/(\d{4})年/g, (match, year) { return ${Number(year) 1}年; }); console.log(updated); // 版本发布2025年再版于2025年。回调函数的参数依次是完整匹配、各个分组捕获内容、匹配位置索引、原始字符串。这个能力在做脱敏处理时特别好用function maskPhone(phone) { return phone.replace(/^(\d{3})\d{4}(\d{4})$/, $1****$2); } console.log(maskPhone(13800138000)); // 138****8000字符串替换里用$1、$2引用分组比回调函数更简洁。但回调函数能做额外的逻辑判断这是字符串替换做不到的。4.3 输入监听场景的实时校验热词里有javascript监听和javascript filter函数这跟表单实时校验关系密切。很多页面要求在用户输入时就即时校验不能等提交才报错。实现方式一般是监听input事件然后对输入值做校验。const phoneInput document.querySelector(#phone); const errorEl document.querySelector(#phone-error); phoneInput.addEventListener(input, (e) { const value e.target.value.trim(); const isValid /^1[3-9]\d{9}$/.test(value); errorEl.textContent isValid ? : 手机号格式不正确; });实际项目里用户输入过程中经常出现正在输入到一半的状态比如刚输入了138这时候校验必然失败页面就一直飘红。更合理的做法是结合filter先判断输入长度或者用防抖debounce延迟校验let timer null; phoneInput.addEventListener(input, (e) { clearTimeout(timer); timer setTimeout(() { const value e.target.value.trim(); const isValid /^1[3-9]\d{9}$/.test(value); errorEl.textContent isValid ? : 手机号格式不正确; }, 300); });防抖的思路是用户停止输入300毫秒后再校验避免每个字符触发一次校验导致界面闪烁。这个小优化在真实项目里体验差异非常明显。5. 性能陷阱与优化实录5.1 灾难性回溯最能卡死页面的隐形杀手正则性能问题里最著名的就是灾难性回溯。复杂嵌套的量词可能导致匹配时间呈指数级增长页面卡到无响应。一个经典的例子// 危险的正则 const badRegex /^(a)b$/;这个正则试图匹配一个或多个a外面再套一层分组最后期望以b结尾。如果测试的字符串是aaaaaaaaaaaaaaaaaaaaaaaaaaaaaac即一串a后面跟着一个无法匹配的c正则引擎会反复尝试所有可能的a分组方式时间复杂度爆炸。实测下来/^(a)b$/匹配长度为29的字符串时卡顿还不明显长度到40以上延迟已经肉眼可见再长一点浏览器直接无响应。这类正则的典型特征是多个量词嵌套且量词修饰的内容有重叠。写成代码的习惯就是层层加码原本一个量词能解决的事多套了几层括号和。遇到这类问题的修复思路是消除嵌套// 改为 const goodRegex /^ab$/;如果确实需要分组加量词尽量用非捕获组(?:...)并尽量减少量词的嵌套层数。更保险的做法是给正则加上一个合理的长度前置判断从源头控制输入串的长度。5.2 预编译与复用别在循环里创建正则JavaScript正则引擎创建一个正则对象是需要成本的。在一个大循环里反复创建同一个正则完全是浪费性能。// 不推荐 const rows [13800138000, 13912345678, ...]; // 大批量数据 rows.forEach(row { console.log(/^1[3-9]\d{9}$/.test(row)); }); // 推荐 const phoneRegex /^1[3-9]\d{9}$/; rows.forEach(row { console.log(phoneRegex.test(row)); });这个例子在数据量小的时候看不出差别但处理几千条以上数据时性能差异就体现出来了。更重要的一点是test()和exec()配合全局标志g使用时正则对象是有状态的——lastIndex属性会记住上一次匹配的位置。这意味着同一个带g的正则在不同地方重复调用test()可能得到不同的结果const regex /a/g; console.log(regex.test(a)); // true console.log(regex.test(a)); // false因为 lastIndex 已经移到了末尾避免这个坑的办法是要么不用g标志要么每次调用前手动重置lastIndex 0。这一点在做循环判断时特别容易踩我见过不少同一个正则第一次灵第二次不灵的奇怪bug其实根源就是lastIndex没重置。5.3 正则调试工欲善其事写复杂正则的时候千万别在浏览器控制台里一行行试错。我自己的流程是先在在线正则工具里把表达式和测试文本放一起边写边看匹配结果确认无误后再放进项目代码。关键要盯的几个维度匹配结果高亮部分是否符合预期分组捕获每个分组的值是不是自己想要的数据性能提示复杂正则工具一般会提示回溯次数一旦出现指数级增长就要警惕。工具能在早期就暴露灾难性回溯的风险。特别是处理用户输入的通用正则多花两分钟在工具里验证边界能省掉不少线上故障的处理时间。6. 运行时报错排查从报错信息反推正则问题6.1 常见的正则报错及原因JavaScript正则相关的运行时报错最常见的有两类。第一类是SyntaxError: Invalid regular expression。这表示正则在编译阶段就没通过也就是语法错了。常见原因包括括号没有配对/(abc/使用了不支持的转义/\p{Digit}/漏了u标志量词放在无法量词化的字符后面/*/。这类报错的好处是指哪打哪浏览器会直接告诉你哪一行出了问题。排查思路是先检查括号配对是否完整、有没有多余的元字符、以及用到的特性是否需要特定的标志。第二类是运行结果不符合预期但不报错。这种往往更折磨人。比如test()返回false但肉眼看着应该匹配或者match()返回null又或者replace()只替换了第一处而后面几处没动。不报错但结果不对的排查套路我总结出一个三步走先去掉所有标志位g、i、m、u、s逐个测试确认是不是某个标志影响了行为在在线正则工具里粘贴同样的表达式和字符串看真实匹配结果用exec()替换test()打印详细匹配信息看lastIndex和groups内容。比如replace()只替换一处而没替换全部十有八九是漏了g标志。这种问题看着小线上出现一次就够折腾半天的。6.2 输入数据类型不正确引发的正则异常还有一种很隐蔽的报错来源正则应用到了非字符串数据上。JavaScript的test()方法会对参数做隐式类型转换null会变成字符串nullundefined变成undefined数字123变成123。如果不做类型检查就会出现明明输入框是空的正则却匹配成功了这种诡异现象。const result /null/.test(null); // true因为 null 被转成了 null避免的方式很简单在调用正则之前先做类型守卫function validateInput(value) { if (typeof value ! string) return false; return /^[a-zA-Z0-9]$/.test(value); }这个习惯在写通用校验函数时特别重要。别省那一次类型判断线上问题十个里有三个是类型转换造成的。6.3 一个完整的排查案例复盘之前遇到过一个线上问题用户在搜索框输入2024时搜索正常输入2024(时接口直接超时。排查下来发现后端有个搜索关键词高亮逻辑用正则对(做了匹配而(在正则里是分组元字符没有转义表达式变成/(2024()/导致语法错误或者匹配行为异常。这个案例给我的教训很深刻任何来自用户输入的内容在进入正则之前都必须做转义处理。前端既要做展示高亮又要保证字符串里的特殊字符不被当作正则语法解析那个escapeRegExp函数就成了必需品。还有一个细节new RegExp()的构造函数和字符串拼接经常出问题。new RegExp(\\d)和new RegExp(\d)的含义完全不同。前者在字符串字面量里先被转义成\d后者直接把\d的斜杠丢掉了。记住一条构造函数模式下双反斜杠才是正则在字符串里的正确写法。// 正确 const regex1 new RegExp(\\d); // 错误\d 在字符串里变成了 d const regex2 new RegExp(\d);这类错误在控制台里报的不一定清楚因为\d在非严格模式下会被解释成d导致正则最终变成/d/匹配的是字母d而不是数字。踩过的坑多了之后我的习惯是正则里只要涉及用户输入、动态拼接、或者需要反复修改的场景一律用escapeRegExp转义加new RegExp构造只要表达式是写死的常量一律用字面量写法。这个分工已经帮我避开了一大半正则在生产环境里的坑。最后再分享一个实战心得正则的测试用例一定要包含非法用例。很多人写正则只测了该通过的内容比如手机号校验只测13800138000没测23800138000第二位不是3-9、1380013800少一位、138001380000多一位。补充这些非法用例其实花不了多少时间但正则的健壮性就是靠这些边缘case堆出来的。学会用边界情况反推表达式的漏洞比多背几条语法规则实用得多。