写正则写多了,难免会碰到几个让我"破防"的瞬间。第一次在项目里校验手机号,随手写了/^\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里是(?P<name>...),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>首页</title><title>关于我们</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匹配单词边界,^和$匹配字符串开头和结尾。记住一个关键区别:^和$默认匹配的是整个字符串的首尾,不是每一行的首尾。要实现"按行匹配",得加上m(multiline)标志。
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-8000:
function 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 = /^a+b$/;如果确实需要分组加量词,尽量用非捕获组(?:...),并尽量减少量词的嵌套层数。更保险的做法是给正则加上一个合理的长度前置判断,从源头控制输入串的长度。
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会变成字符串"null",undefined变成"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堆出来的。学会用边界情况反推表达式的漏洞,比多背几条语法规则实用得多。