写正则的人最怕的不是正则写不出来,而是同一个需求,每个方法看起来都“差不多能用”,但真上线之后,Bug一个接一个。我见过好几次这样的情况:有人用match想拿捕获组,结果返回了一堆字符串;有人在全局标志下反复用test,上一次是true,下一次变成false,排查半天才发现是lastIndex在暗中作祟。
这篇文章就把前端里和正则相关的常用方法从头到尾捋一遍,包括exec、test、match、matchAll、replace、replaceAll、search、split,以及标志位、命名捕获组、前瞻后顾、Unicode 转义这些容易被忽略的细节。每个方法都会写到返回值的结构、使用技巧和实际场景,同时把常见坑点单独拉出来说。适合正在写业务代码时需要处理文本、做表单校验、做数据清洗的开发者,也适合看完文档之后依然一知半解,想在项目里少踩坑的初级前端。
1. 先分清两大家族:正则对象方法与字符串方法
1.1 两个入口,两条思路
JavaScript 里“用正则处理文本”的入口其实有两类。一类是正则对象自己的方法,也就是你写了一个正则re,然后调用re.test()或re.exec();另一类是字符串对象的方法,写一个字符串str,然后调用str.match()、str.replace()等。这两条路针对的需求不同:
- 正则对象的方法更贴近“我是一个模式的判断器”——告诉它一段文本,它回答“有没有匹配”或者“匹配在哪”。
- 字符串方法更贴近“我是一个文本处理工具”——告诉它一个规则,它对文本做查找、提取、替换、切分。
举个例子,你有一段用户输入,要判断它是不是合法手机号,用正则对象的test最直观;但要在一大段文本里把所有邮箱地址找出来并替换成带链接的形式,用字符串的matchAll或replace会更顺畅。这二者的选择没有绝对对错,但理解各自的侧重之后,代码的可读性和稳定性会好很多。
1.2 标志位:控制正则行为的第一道开关
很多人写正则只写/abc/,忽略了后半部分那几个字母的作用。标志位决定了正则的工作模式,直接影响后面所有方法的返回结果。前端常用的标志位有这些:
| 标志 | 含义 | 使用场景 |
|---|---|---|
g | 全局匹配,在文本中查找所有匹配 | 替换全部、遍历所有匹配 |
i | 忽略大小写 | 校验时不想区分大小写 |
m | 多行模式,让^和$按行生效 | 对多段文本逐行匹配校验 |
s | 让.能匹配换行符 | 跨行匹配文本块 |
u | Unicode 模式,支持属性转义、正确处理码点 | 匹配中文、emoji、生僻字 |
y | 粘性匹配,必须从lastIndex开始匹配 | 词法分析、连续片段扫描 |
值得特别注意的是y和g的区别。g是从上一次匹配结束后的位置继续向后搜索,会跳过中间的字符;y则要求下一次匹配必须精确地从lastIndex开始,中间不允许跳过任何字符。做文本解析器或者扫描器时,y比g更可控。
1.3 先想清楚要“找什么”,再决定用哪一类方法
我不是说一定要背住方法列表,而是建议在动手前先问自己三个问题:这次处理是要判断结果、提取内容、替换文本,还是拆分结构?需要取第一个匹配还是全部匹配?需不需要拿到匹配的位置和分组信息?这三个问题的答案组合起来,基本就指向了正确的方法。后面几章会具体展开每种方法的细节。
2. 正则对象方法详解:exec 与 test
2.1 exec:最“完整”的匹配方法,只是返回值有点绕
exec是正则对象的核心方法,它执行一次匹配,如果能匹配,返回一个数组,这个数组同时还有几个额外属性。没用过的人第一次看到它的返回值可能会懵,但只要你理解它返回的是一个“带属性的数组”,后面所有方法都好懂了。
const re = /(\d{4})-(\d{2})-(\d{2})/; const result = re.exec('今天的日期是2025-03-14,明天再确认'); console.log(result[0]); // "2025-03-14" 整个匹配 console.log(result[1]); // "2025" 第一个捕获组 console.log(result[2]); // "03" 第二个捕获组 console.log(result[3]); // "14" 第三个捕获组 console.log(result.index); // 匹配到的起始位置 console.log(result.input); // 被匹配的完整原字符串exec返回的数组,0号元素永远是“整个匹配到的文本”,后面的元素按捕获组的左括号顺序排列。index告诉你匹配始于原字符串的哪里,input是原始串。这些信息在实际业务里特别有用,尤其是做富文本编辑器里的高亮功能时,你需要知道每个匹配的精确位置才能做切片。
如果没匹配到,exec返回null,而不是空数组。所以判断往往是if (result !== null)而不是if (result),因为空数组也是真值,但如果返回的是null,直接if (result)其实也没问题。真正容易踩的坑是另一个,见下节。
2.2 exec 配合全局标志:用循环拿到每一次匹配
如果不带g,每次调用exec都从字符串开头找,永远只返回第一个匹配。如果带了g或y,exec会像一个游标,每调用一次,lastIndex就前进到当前匹配的后面,下一次调用从新的位置继续找。
const text = '联系人:a@test.com,备用联系:b@test.com,最后:c@test.com'; const re = /([a-z0-9]+)@([a-z0-9]+\.(?:com|cn))/g; let match; while ((match = re.exec(text)) !== null) { console.log(`找到 ${match[0]},位置 ${match.index},用户名是 ${match[1]}`); }这里有个多年老坑:如果你在循环里用了同一个正则对象,但它没带g,或者你在别处也复用了同一个对象,那么lastIndex的状态会带跑偏。循环结束条件是exec返回null,这个没问题,但如果循环体里有break,那lastIndex就停在一个非零位置,正则对象处于“半脏”状态,下次再用它去匹配别的字符串,会直接从半截开始找,结果完全不对。
解法很简单:要么每次匹配前显式把lastIndex重置为 0,要么干脆用matchAll,它在内部帮你管理迭代,不污染原正则对象的状态。后面会讲matchAll。
2.3 test:判断真假最好用,但全局标志会带来“假阳性”
test的语义很简单:返回true或false。它的底层行为和exec一致,也在内部推进lastIndex。所以,当正则带了g标志,同一个正则连续调用test,结果是交替的。
const re = /^[a-z]+$/g; console.log(re.test('abc')); // true console.log(re.test('abc')); // false,因为上次匹配后 lastIndex 已经移到末尾 console.log(re.test('abc')); // true,lastIndex 到了末尾之后又从 0 开始这是我在实际项目里真实见过的一个问题:一个校验函数里写了const re = /^[a-z]+$/g; return re.test(input),第一次点提交通过,第二次同一个输入却被拒绝。因为同一个正则对象被复用了,上一次test之后lastIndex指向末尾,第二次从末尾开始匹配自然失败。
所以test的规则很简单:不需要循环查找时,绝对不要加g。如果你拿到的正则是一个带g的全局正则,而你只打算用它判断一次,调用前先re.lastIndex = 0也行,但更好的习惯是不用全局标志。
2.4 什么时候优先用字符串方法而不是 exec
exec返回的细节最丰富,但它有两个明显弱点:一是不太直观,需要手动循环才能拿全所有匹配;二是容易污染lastIndex。所以日常场景里,除非你确实需要拿到每个匹配的位置、分组信息、或者做一个复杂的自定义遍历,否则优先用字符串方法更省心。像是“只想判断是否匹配”用test,“想拿到所有匹配结果”用matchAll,“想替换内容”用replace。这是我用了很多年后总结出来的经验:能用字符串方法,就不要手搓while循环。
3. 字符串方法中的正则:match、matchAll、replace、replaceAll、search、split
3.1 match 与 matchAll:一个容易含糊,一个结构清晰
match是字符串方法里最常用的一个,但它的行为受g标志影响极大,这也是它“坑多”的根源。
不带g时,match的返回值几乎和exec一模一样,是一个带index、input、groups的数组,返回第一个匹配及捕获组。
const date = '2025-03-14'.match(/(\d{4})-(\d{2})-(\d{2})/); console.log(date[0]); // "2025-03-14" console.log(date[1]); // "2025" console.log(date.index); // 0带g时,match返回的是一个纯字符串数组,所有匹配到的文本按顺序排列,分组信息、位置信息全部丢掉了。
const emails = 'a@test.com b@test.com'.match(/[a-z0-9]+@[a-z0-9]+\.com/g); console.log(emails); // ["a@test.com", "b@test.com"]如果你带着g还想拿到分组信息,这时候就该用matchAll。matchAll返回一个可迭代对象,展开后每一个元素都与exec带g时的返回结构一致。它是 ES2020 加入的,兼容性在现代浏览器里已经没问题。
const text = '今天:25-03-14,明天:25-03-15'; const matches = [...text.matchAll(/(\d{2})-(\d{2})-(\d{2})/g)]; matches.forEach(m => { console.log(m[0], m[1], m[2], m[3], m.index); });我建议处理“需要多次匹配 + 需要分组信息”的场景直接默认用matchAll。它不仅结构清楚,而且返回的是可迭代对象,不会像exec一样因为循环里的异常或提前break留下lastIndex污染。for...of循环里可以随便break,完全不影响外部状态。
3.2 replace:替换文本的万能入口
replace可能是前端正则方法里使用频率最高的一个。它有几种用法:
- 第一个参数传字符串,只替换第一个匹配到的字面量。
- 第一个参数传正则,根据是否带
g决定替换第一个还是全部。 - 第二个参数传字符串,按特殊模式替换,比如
$&代表整个匹配文本,$1代表第一个捕获组。 - 第二个参数传函数,在函数里拿到整个匹配、分组、位置和原文本,做更灵活的处理。
先看特殊模式的用法:
const text = '订单编号:ORD-12345,关联编号:ORD-67890'; const output = text.replace(/ORD-(\d+)/g, '订单[$1]'); console.log(output); // 订单[12345],关联订单[67890]再看函数的用法。回调函数的参数顺序是固定的:第一个是完整匹配,然后依次是捕获组内容,再然后offset是匹配位置,最后是原始字符串。
const text2 = '2025-03-14 到 2025-03-16'; const formatted = text2.replace(/(\d{4})-(\d{2})-(\d{2})/g, (match, year, month, day, offset, original) => { return `${year}年${month}月${day}日`; }); console.log(formatted); // 2025年03月14日 到 2025年03月16日我实际项目里用得最多的是函数形式做“条件替换”。比如一段文本里,如果匹配到数字小于 100 就加粗,大于等于 100 就变色,这种逻辑只有在函数回调里才能做。replace的函数形式能直接拿到匹配内容和上下文位置,比先matchAll再手动拼字符串更干净。
3.3 replaceAll:把“替换全部”写得更直白
replaceAll是 ES2021 提供的,它解决了一个语义混淆问题:replace传字符串时默认只替换第一个匹配,要替换全部必须写正则并加g。replaceAll则把“全部替换”变成了默认行为。
const str = 'aaa---bbb---ccc'; console.log(str.replace('---', '|')); // "aaa|bbb---ccc" 只替换第一个 console.log(str.replaceAll('---', '|')); // "aaa|bbb|ccc" 全部替换这里有一个关键注意点:replaceAll如果第一个参数传正则,那这个正则必须带g,否则会直接抛TypeError。这其实是好事,它在语法层面阻止了“忘了写g导致只替换第一个”这种低级错误。
我在项目里一般这样定规矩:替换固定字符串用replaceAll,替换可变模式用replace加带g的正则。这样语义最明确,也方便后来读代码的人一眼看出意图。
3.4 search 与 split:两个容易忽略的实用方法
search和String.prototype.indexOf差不多,但参数是正则。它返回第一个匹配位置的索引,没匹配到返回 -1。它不支持g,也不需要g,因为只找第一个位置。
const text = '订单号:ABC123,备注:无'; const idx = text.search(/\d{3}/); console.log(idx); // 5search的价值在于:当你只想判断“有没有”并且还要知道“在哪”的时候,它比indexOf更灵活,比exec更简单。但它拿不到匹配文本本身和捕获组,所以需要内容时还是要靠exec或match。
split大家常用,但有一个冷门知识:如果分隔符是带捕获组的正则,那么结果数组里会包含分隔符中的捕获组内容。
const csv = 'a,b;c,d;e'; const arr = csv.split(/[,;]/); console.log(arr); // ["a", "b", "c", "d", "e"] const csv2 = '1,2;3,4'; const arr2 = csv2.split(/([,;])/); console.log(arr2); // ["1", ",", "2", ";", "3", ",", "4"]第二个例子里,逗号和分号本身也被留在了结果数组中。这听起来违反直觉,但如果你在做简单的模板解析或词法切分,这种“分隔符留档”的行为反而能帮你把原始结构信息保存下来。知道这个行为,以后遇到奇怪的数据结构就不会一脸懵。
4. 进阶技巧与性能优化
4.1 命名捕获组:告别 $1 $2 的可读性灾难
当一个正则里有四个五个捕获组时,回调函数里写match[3]、match[4]很容易记错顺序。ES2018 之后可以用命名捕获组,把每个分组起一个名字,匹配结果放在返回值的groups属性里。
const re = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/; const result = re.exec('2025-03-14'); console.log(result.groups.year); // 2025 console.log(result.groups.month); // 03 console.log(result.groups.day); // 14在replace里,命名捕获组也有对应的替换语法$<名字>:
const date = '2025-03-14'; const formatted = date.replace(/(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/, '$<year>年$<month>月$<day>日'); console.log(formatted); // 2025年03月14日从可维护性角度看,命名捕获组的好处是显而易见的。我在实际开发里几乎已经不用无名捕获组了,除非是纯临时脚本。因为过两周你再回来看代码,看到result[5]你还得重新数括号,改成result.groups.city一眼就明白。
还有一个小技巧:如果不希望某些括号被当作捕获组参与编号,可以用非捕获分组(?:...)。比如/(?:https?:\/\/)?(\w+\.\w+)/里,(?:那部分只表达“可能有协议头”,不产生捕获组编号,不会干扰后面的$1。
4.2 前瞻与后顾:不消费字符的判断
前瞻和后顾是用来做“位置判断”的,它们本身不匹配字符,只检查当前位置前后是否符合条件。这在密码校验、数字千分位格式化、或者“匹配后面跟了某些内容的词”等场景非常有用。
// 只匹配后面是数字的 "px" const re = /px(?=\d)/; console.log(re.test('width: 100px3')); // true,px 后面有数字 console.log(re.test('width: 100px;')); // false,px 后面是分号 // 匹配前面是数字的 "px" const re2 = /(?<=\d)px/; console.log(re2.test('width: 100px')); // true负向前瞻和负向后顾把=换成!:
// 匹配后面不是数字的 "px" const re3 = /px(?!\d)/; // 匹配前面不是数字的 "px" const re4 = /(?<!\d)px/;这里必须提醒一个 JavaScript 的限制:后顾断言里的内容必须是固定长度。/(?<=\d+)px/这种写法在 JavaScript 里直接报错,因为\d+长度不固定。部分其他语言支持变长后顾,但 JS 不支持。遇到这种情况,可以换一个思路,比如用捕获组配合正则整体匹配来间接实现。
前端里最有名的案例之一是数字千分位格式化:
const num = '1234567890'; const formatted = num.replace(/\B(?=(\d{3})+(?!\d))/g, ','); console.log(formatted); // 1,234,567,890这个表达式的思路是:在每个“数字位置”前面判断,如果从这个位置往前数,后面恰好是三的倍数个数字并且再往后不是数字,就插入逗号。第一次上手确实难懂,但理解了“\B是边界判断、(?=...)是向后看的条件”之后,就能看懂。
4.3 Unicode 处理:中文、emoji 与属性转义
前端正则处理中文,很多人还在写[\u4e00-\u9fa5]。这种写法有两个问题:只覆盖了部分汉字,而且语义不清楚。ES2018 带来了 Unicode 属性转义,配合u标志,可以直接用“语言名”或“类别名”来匹配字符。
const reHan = /^\p{Script=Han}+$/u; console.log(reHan.test('你好世界')); // true console.log(reHan.test('hello')); // false const reEmoji = /\p{Emoji}/u; console.log(reEmoji.test('今天很开心😄')); // true使用属性转义时必须加u标志。不加u的话,\p{...}会被当作字面量的p加花括号来处理,大概率匹配不到任何内容,但也不是直接报错,这种“静默失败”特别折磨人。
实际遇到的中文匹配场景里,如果你只是想判断是否包含中文字符,\p{Script=Han}比写死一个码点范围靠谱得多;如果你想匹配常见表意文字而不包含日文假名,就要根据具体需求选择合适的属性类别。还有一个实际建议:处理 emoji 时,\p{Emoji}会匹配到很多数字符号和特殊符号,更精确的表达是\p{Extended_Pictographic},这个类别才是“看起来是图形”的字符集。
4.4 正则性能的三大隐患
正则写不对是功能问题,写对了但跑得慢就是性能问题。我在项目里遇到过三次比较典型的性能坑,这里直接列出来:
第一个是灾难性回溯。当一个正则里出现多个嵌套的量词,比如/(a+)+$/,而待匹配文本又很长且匹配失败时,引擎会在内部尝试海量的分支组合,导致卡死页面。前端常见的诱因是匹配 HTML 标签、匹配嵌套括号、或者写“不限长度”的贪婪匹配串。规避策略是:量词不要嵌套,能具体化就用具体化,能用字符串方法先判断就用字符串方法。
第二个是长文本上的多次单次匹配。如果你有一篇 10 万字的文章,要提取里面所有链接,正确做法是用一个带g的正则配合matchAll一次性找完,而不是写一个while循环里反复indexOf加上exec。后者的开销往往是指数级的差别。
第三个是每次调用都重新构造正则。有人习惯在循环体里写new RegExp(pattern),一遍遍执行编译。正则对象本身可以复用,如果循环次数上千,反复编译的开销就很明显。把正则的创建提到循环外是基本素养。
5. 常见问题排查与实践速查
5.1 正则匹配不生效的三大排查方向
我自己调试正则时,遇到“为什么没匹配上”的第一个动作不是改表达式,而是先看三个地方:
- 标志位。目标字符串里有换行,但正则没有
s,.匹配不到换行;字符串跨行,^和$没有按行生效,却忘了加m;或者正则本身带了g,然后上一次调用的lastIndex把它带偏了。 - 转义。在字符串字面量里写
new RegExp('\\d+'),很多人少写一个反斜杠,导致表达式变成d+而不是\d+。用字面量写法/\\d+/和用构造函数写new RegExp('\\\\d+')是完全不同的效果,中间隔着一层字符串转义,经常栽在这里。 - 匹配方向。
^和$是整段文本的概念,如果你在split后逐行调用正则,记得把m加上,或者干脆去掉^$改用\b之类的边界。
这三板斧看完,大部分“明明表达式没问题但就是不出结果”的情况都能找到原因。
5.2 捕获组顺序错乱与 match 不带 g 的坑
捕获组顺序是按左括号出现的先后排列的,从左往右数。一个常见错误是:在表达式里头写了一个非捕获分组(?:...),但记不清它算不算编号,导致后面取值错位。我把这条规律总结成一句话:捕获组编号只数(后面不是?:的括号,也就是只看普通分组和命名分组。
另外补充一个match的实际陷阱:如果你带g调用match,它返回的是纯字符串数组,没有index也没有groups。有些写惯了exec的开发者会在这个环节翻车。要在保持全局匹配的同时拿到位置和分组,就换matchAll,没有第二个更稳的方案。
5.3 从需求到表达式:把一句话拆成可写的正则
经常有人在群里问“我有一个需求,怎么写正则”,他们给的描述是“匹配所有网址”。这种需求要下笔之前先拆成几个要素:
- 协议部分:
https?://,是否必须有? - 域名部分:
[\w-]+(\.[\w-]+)+ - 路径部分:
(/[\w\-./?%&=]*)? - 边界条件:前后不能连着别的内容,用
\b或者用前后断言。
拆完之后再组合,而不是一上来就默背模板。我通常先在草稿里写出各段的小正则,再用括号组合成完整表达式,最后加u、i等标志位。这个流程写出来的正则,比直接网上抄一个回来改的可读性和准确率都高得多。
5.4 前端开发正则速查清单
下面是我自己在日常开发中高频使用的一组正则,整理成表格供直接参考:
| 场景 | 正则写法 | 说明 |
|---|---|---|
| 邮箱 | /^[^\s@]+@[^\s@]+\.[^\s@]+$/ | 简单校验,不做域名验证 |
| 手机号 | /^1[3-9]\d{9}$/ | 以 1 开头,第二位 3-9,剩余 9 位数字 |
| 纯中文 | /^\p{Script=Han}+$/u | 配合u标志 |
| 金额两位小数 | /^\d+(\.\d{1,2})?$/ | 支持整数或最多两位小数 |
| 网址 | /^(https?:)?\/\/[\w.-]+(\/[\w\-./?%&=]*)?$/ | 简化版,满足多数业务需求 |
| IPv4 地址 | /^(\d{1,3}\.){3}\d{1,3}$/ | 仅做格式判断,未校验数值范围 |
| 十六进制颜色 | `/^#?([0-9a-fA-F]{3} | [0-9a-fA-F]{6})$/` |
| 日期 YYYY-MM-DD | /^\d{4}-\d{2}-\d{2}$/ | 格式校验,配合Date.parse判断合法性 |
速查表的价值在于提供一个“起步点”,直接用可以,但生产环境建议根据业务口径再收紧,比如手机号要限制号段、邮箱要过滤特殊字符、网址要支持端口等。
前端正则方法说多不多,说少不少,核心还是那句老话:匹配效果取决于标志位,取值结果取决于方法和捕获组,稳定性取决于lastIndex和编码习惯。我自己在实际项目里养成了一个习惯,凡是出现超过三处相同模式的正则,就抽成一个公共函数或者常量放在统一模块里,这样既避免到处new RegExp,也方便以后改动一处、全局生效。如果你经常和文本处理打交道,强烈建议花一个下午的时间把这几个方法挨个写一遍,尤其是matchAll和命名捕获组这两个相对年轻的功能,用熟了之后你会明显感觉到之前的很多字符串处理代码都写复杂了。