 方法详解:正则、捕获组与脱敏格式化实战)
刚入门前端那会儿我最常做的事就是把后端返回的字段名用 replace() 一个个改格式今天把用户输入的手机号打码明天把金额加上千分位后天把下划线字段转成驼峰再塞给组件。用久了才发现这个看起来平平无奇的字符串方法配合正则之后几乎能搞定所有字符串替换需求而且很多老手其实也只用了它三成功力。这篇我打算把 replace() 方法配合正则的完整用法拆开讲一遍从字符串参数和正则参数的行为差异到 $ 占位符、捕获组、回调函数再到脱敏、格式化、HTML转义这些高频实战场景最后再列几个我踩过跟它有关的坑。1. 先把replace的核心机制说透参数、返回值与首次匹配陷阱1.1 方法签名replace(旧的, 新的) 到底接受什么replace() 是字符串原型上的方法完整签名是str.replace(regexp | substr, newSubStr | function)第一个参数叫做pattern可以传字符串也可以传正则表达式第二个参数叫replacement可以传替换用的字符串也可以传一个回调函数。方法会基于原字符串执行匹配与替换然后返回一个新字符串原字符串本身不会发生任何变化。这一点很多新手会忽略比如写完str.replace(a, b)之后直接打印str发现根本没变然后开始怀疑人生。正确用法是把返回值接收下来let text hello world; let changed text.replace(world, js); console.log(changed); // hello js console.log(text); // hello world原字符串没动为什么设计成不改原字符串因为 JavaScript 字符串本身就是不可变类型所有看似“修改”字符串内容的方法实际上都是制造了一个新字符串返回给你。这也是后续所有 replace 操作的基础认知。1.2 字符串参数 vs 正则参数的根本差异第一个参数传字符串和传正则行为差异是很多人第一个踩的坑。const text cat, cat, cat; // 字符串参数只替换第一处匹配 console.log(text.replace(cat, dog)); // dog, cat, cat // 正则参数不加 g 标志也只替换第一处 console.log(text.replace(/cat/, dog)); // dog, cat, cat // 正则参数加 g 标志全局替换所有匹配 console.log(text.replace(/cat/g, dog)); // dog, dog, dog从本质上来讲字符串参数并不会被隐式转换成正则表达式它只是按普通字符串做“一次”查找找到第一处就替换然后结束。如果你把第一个参数写成字符串又想替换所有出现的地方是没有办法的只能选择带 g 标志的正则或者用后面会讲到的 replaceAll()。正则参数就灵活得多g 代表全局匹配i 代表忽略大小写m 代表多行模式u 代表按 Unicode 匹配s 代表让.匹配换行符。这些标志位可以自由组合Hello HELLO hello.replace(/hello/gi, Hi); // Hi Hi Hi // 多行模式^ 和 $ 会按行匹配而不是只按整个字符串首尾 line1\nline2\nline3.replace(/^line/gm, row); // row1\nrow2\nrow31.3 第二个参数的两种姿态静态替换串与动态回调第二个参数传字符串时里面可以包含各种 $ 占位符比如$1表示第一个捕获组的内容传回调函数时则可以根据匹配到内容动态计算替换结果。这两种方式的取舍后面单独展开这里先记住一个结论替换结果是固定的优先用字符串 $ 占位符简单直观替换结果需要计算、查表、判断或者要从用户输入里拿替换内容时用回调函数更安全。2. 替换字符串里的$符号占位、分组与命名捕获的完整手册2.1 常用$模式对照表很多人不知道 replace 的第二个参数不只是普通字符串它有一套特殊的 $ 模式至少先记住下面这张表模式含义示例$$输出一个字面量美元符号a1.replace(1, $$)得到a$$匹配到的整个子串abc.replace(b, [$])得到a[b]c$匹配位置之前的部分abc.replace(b, [$])得到a[a]c$匹配位置之后的部分abc.replace(b, $)得到accc匹配后是cc插两边各一个$1~$99第 n 个捕获组2024-12-25.replace(/(\d{4})/,$1年)得到2024年-12-25$name命名捕获组2024.replace(/(?y\d{4})/,$y年)得到2024年$这个模式特别常用比如我想给文本中的数字统一加粗在富文本场景里商品数量3库存5.replace(/\d/g, b$/b); // 商品数量b3/b库存b5/b$和$相对冷门但偶尔能帮你省掉一长串字符串切片。假如我要处理 Markdown 里的反引号想把某个词替换成它前面的内容加上后缀熟悉位次关系后会很顺手。2.2 捕获组编号规则为什么有时$2不是你想要的组捕获组就是正则表达式里用一对括号()包住的部分括号内的内容会单独被记录下来。编号规则很简单粗暴从左往右数左括号第几个左括号就是第几组。abcdef.replace(/(a(b(c)))/, [$1]---[$2]---[$3]); // [abc]---[bc]---[c]这里有三个左括号外层(a(b(c)))是第一组内容为abc中间(b(c))是第二组内容为bc最内层(c)是第三组内容为c。一个容易踩的细节是非捕获括号(?:)不参与编号。它只是用来分组匹配逻辑的不会产生捕获组2024-12-25.replace(/(?:\d{4})-(\d{2})/, month: $1); // month: 12-25这里(?:\d{4})只是匹配四位数字不产生捕获组所以$1对应的是( \d{2})。还要注意如果你引用了一个不存在的捕获组替换串不会报错而是把$n原样输出abc.replace(b, $2); // a$2c因为正则里根本没第二个捕获组2.3 命名捕获组从今天开始告别数字魔法值ES2018 带来的命名捕获组是提升可读性的好东西语法是(?name...)替换串里用$name引用2024-12-25.replace( /(?year\d{4})-(?month\d{2})-(?day\d{2})/, $day/$month/$year ); // 25/12/2024相比之下$1/$2/$3这种写法一旦正则变复杂维护的人根本分不清哪个数字对应哪段。我在改别人代码时看到一堆$2、$5只能回去数括号非常痛苦。命名捕获组相当于给每一段匹配起了变量名代码自解释。要特别提醒的是命名捕获组在回调函数里也会体现为最后一个参数对象这个放在下一节讲。老的运行环境可能不支持$name语法如果要在兼容性敏感的项目里使用建议先确认运行环境。3. 用回调函数做动态替换参数顺序、计算能力与选型判断3.1 回调函数参数逐个拆解第二个参数传回调函数时函数会在每次匹配后执行返回值会作为替换文本插入。回调能拿到的参数列表如下参数位置含义说明第 1 个match本次匹配到的完整子串第 2 ~ n 个p1~pn第 1 ~ n 个捕获组的内容倒数第 2 个offset本次匹配在原始字符串中的下标倒数第 1 个string原始完整字符串最后一个可选groups命名捕获组对象只有存在命名捕获组时才出现比如abc123def.replace(/(\d)/, (match, group, offset, string) { console.log(match); // 123 console.log(group); // 123 console.log(offset); // 3 console.log(string); // abc123def return [ match ]; }); // abc[123]def一个常见的误判是认为“回调的第一个参数始终是匹配串第二个参数就是下标”但只要有捕获组参数顺序就会整体往后挪。所以更稳的写法是解构时把不关心的捕获组占位2024-12-25.replace(/(\d{4})-(\d{2})-(\d{2})/, (match, y, m, d, offset, str) { return ${d}/${m}/${y}; }); // 25/12/2024如果正则里有命名捕获组那么回调的最后一个参数会是一个包含各组名的对象2024-12-25.replace( /(?year\d{4})-(?month\d{2})-(?day\d{2})/, (...args) { const groups args[args.length - 1]; return ${groups.day}/${groups.month}/${groups.year}; } ); // 25/12/20243.2 回调派上用场的三类典型场景第一类是替换值需要动态计算比如把所有数字乘以 2单价: 12.5, 数量: 3.replace(/\d(\.\d)?/g, (num) parseFloat(num) * 2); // 单价: 25, 数量: 6第二类是查表映射典型如 HTML 实体转义const escapeMap { : amp;, : lt;, : gt;, : quot;, : #39;, }; div classaTom Jerry/div.replace( /[]/g, (ch) escapeMap[ch] ); // lt;div classquot;aquot;gt;Tom amp; Jerrylt;/divgt;这里如果直接用替换字符串就抓瞎了因为不同字符对应不同实体回调查表是最自然的写法。第三类是替换次数与位置判断。比如我只想替换第三次出现的alet count 0; aaa.replace(/a/g, () { count; return count 3 ? z : a; }); // aaz这种“按第几次替换”的逻辑用字符串参数根本没有办法实现。3.3 什么时候用$串什么时候用回调我的选择标准很朴素替换内容能用固定模板表达就优先用 $ 串性能更好人也直观一旦涉及条件判断、算术、映射表、外部变量取值就老老实实写回调函数。还有一个安全角度的问题如果替换字符串来自用户输入比如用户在前端输入框里填了一段内容你要把它当成替换文本插进去千万不要直接把用户输入塞到第二个参数里。因为用户输入里如果恰好含有$或$1会触发 replace 的特殊逻辑被解释成别的意思。正确做法是用回调函数接收一个无业务含义的占位符返回用户原文const userInput $1 和 $; 替换这段.replace(/这段/g, () userInput); // 输出 替换$1 和 $不会触发任何 $ 模式4. 高频场景实战格式化、脱敏、风格转换与安全清洗4.1 数字千分位\B 前瞻断言的精妙配合给一串数字加千分位是最能体现 replace 配合正则威力的例子。先看代码function toThousands(num) { const parts String(num).split(.); parts[0] parts[0].replace(/\B(?(\d{3})(?!\d))/g, ,); return parts.join(.); } toThousands(1234567.891); // 1,234,567.891这个正则/\B(?(\d{3})(?!\d))/g看起来吓人拆开很好理解\B是非单词边界在这里的作用是确保匹配位置出现在两个数字字符之间不会落在字符串开头(?...)是正向前瞻表示当前位置后面必须满足括号内的条件(\d{3})表示后面必须跟上一组或多组恰好三位连续数字(?!\d)是负向前瞻表示后面不能还是数字。组合起来的意思就是每个“后面带着三位一组的数字、且当前位置不是边界”的空隙处插入逗号。这种视角是从右往左数每三位插一个逗号。如果你只处理整数还有个更短的写法1234567.replace(/(\d)(?(\d{3})$)/g, $1,); // 1,234,567这里用$锚定字符串末尾配合(\d{3})$保证只处理整数部分。拆分成整数和小数分别处理更稳妥因为小数部分不足三位时直接用标准千分位正则可能在小数部分插入逗号造成1,234,567.891变成1,234,567,891这种错误。所以先按小数点 split 再对整数部分做替换是我更推荐的做法。4.2 手机号和身份证脱敏数据报表、工单系统里最常见的需求是给敏感信息打码。手机号 11 位保留前 3 后 413812345678.replace(/(\d{3})\d{4}(\d{4})/, $1****$2); // 138****5678身份证 18 位保留前 6 后 4110105199001011234.replace(/(\d{6})\d{8}(\d{4})/, $1********$2); // 110105********1234这里的原理很直白用两个捕获组分别保住开头和结尾的明文数字中间的数字用\d{4}或\d{8}匹配掉替换串里只有$1和$2是被保留的。脱敏场景有个细节建议不要只脱敏数字邮箱、银行卡号也同理可以先想清楚哪些位数是业务上需要露出的再写捕获组。比如银行卡一般是前 4 后 46222020200098765.replace(/(\d{4})\d{8}(\d{4})/, $1 **** **** $2); // 6222 **** **** 87654.3 下划线/中划线/驼峰命名互转前端对接后端接口时经常遇到这种需求后端返回user_name_info前端组件要的是userNameInfo。用 replace 回调一行搞定user_name_info.replace(/_(\w)/g, (_, letter) letter.toUpperCase()); // userNameInfo正则/_(\w)/g会匹配“下划线 一个单词字符”\w等价于[A-Za-z0-9_]被捕获组(\w)拿到后在回调里转成大写。不用回调也可以写成$_1不行替换串里没法做大小写转换这种必须用回调。反过来驼峰转下划线userNameInfo.replace(/[A-Z]/g, (m) _ m.toLowerCase()); // user_name_info如果你接的是 JSON 配置文件经常是>scriptalert(1)/script.replace(/(\/?script)/gi, lt;$1gt;); // lt;scriptgt;alert(1)lt;/scriptgt;模板占位符替换比如遇到${name}这种格式const vars { name: 张三, count: 5 }; Hello, ${name}! You have ${count} new messages..replace( /\$\{(\w)\}/g, (match, key) vars[key] ?? match ); // Hello, 张三! You have 5 new messages.模板字符串里\$\{表示匹配字面量${因为$和{在正则里都有特殊含义必须转义。如果变量不存在我通过?? match保留了原始占位符不会变成undefined这个细节在模板渲染场景里很重要。4.5 一个组合示例清洗用户输入综合一下比如用户提交了一段带空格、全角符号、多余换行的文本我常常这样一条链处理const dirty 商品【数量】 12 单价 3.5元 \n; const clean dirty .replace(/^\s|\s$/g, ) // 去掉首尾空白 .replace(/[。]/g, (ch) (ch ? , : .)) .replace(/\s/g, ); // 多个空白压成一个 console.log(clean); // 商品【数量】 12 ,单价 3.5元.这种“分步 replace”的思路比写一个超长巨正则要容易维护得多。每行正则只做一件事后续改需求时直接增删一行就行。5. 避坑清单全局标志、lastIndex、转义与replaceAll5.1 没加g只替换一次这是出现频率最高的“假Bug”很多人明明写了正则在线上环境替换时发现只有第一处生效排查半天发现是正则没加 g。the quick brown fox, the dog.replace(/the/, a); // a quick brown fox, the dog没有加 g 的正则默认非全局只替换第一个匹配。这里有个历史包袱很多编程语言里默认是“替换所有”JS 偏偏默认“替换第一处”所以跨语言经验越丰富越容易在这里栽跟头。5.2 全局正则对象与exec混用小心死循环当你需要遍历所有匹配时通常会写 while execconst re /a/g; const str aaa; let match; while ((match re.exec(str)) ! null) { console.log(match[0], re.lastIndex); } // 输出 a 1、a 2、a 3这个时候re.lastIndex会在每次 exec 后推进等到匹配不到时停在字符串末尾exec返回 null循环结束。如果有人在 while 内部又调用str.replace(re, b)麻烦就来了。replace 在执行过程中会重新管理同一个正则对象的 lastIndex导致 while 的推进逻辑被打乱最常见的症状是死循环或者漏替换。const re /a/g; let str aaa; let match; let safe 0; while ((match re.exec(str)) ! null safe 5) { safe; str str.replace(re, b); // 尽量不要这么干 } // 循环条件里 lastIndex 被 replace 重置导致非常容易陷入死循环所以我的建议是需要同时做遍历和替换时要么只遍历不改要么只 replace 不 exec。用 replace 的回调函数拿到 match 逐次处理比在 exec 循环里修改字符串要安全得多。另外全局正则对象如果在其他场景被复用比如先用test()判断过再用replace()一般 replace 会自动把 lastIndex 重置不用太担心。但你自己手动管理 exec 时记得在循环开始前把re.lastIndex 0归位避免上一次残留的位置影响本次遍历。5.3 正则转义构造RegExp和转义字面量的标准姿态如果正则不是写在字面量/.../里而是从字符串构建转义就会多一层。比如new RegExp(\\d); // 字符串里要双写反斜杠才等价于 /\d/如果你想匹配一段用户输入的字符串作为普通文本而不是把它当正则元字符解析那就需要先做一次转义。标准做法是function escapeRegExp(str) { return str.replace(/[.*?^${}()|[\]\\]/g, \\$); } escapeRegExp(10$); // 10\\$这段代码来自 MDN 的经典方案把正则里所有有特殊含义的字符都找出来在前面加一个反斜杠。替换串里\\$表示一个反斜杠加上匹配到的原字符。比如用户输入11如果不转义直接拼进new RegExp()里会被当成量词匹配逻辑就完全变了。5.4 replaceAll与replace的取舍细节ES2021 之后字符串多了一个replaceAll()方法它和 replace 最大的区别是当第一个参数是字符串时它会替换所有匹配而 replace 只替换第一处。a-b-c.replace(-, ); // ab-c a-b-c.replaceAll(-, ); // abc如果第一个参数传正则replaceAll 有硬性要求——必须带 g 标志否则直接抛 TypeErrora-b-c.replaceAll(/-/, ); // 正常 a-b-c.replaceAll(/-/, ); // TypeError: replaceAll must be called with a global RegExp所以我现在的习惯是只要确定要“替换全部匹配”能写字符串参数就直接用 replaceAll可读性更好也避免了忘加 g 的隐患需要复杂匹配逻辑时再用带 g 的正则配 replace。两者的正则行为一致只是 replaceAll 对 g 标志做了强制校验。写到这里replace() 配合正则的完整用法基本讲透了。从字符串参数与正则参数的机制差异到 $ 占位符、捕获组编号、命名捕获组再到回调函数和脱敏、格式化等实战场景最后是那几个跟标志位、lastIndex、转义相关的坑。我自己在项目里最常用的其实就那么几种脱敏打码、千分位、命名风格互转、HTML转义。剩下的用法多半是遇到具体需求时才翻出来。如果你刚开始接触正则建议先把$1捕获组、g标志和回调函数三个点练熟它们在日常开发里出现的频率最高。