在 JavaScript 里天天跟字符串打交道,replace()算是最常用的方法之一。但很多人对它的理解停留在“换个词”或者“拿正则匹配一下替换掉”的层面,真到用的时候,要么$1用得稀里糊涂,要么回调函数里的参数傻傻分不清楚,要么因为忘了g标志,明明想全部替换结果只换了一处,排半天 bug。这篇文章就把replace()方法从基础语法到正则捕获组、替换模式、回调函数、命名分组、常见坑位,完整过一遍,配合可直接复制的代码示例,适合刚接触正则的初学者,也适合写了几年 JS 但始终没把replace()吃透的进阶开发者。
1. replace() 方法基础:先从字符串替换说起
1.1 基本语法与参数说明
replace()的语法看起来很简单:
string.replace(searchValue, replaceValue)它接收两个参数,第一个是“要查找的内容”,第二个是“替换成什么”。第一个参数可以是字符串,也可以是正则表达式;第二个参数可以是字符串,也可以是一个回调函数。
最常见、最基础的使用方式,就是用字符串去替换字符串:
const str = 'hello world'; const result = str.replace('world', 'javascript'); console.log(result); // hello javascript这段代码的逻辑很直白:在str里找到'world',换成'javascript',返回替换后的新字符串。注意,replace()不会修改原字符串,它返回的是一个新字符串,原字符串保持不变,这一点刚入门时常忽略。
如果被查找的内容在字符串里出现了多次,但第一个参数传的是字符串,那么它只会替换第一个匹配到的位置:
const str = 'one two one two'; const result = str.replace('one', '1'); console.log(result); // 1 two one two第二个'one'没被替换掉。如果想一次性换掉所有匹配项,就得借助正则或者是后面出的replaceAll()方法。
这里要特别强调一个换行习惯上的细节:replace()返回的是“新字符串”,所以如果要保留处理后的结果,一定要用一个变量去接住它。很多人调试时直接在控制台打印原字符串,发现没变化,误以为方法没生效,其实问题就出在这。
1.2 字符串替换的局限与边界
字符串作为第一个参数,本质上是“精确匹配”。它只能替换完全相同的文本,无法处理模糊匹配、位置匹配、数量匹配。
举几个直观的例子:
// 需求:把所有的数字两边加上中划线 const price = 'apple 5, banana 10, orange 15'; // 用字符串替换没法做,因为每个数字都不同 // 必须用正则 \d+ // 需求:把 email 里的用户名部分替换成 xxx const email = 'tom@example.com'; // 字符串替换做不到定位到 @ 前面那段 // 正则可以用 (\w+)@ 去捕获所以在实际开发中,如果要进行“规则化”的替换,基本都会选择正则表达式作为第一个参数。第二个参数也不一定非得写死成字符串,可以用$系列的特殊字符去引用匹配到的内容,也可以直接丢一个回调函数进去做动态计算,这两块是replace()真正的精髓,下面重点展开。
还有一个容易被误解的点:如果第一个参数传入的是字符串'$1'这种带$符号的内容,它是不会被当成替换模式去解析的,因为替换模式只有在第一个参数是正则或字符串且第二个参数包含$时才生效。但需要注意,String.replace的第二个参数如果是字符串,其中包含$符号时确实会被当作特殊模式解析,这一点和第一个参数是什么类型没有关系,后面专门讲这块。
2. 正则替换:模式匹配与替换模式的完整拆解
2.1 正则替换与全局标志 g
当第一个参数传入正则表达式时,replace()的匹配能力瞬间提升了一个量级。正则负责“找到该找的内容”,第二个参数负责“决定怎么替换”。两者配合,能实现非常灵活的文本处理。
先看全局标志g带来的差异:
const str = 'one two one two'; const result1 = str.replace(/one/, '1'); const result2 = str.replace(/one/g, '1'); console.log(result1); // 1 two one two console.log(result2); // 1 two 1 two不加g,正则在第一次匹配成功后就直接返回了,行为和传字符串几乎一致;加g,会扫遍整个字符串,把全局所有匹配项都替换掉。这个细节必须刻在脑子里,正则替换时“没反应”或者“只换了一处”,十有六七是忘了加g。
再配合正则的量词和字符类,规则替换才真正展开:
// 把连续的数字替换成 # const str = '订单号:123456,金额:7890,备注:无'; const result = str.replace(/\d+/g, '#'); console.log(result); // 订单号:#,金额:#,备注:无\d+匹配连续的数字,g标志保证所有数字片段都被替换掉。这里要留意,\d+匹配的是“数字片段”,而不是单个数字,所以123456整体被替换成了#,不会拆成六个#。
// 把空白字符统一替换成单空格 const str = '你好 世界 JavaScript'; const result = str.replace(/\s+/g, ' '); console.log(result); // 你好 世界 JavaScript\s+匹配一个或多个空白符(空格、制表符、换行等),配合g把连续空白折叠成单个空格。这类清洗文本的操作,在解析用户输入或者处理爬虫抓回来的脏数据时非常实用。
2.2 捕获组与 $1、$2 替换符号
正则里用圆括号可以把一部分匹配内容“圈起来”,称为捕获组。replace()的第二个参数,可以通过$1、$2这样的写法引用对应捕获到的内容。
看一个最经典的例子:重排日期格式。
const dateStr = '2024-03-15'; const result = dateStr.replace(/(\d{4})-(\d{2})-(\d{2})/, '$3/$2/$1'); console.log(result); // 15/03/2024正则/(\d{4})-(\d{2})-(\d{2})/里有三个捕获组:第一个捕获年份,第二个捕获月份,第三个捕获日期。替换字符串'$3/$2/$1'里的$3就代表匹配到的日期部分,$2代表月份,$1代表年份,所以最终的顺序变成了“日/月/年”。
这里要提醒一个新手经常踩的坑:替换字符串里的$1并不是表示“从某个数组里取值”,也不是模板字符串的插值语法,它是replace()内部解析的特殊模式。如果你写成了${1}或者$1外面再加引号什么的,都不会有效果。同时,正则里的捕获组是从左往右数,第一个左圆括号对应$1,第二个对应$2,嵌套括号时按左括号出现的顺序编号。
再来看一个更贴近业务的场景:隐藏手机号中间四位。
const phone = '13812345678'; const result = phone.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2'); console.log(result); // 138****5678这个正则把手机号分成两部分:前三位是$1,后四位是$2,中间四位用*代替。在数据脱敏、日志打码这类需求里,这个写法比先截取字符串再拼接要简洁得多,而且正则本身就能保证只处理手机号格式的字符串,不会误伤别的文本。
还有一个非常实用的场景:调换姓名顺序。
const name = 'lastname, firstname'; const result = name.replace(/(\w+), (\w+)/, '$2 $1'); console.log(result); // firstname lastname文本处理工具里常见的“姓,名”转“名 姓”,用replace()一行就能做完,比拆数组再重新拼要直观。
2.3 更多替换模式:$$、$&、$`、$' 的适用场景
除了$1、$2这种引用捕获组的写法,replace()的替换字符串里还有几个特殊模式,理解它们可以省掉不少麻烦。
首先是$$,它代表一个字面量的美元符号$。当你想在替换结果里真正显示一个$符号时,必须用$$来转义,因为单个$会被认为是特殊模式的开始。
const price = '100'; const result = price.replace(/\d+/, '$$$&'); console.log(result); // $100这段代码里,替换字符串'$$$&'分两部分看:$$解析成一个$符号,$&代表“本次匹配到的完整内容”。所以数字100前面被加了一个美元符号。这个例子同时带出了另一个模式$&。
$&的含义是“整个匹配到的文本”。不管正则里有没有捕获组,$&永远指向正则整体匹配到的那个片段。比如给匹配到的内容加样式:
const str = '遇到问题,请拨打 400-888-8888 联系客服'; const result = str.replace(/\d{3}-\d{3}-\d{4}/g, '<b>$&</b>'); console.log(result); // 遇到问题,请拨打 <b>400-888-8888</b> 联系客服如果没有$&,你得把正则整体再包个捕获组/(\d{3}-\d{3}-\d{4})/g,然后写$1,效果一样,但多了对括号,可读性反而差一点。所以当“引用整体匹配”时,$&是首选。
$`` 代表匹配位置之前的所有文本,$'` 代表匹配位置之后的所有文本。这两个模式使用频率不高,但特定场景下特别好用。
const str = 'hello world'; const result = str.replace(/world/, '$`'); console.log(result); // hello hello这段代码把world替换成了它前面的内容hello,得到hello hello。同样:
const result = str.replace(/hello/, "$'"); console.log(result); // world world把hello替换成了它后面的内容world。这两个模式本质上是在做“取匹配点左侧/右侧文本”的操作。坦白讲,我在真实项目里用它们的次数一只手数得过来,但有时候在写一次性脚本、处理文本对齐时,它们能省掉一个substring()的调用和对应的字符串拼接逻辑。知道有这回事即可,真遇到需求的时候别愣住就行。
3. 回调函数替换:真正灵活的动态替换方式
3.1 回调函数的参数解析
第二个参数除了传字符串,还可以传一个函数。这个函数会“根据匹配到的内容来决定返回什么替换文本”,正因为如此,替换逻辑不再局限于静态字符串,而是可以动态计算。
回调函数的参数有一套固定规则。如果正则里没有捕获组,回调接收四个参数:
const str = '订单金额:2000 元'; const result = str.replace(/\d+/, (match, offset, string) => { console.log('匹配到:', match); console.log('偏移量:', offset); console.log('原字符串:', string); return `【${match}】`; }); console.log(result); // 订单金额:【2000】 元match:匹配到的完整子串。offset:匹配到的子串在原字符串中的偏移位置(从 0 开始)。string:原始字符串本身。
如果正则里带捕获组,参数会变成(match, p1, p2, ..., offset, string),匹配结果排在最前面,接着是每一个捕获组对应的内容,最后才是offset和string。
const str = '2024-03-15'; const result = str.replace( /(\d{4})-(\d{2})-(\d{2})/, (match, year, month, day, offset, string) => { console.log(match); // 2024-03-15 console.log(year); // 2024 console.log(month); // 03 console.log(day); // 15 console.log(offset); // 0 return `${day}/${month}/${year}`; } ); console.log(result); // 15/03/2024这个参数顺序必须记牢:先是“匹配整体”,然后是“按正则捕获组顺序的各个分组”,最后是“偏移量”和“原字符串”。很多人第一次用的时候,很容易把offset当成第一个参数,结果打印出来发现是个字符串,一头雾水。
还有一点要特别注意:如果没有捕获组,回调的第三个参数才是offset;如果有捕获组,offset就顺延到捕获组之后。也就是说,回调函数的参数个数是动态的,数参数时一定先数清正则里有几组括号。代码里写的时候,建议参数命名尽量语义化,或者干脆用剩余参数...args配合解构来避免数错位置。
3.2 回调函数实战:动态计算替换结果
回调函数最大的价值在于“根据不同匹配内容,返回不同替换结果”。举一个很常见的例子:把字符串里的数字做单位换算。
const str = '文件大小:2048MB,缓存占用:512MB'; const result = str.replace(/(\d+)(MB)/g, (match, num, unit) => { const gb = Number(num) / 1024; return `${gb.toFixed(2)}GB`; }); console.log(result); // 文件大小:2.00GB,缓存占用:0.50GB这个场景里,(\d+)捕获数字部分,(MB)捕获单位部分。如果不正则替换,你得先把中文文本拆开,找出所有数字,再逐个处理拼接,逻辑繁琐且容易出错。用回调以后,匹配逻辑被正则接管,转换逻辑被函数接管,各司其职,代码非常干净。
再比如,根据匹配到的内容做条件判断:
const str = 'rating: 4, status: 200, count: 128'; const result = str.replace(/\d+/g, (match) => { const n = Number(match); if (n > 100) return 'high'; if (n > 10) return 'medium'; return 'low'; }); console.log(result); // rating: low, status: medium, count: high同一个正则匹配所有数字,但回调函数让每一个数字都有了独立的替换策略。这种“匹配相同、替换不同”的需求,用回调是唯一合理的解法,靠字符串替换或静态替换模式都做不到。
我还遇到过一种场景,需要把字符串里的时间戳批量替换成格式化后的时间格式。注意,replace()配合回调时,回调里如果做了异步操作,返回值是无法被同步写入的,因为replace()本身是同步的,它不会等待异步回调。所以时间格式化这类纯同步操作没问题,但如果你在回调里写async函数、查接口、读文件,那返回的就是一个Promise对象,会被直接插入到字符串里,变成[object Promise]。这个坑我在早期踩过一次,排查半天才反应过来。处理这类异步需求,应该先用正则把所有匹配项收集起来,再逐个异步处理,最后拼接,而不是在replace()里做异步。
4. 真实场景中的高级正则替换案例
4.1 命名捕获组与 $ 引用
ES2018 之后,正则里支持命名捕获组。命名捕获组的写法是(?<name>...),在替换字符串里通过$<name>引用,在回调函数里则通过参数对象或者解构来取。
const dateStr = '2024-03-15'; const result = dateStr.replace( /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/, '$<day>/$<month>/$<year>' ); console.log(result); // 15/03/2024当捕获组数量变多时,$1、$2这种数字编号很容易看错,尤其是有嵌套括号的时候。命名捕获组最大的好处就是代码可读性高:$<year>一眼就知道代表年份,不用回去数括号。
回调函数里,命名捕获组会作为参数传给回调,不过位置和普通捕获组一样,仍然按顺序排在match后面。如果想更清晰地取值,可以利用解构配合剩余参数:
const str = '价格:100 元,数量:3 件'; const result = str.replace( /(?<price>\d+)\s*元/g, (...args) => { const { price } = args.at(-2); // 倒数第二个参数是捕获组对象 return `¥${Number(price) * 0.9}`; } ); console.log(result); // 价格:¥90 元,数量:3 件这里稍微绕了一点,需要说明的是,当正则里存在命名捕获组时,回调函数里最后一个参与解构的对象参数会携带命名捕获组信息。具体到代码里,我习惯用args.at(-2),因为args的最后一个元素是原始字符串,倒数第二个元素就是包含命名捕获组的对象。这种写法虽然看起来有点“黑魔法”,但在多个捕获组混合场景里确实能减少认知负担。
不过从实际工程角度说,如果正则逻辑已经复杂到需要借助命名捕获组才能看懂,我更倾向于把正则拆开,分步骤处理或者用多个replace()串联,而不是在一个表达式里堆砌大量捕获组。代码首先是写给人看的,其次才是给机器执行的。
4.2 驼峰命名转换
字符串格式转换是前端开发里绕不开的需求,而replace()配合正则往往是最高效的解法。
第一个案例:下划线命名转驼峰命名。
const str = 'user_name_info'; const result = str.replace(/_(\w)/g, (match, letter) => letter.toUpperCase()); console.log(result); // userNameInfo核心逻辑是:匹配一个下划线后面跟一个字母的片段,把下划线删掉,把字母变成大写。letter这个捕获组正好捕获了需要大写的字母,回调里直接调toUpperCase()返回。
反向操作,驼峰转下划线:
const str = 'userNameInfo'; const result = str.replace(/([A-Z])/g, '_$1').toLowerCase(); console.log(result); // user_name_info原理是把每个大写字母替换成“下划线 + 这个大写字母”,最后再统一转小写。两个案例结合起来,可以在接口字段名和前端变量名之间自由转换,写工具函数或者处理 API 数据字段时非常常用。
再补充一个首字母大写的场景:
const name = 'javascript'; const result = name.replace(/^(\w)/, (match) => match.toUpperCase()); console.log(result); // Javascript用^(\w)锚定字符串开头,回调里把第一个字符转大写。其实这种简单需求也可以不用replace(),直接str[0].toUpperCase() + str.slice(1),但正则写法在连续处理多段文本时更方便,例如把一个字符串里所有独立单词都变成首字母大写。
4.3 数字千分位格式化
这个案例非常经典,也足够演示replace()+ 正则 + 回调三者配合的威力。
需求很简单:把数字格式化成千分位字符串,1234567.89变成1,234,567.89。
function formatThousands(num) { const parts = String(num).split('.'); parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); return parts.join('.'); } console.log(formatThousands(1234567.89)); // 1,234,567.89 console.log(formatThousands(12345)); // 12,345重点看这个正则:\B(?=(\d{3})+(?!\d))。
\B表示非单词边界,确保插入逗号的位置不是在单词之间。(?=...)是正向先行断言,表示“后面必须跟着什么”,但断言本身不会消费字符。(\d{3})+表示一个或多个“三位数字”片段。(?!\d)是负向先行断言,表示“后面不能再是数字”,确保匹配到的是从右往左数、每三位一组的完整分组。
连起来理解:在“后面刚好是若干个三位数组,并且再往后不是数字”的位置插入逗号。这个写法在很多格式化场景里被反复使用,但说实话,理解透需要一点时间,建议你把它拆开一步步调试观察,把\B去掉试试、把(?!\d)去掉试试,看到差异之后才算真正掌握了。
小数部分不能格式化,所以我的实现里用.split('.')把整数和小数分开处理,最后再拼回去。这种“先把字符串拆开、在需要的地方替换、再拼回去”的思路,本身也是replace()之外非常常用的处理套路。
至于大数场景,Number本身的精度有限,如果需要处理超大金额,建议引入专门的大数库,而不是指望replace()来解决精度问题。
4.4 HTML 转义与安全处理
前端渲染用户输入的内容时,如果不做转义,用户输入的<script>标签或者onerror属性可能引发 XSS 攻击。用replace()配合正则做 HTML 转义,是很多模板引擎和工具库里的标准做法。
function escapeHtml(str) { const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; return str.replace(/[&<>"']/g, (char) => map[char]); } const userInput = '<script>alert("xss")</script>'; console.log(escapeHtml(userInput)); // <script>alert("xss")</script>这段代码的关键点在于:一个正则[&<>"']匹配所有需要转义的字符,回调函数里用查表的方式返回对应的实体字符。g标志保证所有特殊字符都被替换,而不是只换第一个。这样的写法,等于把“匹配规则”和“转换规则”完全分开,新增要转义的字符时,只需要在map里加一项,不需要改正则,维护起来很舒服。
我还见过一种写法是用replace配合createTextNode之类的 DOM 方法来做转义,渲染端由浏览器自行处理,也不失为一种方案。但纯字符串场景(比如生成邮件模板、拼接 JSON 字符串)里,用replace()转义仍然是最通用的手段。
转义的反向操作是反转义,逻辑一模一样,只要把map的 key 和 value 互换即可。如果你要处理的是大量用户评论、富文本内容,建议直接用成熟的工具库,不要自己手写全套转义逻辑,边界情况太多了。
5. 常见问题与避坑实录
5.1 常见错误排查速查表
我在实际开发和答疑过程中,整理过replace()最常见的几个问题,放在一张表里,方便你以后排查。
| 现象 | 原因 | 解决方法 |
|---|---|---|
| 只替换了第一处,其余没变 | 正则没加g标志 | 在正则末尾加g,比如/\d+/g |
想替换$1这个字符串,结果变成匹配内容 | 第二个参数里的$1被当成替换模式解析了 | 用$$1表示字面量$1 |
正则里写了捕获组,但$1没生效 | 替换字符串里写错了引用格式,或者捕获组编号数错 | 按左括号出现顺序数捕获组,写成$1、$2 |
替换结果出现undefined | 回调函数没有return任何值 | 确保回调函数每个分支都有返回值 |
| 正则匹配不到内容,替换无变化 | 正则表达式写错,比如转义没做对 | 在控制台先用.match()或exec()验证正则 |
想全局替换但用了replaceAll()且正则没有g | replaceAll()要求第一个参数为正则时必须有g标志 | 使用replace()加g,或者给replaceAll()传字符串 |
替换金额出现了多余的$ | 替换字符串里的单个$被当成了特殊符号 | 写成$$来表示字面美元符号 |
在回调函数里用了await,返回[object Promise] | replace()是同步方法,无法等待异步回调 | 调整思路,先收集匹配项再异步处理 |
第一个坑是最常见的。我之前接手的某个项目,把用户输入的敏感词做屏蔽,正则写好了,但漏了g,结果每段话只屏蔽了第一个出现的敏感词,后面全裸奔。线上跑了一阵子才发现,问题说起来简单,排查倒费了不少劲。
第二个坑的频率也很高。想象一下,你写了一段模板字符串替换:
const tpl = 'Hello, $1'; const result = tpl.replace('$1', 'World'); console.log(result); // Hello, World这种没问题,因为第一个参数是字符串,没涉及正则。但如果第二个参数来自某个配置项,配置项里包含$符号,同时又用了正则替换,那就极容易出现“预期输出$,实际输出变成了匹配内容”的情况。例如:
const str = 'abc'; const result = str.replace(/b/, '$$'); console.log(result); // a$c如果只写一个$,结果会是aabc之类意想不到的东西。稳妥起见,在写替换字符串前,先想清楚里面的$需不需要转义。
5.2 效率与性能的思考
replace()在绝大多数场景下性能都不是瓶颈,但有几个值得留意的点。
首先,replace(/\d+/g)这种带g的正则,内部会扫描整个字符串,O(n) 级别。如果你的字符串特别长(比如几 MB 的文本),又做了多次串联替换,那么每轮replace()都会重新生成一个新字符串,会造成一些内存开销。遇到这种极端场景,建议用数组split()+map()+join()的方式一次性处理完,或者用流式处理,避免反复生成大字符串。
看一个简单的对比:
// 多次 replace 串联 const result = bigStr .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>'); // 单次 replace + 回调 const result2 = bigStr.replace(/[&<>]/g, (char) => map[char]);第一种写法,每一步都会创建一个全新的完整字符串,三步就是三次拷贝。第二种写法,一轮扫描就完成所有替换,中间不会产生中间态的大字符串。两者输出结果一样,但第二种在性能上更友好,代码也更集中。所以处理“多字符映射替换”时,优先考虑把多个字符组成字符类放进同一个正则里。
其次,正则表达式本身也有编译成本。如果你在循环里反复执行同一个正则的replace(),可以考虑把正则对象提取到循环外面,避免重复编译:
// 不推荐:循环内创建正则 for (const item of list) { const res = item.replace(/\d+/g, ''); } // 推荐:正则只创建一次 const numRegex = /\d+/g; for (const item of list) { const res = item.replace(numRegex, ''); }注意,带g的正则对象有一个lastIndex属性,会影响exec()和test()的匹配起始位置。但replace()内部不会因为lastIndex残留而跳匹配,所以这种场景下提取正则对象是安全的。不过如果正则对象同时被replace()和exec()复用,要小心lastIndex被exec()改掉的问题,最好按需求重新new RegExp,或者每次都把lastIndex归零。
最后,简单场景不要盲目上正则。如果只是固定文本替换,用字符串参数就够了。正则虽然强大,可读性和可维护性都不如普通字符串,一个写复杂的正则,过两周你自己回来看,也要画好几分钟才反应过来。能在应用层拆分的逻辑,就别硬塞进正则里。
我在实际项目里养成的习惯是:先用最简单的split()、includes()、startsWith()判断能不能解,解不了再上正则。这样既保证了代码清晰,又规避了因为正则边界问题引发的隐藏 bug。replace()虽好,但也要用在刀刃上。
像replace()这种 API,看起来简单,实际用起来门道不少。如果说有什么心得,那就是先在脑里明确三件事:匹配目标是什么、替换规则是什么、有没有全局处理的诉求。想清楚这三件事,再动手写正则,基本就不会出大问题。正则表达式学起来确实有点陡,但你多写几个实际案例,把它和replace()的组合练熟了,后面处理字符串的效率会提升非常明显。