JavaScript 里最容易被低估的字符串方法,replace()一定榜上有名。很多人对它的认知停留在“第一个参数传字符串,把查到的内容换掉”这个层面,但一旦遇到动态关键词处理、URL 参数重写、日期格式统一、模板占位符批量替换这类需求,手头的replace()立刻变得不好使。这篇文章把replace()配合正则表达式的底层机制、回调函数写法、高频业务场景,以及我见过的高频坑一次性梳理清楚,适合所有跟字符串打过交道的开发者拿去做参考。
很多人以为replace()只是一个“查找并替换”的小工具,其实它更像一台小型文本加工机器。只要把正则写对,把回调函数用起来,它能帮你省掉大量手写循环和字符串拼接的功夫。下面我从最基础的机制开始讲,慢慢往上叠复杂度。
1. 先搞懂 replace() 的底层机制,后面才不踩坑
1.1 第一个参数传字符串:字面匹配,只处理第一处
replace()的完整签名是str.replace(regexp|substr, newSubStr|function)。注意第一个参数有两种可能:字符串或正则。
当第一个参数是字符串时,它会被当作普通字面文本去查找,而不是正则模式。这意味着你传"."进去,它匹配的就是一个普通句号,不会把它当成“匹配任意字符”的通配符。这个特性偶尔有用,但更关键的限制是:字符串模式只会替换第一个匹配到的位置。
const str = '2024-05-12 2024-05-13'; const result = str.replace('2024', '2023'); console.log(result); // '2023-05-12 2024-05-13'第二个2024原封不动。如果你期望的是所有2024都被替换,必须用带全局标志g的正则,或者用 ES2021 之后的replaceAll()。
还有一个很常见的误解:有人以为replace()会修改原字符串,甚至用完之后发现原变量没变,以为代码写错了。实际上 JavaScript 字符串是不可变的,replace()永远返回一个新字符串,原字符串保持原样。
let s = 'hello'; const r = s.replace('h', 'H'); console.log(s); // 'hello' console.log(r); // 'Hello'所以在做多次连续替换时,需要手动把返回结果重新赋值给变量,否则后面的替换操作会基于原始字符串,而不是上一次的替换结果。
1.2 第一个参数传正则:带不带 g 直接决定行为
当第一个参数是正则时,replace()的行为由正则的标志位和模式内容共同决定。最核心的一个标志就是g(global,全局匹配)。
- 不带
g的正则:虽然正则本身可能匹配到多处,但replace()只替换第一处。 - 带
g的正则:从头到尾把所有匹配的位置全部替换。
const str = '2024-05-12 2024-05-13'; str.replace(/2024/, '2023'); // '2023-05-12 2024-05-13',只替换第一处 str.replace(/2024/g, '2023'); // '2023-05-12 2023-05-13',全局替换这个区别是无数 bug 的来源。我见过太多业务代码里写了一个不带g的正则,或者传了一个字符串参数,然后只替换了第一处,测试还通过了,因为测试数据里只有一个匹配项,一上线面对真实数据就露馅。
1.3 返回值、不可变性与链式替换
正因为replace()返回新字符串,所以你可以放心地链式调用。比如先去掉首尾空格,再替换掉空白符,再处理特殊字符:
const userInput = ' hello world '; const cleaned = userInput .replace(/^\s+|\s+$/g, '') // 去掉首尾空白 .replace(/\s+/g, ' '); // 多个空格合并为一个 console.log(cleaned); // 'hello world'这里每一步都基于上一步返回的新字符串,原字符串始终没变。链式替换在做文本清洗时非常好用,但要注意别链太长,否则可读性会变差。我习惯把多步替换规则放进一个规则数组,用reduce()串起来,后面第 5 章会给出示例。
2. 正则参数的正确用法:g/i/m 标志与转义规则
2.1 g、i、m 三个标志的高频组合场景
正则的标志位里,除了g,日常打交道最多的是i(ignoreCase,忽略大小写)和m(multiline,多行模式)。
i的用途很直白:匹配时不区分大小写。适合处理用户输入、搜索关键词、代码中的人名地名等大小写不确定的场景。
const str = 'Hello world, hello JS'; str.replace(/hello/g, 'hi'); // 'Hello world, hi JS',只替换了小写的 hello str.replace(/hello/gi, 'hi'); // 'hi world, hi JS',大小写都替换了m标志则会影响^和$这两个锚点的行为。默认情况下,^只匹配整个字符串的开头,$只匹配整个字符串的结尾。加了m之后,它们会匹配每一行的开头和结尾。
const multiLine = 'Line1\nLine2\nLine3'; multiLine.replace(/^Line/gm, 'Row'); // 'Row1\nRow2\nRow3'这个特性在处理多行日志、多行配置文本时非常有用。比如你要把每行开头的某个前缀批量替换掉,经常要带上m。
实际上g、i、m经常组合使用,比如/pattern/gim。这个组合的含义是:全局匹配、忽略大小写、逐行处理锚点。是文本处理里最常见的一套组合拳。
2.2 元字符转义:这不是 replace 的锅,但很多人倒在这
正则表达式里有一批元字符具有特殊含义,比如.、*、+、?、(、)、[、]、{、}、^、$、|、\。如果你要匹配的文本本身就包含这些字符,必须先做转义。
最常见的场景是匹配 IP 地址、版本号、小数点。比如有一个字符串v1.2.3,你想把里面的点号替换成短横线:
const version = 'v1.2.3'; version.replace(/\./g, '-'); // 'v1-2-3',正确 version.replace(new RegExp('.', 'g'), '-'); // '--------',错误!因为 . 匹配任意字符第一种写法用的是正则字面量/\./g,反斜杠把点号转义成字面点号。第二种写法用new RegExp()从字符串构建正则,但字符串里'.'到了正则引擎那里仍然是元字符,结果把每个字符都替换掉了。
用new RegExp()构建正则时,字符串层面的反斜杠还要过一次转义。比如你想构造一个匹配字面点号的正则,字符串里要写成'\\.',而不是'.':
const pattern = new RegExp('\\.', 'g'); 'v1.2.3'.replace(pattern, '-'); // 'v1-2-3'这里的规则是:正则表达式里要写\.,但在 JavaScript 字符串字面量里,反斜杠本身需要用\\来表示,所以最终看到的是'\\.'。这个双重转义特别容易踩坑,尤其是当你把用户输入拼进new RegExp()的时候。
2.3 分组捕获与替换模式里的 $1、$2
正则里的圆括号除了用来分组,还能捕获匹配内容。配合replace()的替换字符串模式,你可以把捕获组重新排列组合。
比如要把2024-05-12从年月日改成月日年的格式:
const date = '2024-05-12'; const formatted = date.replace( /(\d{4})-(\d{2})-(\d{2})/, '$2/$3/$1' ); console.log(formatted); // '05/12/2024'替换字符串里的$1代表第一个捕获组,也就是(\d{4})匹配到的2024;$2是第二个捕获组,$3是第三个。这种写法非常直观,比写回调函数更快。
除了$1、$2这种数字捕获组,ES2018 之后还支持命名捕获组。写法是把括号改成(?<name>...)的形式,替换字符串里用$<name>引用:
const named = date.replace( /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/, '$<month>/$<day>/$<year>' ); console.log(named); // '05/12/2024'命名捕获组的可读性更好,尤其当正则里有七八个组的时候,$4、$5这样的写法很容易搞混,用名字就不会。需要注意命名捕获组对运行环境的兼容性要求较高,较老的浏览器或运行时可能不支持。如果项目需要兼容旧环境,还是老老实实用数字组。
3.1 回调函数的参数逐个拆解
替换字符串虽然方便,但只能做固定模式的拼接。一旦替换逻辑需要条件判断、计算、查表,就必须换成回调函数。
const str = '2024-05-12'; str.replace(/(\d{4})-(\d{2})-(\d{2})/, function (...args) { console.log(args); // 输出: // ['2024-05-12', '2024', '05', '12', 0, '2024-05-12'] });回调函数拿到的参数依次是:
- 第一个参数:整个匹配到的字符串
- 之后若干个参数:每个捕获组对应的内容,有几个组就有几个参数
- 倒数第二个参数:匹配内容在原始字符串中的起始索引
- 最后一个参数:原始完整字符串
如果用命名捕获组,回调函数的第二参数开始会变成对象,里面是捕获组的键值对。不过要注意命名捕获组的回调参数格式在不同运行环境下有过变化,现在主流规范是以“参数列表末尾追加一个对象”的形式,写业务代码时要确认目标环境的支持情况。
回调函数的返回值会作为最终替换内容插入到结果里。如果函数没有return,默认返回undefined,undefined会被强转为字符串"undefined"拼进结果。这是个隐蔽的大坑,我的经验是回调函数里一定要显式return一个字符串,哪怕想保留原匹配也要return match。
3.2 用回调实现字典替换和条件判断
回调函数最大的价值在于:替换内容可以动态计算,而不是固定字符串。
举一个我在数据处理中常用的场景:文件大小单位转换。假设有一段文本里写满了以 MB 为单位的数字,你要把所有 MB 值统一转成 GB:
const text = '文件大小:12MB,剩余空间:300MB'; const result = text.replace(/(\d+)(MB|GB)/g, (match, num, unit) => { if (unit === 'GB') { return match; // 已经是 GB,原样保留 } return `${(Number(num) / 1024).toFixed(2)}GB`; }); console.log(result); // '文件大小:0.01GB,剩余空间:0.29GB'这里match是完整的12MB,num是12,unit是MB。回调函数先判断单位,只有 MB 才做换算,GB 直接返回原匹配。这种逻辑用替换字符串模式根本写不出来。
承受能力再强一点的场景:把多种物品名称映射成分类名称。
const dict = { apple: '水果', banana: '水果', carrot: '蔬菜' }; const text = 'I like apple, banana and carrot.'; const result = text.replace( new RegExp(Object.keys(dict).join('|'), 'g'), match => dict[match] ); console.log(result); // 'I like 水果, 水果 and 蔬菜.'这里用Object.keys(dict).join('|')动态构造了一个多选分支的正则,每次匹配到的单词在回调里查字典得到中文分类。这个模式特别适合做轻量级的关键词映射、术语替换、品牌词替换等。
3.3 用回调做安全的动态关键词替换
动态构造正则时,最危险的一点是用户输入或者字典 key 里可能含有正则元字符。比如你想把文本里的hello.world这个字符串作为关键词替换掉,直接拼进正则:
const keyword = 'hello.world'; const text = 'hello.world is a keyword, hello.world'; text.replace(new RegExp(keyword, 'g'), '***'); // 意外结果:'helloxworld is a keyword, helloxworld'因为.在正则里会匹配任意字符,所以hello.world变成了匹配hello+ 任意一个字符 +world,连helloxworld都被误伤了。
解决办法是在拼正则之前,先把关键词里的所有元字符转义:
function escapeRegExp(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } const escaped = escapeRegExp(keyword); // 'hello\.world' const safeRegExp = new RegExp(escaped, 'g'); text.replace(safeRegExp, '***');这个escapeRegExp函数我是从正则实践中总结出来的,几乎所有需要动态关键词匹配的场合都能用。注意替换字符串里那个'\\$&':$&在replace()替换模式中代表“已匹配到的内容”,前面再加\\转义成一个反斜杠,这样就能在关键词每个元字符前面补一个反斜杠。
如果字典里有多个关键词,并且存在互相包含的情况,建议按关键词长度降序排序后再 join,避免短词抢先匹配导致长词永远匹配不到:
const keys = Object.keys(dict).sort((a, b) => b.length - a.length); const pattern = new RegExp(keys.map(escapeRegExp).join('|'), 'g');这种细节在真实业务里很容易被忽略,等线上出现替换错乱时再排查,成本就高了。
4. 高频实战场景拆解:URL 参数、日期、千分位、模板替换
4.1 URL 查询参数获取与替换
前端开发里解析 URL 查询参数是高频操作。虽然现代浏览器有URLSearchParams,但有些场景(比如处理非标准 URL、兼容旧环境、或者在一个字符串片段里提取参数)下,用replace()配合正则会非常灵活。
先写一个读取参数值的方法:
function getQueryParam(url, key) { const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const match = url.match(new RegExp(`[?&]${escaped}=([^&]*)`)); return match ? decodeURIComponent(match[1]) : null; } getQueryParam('https://example.com/page?userId=1001&type=test', 'userId'); // '1001'正则[?&]保证userId前面必须是?或&,这样不会误匹配到userId2这种参数。([^&]*)捕获等号后面的值,直到遇到&或字符串结尾。
替换某个参数的值稍微复杂一点,但核心仍然是replace():
function setQueryParam(url, key, value) { const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regex = new RegExp(`([?&])${escaped}=([^&]*)`); const encoded = encodeURIComponent(value); if (regex.test(url)) { return url.replace(regex, `$1${key}=${encoded}`); } return url + (url.includes('?') ? '&' : '?') + `${key}=${encoded}`; } setQueryParam('https://example.com/page?userId=1001', 'userId', '2002'); // 'https://example.com/page?userId=2002'这里替换字符串里的$1引用的是([?&])捕获到的内容,也就是原来的?或&,这样能保留 URL 原有的结构。测试一下regex.test(url)是为了判断参数原本是否存在,存在就走替换分支,不存在就走追加分支。这个函数是我实际项目中反复用过的方案,注意regex不带g,所以可以安全地test()完再replace(),不会出现lastIndex问题。
4.2 日期格式统一:多种分隔符一键归一
不同系统导出的日期格式五花八门,有斜杠的、有横杠的、还有点号的。要统一成同一种格式,用replace()加字符类很省事:
const mixed = '2024/05/12 2024.05.13 2024-05-14'; const normalized = mixed.replace( /(\d{4})[\/.\-](\d{1,2})[\/.\-](\d{1,2})/g, '$1-$2-$3' ); console.log(normalized); // '2024-05-12 2024-05-13 2024-05-14'正则里的[\/.\-]是一个字符类,意思是匹配/、.、-中的任意一个。因为括号捕获了三个数字段,替换模式用$1-$2-$3重新拼成统一格式。同时我加了g,确保一行文本里有多个日期时全部生效。
如果还要把05这种个位数字补成05已经是两位数的情况,那就更简单了,因为(\d{1,2})捕获到的就是原始内容,不会自己补零。需要补零的话,就得换成回调函数:
'2024/5/12'.replace( /(\d{4})[\/.\-](\d{1,2})[\/.\-](\d{1,2})/, (match, y, m, d) => `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}` ); // '2024-05-12'这就是字符串替换模式和回调函数的差别:前者只能重排已有内容,后者可以加工已有内容。
4.3 金额千分位格式化:经典正则一次搞定
处理金额数据时,经常需要把1234567.8910格式化成1,234,567.8910。这个需求网上流传的正则写法不少,我最常用的是这个:
const amount = '1234567.8910'; const formatted = amount.replace(/\B(?=(\d{3})+(?!\d))/g, ','); console.log(formatted); // '1,234,567.8910'拆解一下这个正则:
\B表示非单词边界,保证不会在数字串的最前面插入逗号。(?=(\d{3})+(?!\d))是一个正向先行断言,它要求当前位置后面必须跟有一组或多组恰好三个数字,并且这些三位数组的后面不能再跟数字。- 整个表达式配合
g,就会在满足条件的每个位置插入一个逗号。
需要注意,如果数据本身就是数字类型,要先转成字符串再调用replace(),因为Number类型没有这个方法。还有,这个正则对小数部分的处理是合理的,它只在整数部分加逗号,小数部分不会加。
如果金额是带负号的,比如-1234567.89,\B也能正确处理,结果会是-1,234,567.89,不会在负号后面直接加逗号。
4.4 模板占位符批量替换:把 replace 当模板引擎用
很多简单场景不需要引入模板引擎库,用一个replace()配合回调就能完成占位符替换。比如短信模板、通知文案等:
const template = '订单{{orderId}}已由{{user}}签收'; const data = { orderId: 'A1001', user: '某开发者' }; const output = template.replace( /\{\{\s*(\w+)\s*\}\}/g, (match, key) => data[key] ?? match ); console.log(output); // '订单A1001已由某开发者签收'正则/\{\{\s*(\w+)\s*\}\}/g匹配{{orderId}}这样的结构,\s*允许占位符内部有空格,(\w+)捕获变量名。回调函数里通过变量名查data对象,如果字段不存在,?? match会让你把原来的占位符保留下来,而不是替换成undefined。
如果你要支持{{user.name}}这种嵌套路径,可以稍微改一下捕获规则:
const output2 = template.replace( /\{\{\s*([^}]+?)\s*\}\}/g, (match, path) => path.split('.').reduce((obj, key) => obj?.[key], data) ?? match );这里([^}]+?)可以捕获点路径,然后用reduce()逐层取属性。注意这种写法更适合小规模、可信的模板数据,如果模板里有复杂表达式,还是老老实实用成熟的模板引擎。
4.5 文本清洗与关键词标亮:动态正则加回调组合拳
搜索功能里经常要标亮结果中的关键词。最基本的做法是用RegExp构造函数配合g标志:
const text = '正则表达式在 JavaScript 里非常强大,正则表达式用好能省很多事。'; const keyword = '正则表达式'; const highlighted = text.replace( new RegExp(keyword, 'g'), match => `<mark>${match}</mark>` ); console.log(highlighted); // '<mark>正则表达式</mark>在 JavaScript 里非常强大,<mark>正则表达式</mark>用好能省很多事。'这里用回调函数返回整个标亮标签,比直接写替换字符串'<mark>$&</mark>'更直观。回调函数拿到的match就是匹配到的原文,可以放心地塞进标签里。
如果关键词来自用户输入,记得先走一遍escapeRegExp转义,否则用户搜一个hello.world会造成误匹配。还需要考虑大小写问题,通常搜索是忽略大小写的,可以在构造正则时加上i标志:
const regex = new RegExp(escapeRegExp(keyword), 'gi');不要小看文本清洗这个场景,数据接入、日志脱敏、前后端渲染预处理,到处都有动态正则加replace()的身影。
5. 常见问题与排查技巧实录:五个高频坑一次说清
5.1 只替换了第一处:忘了全局标志 g
这是出现频率最高的一个问题。症状是这个replace()只替换了第一个匹配,后面的原封不动。
原因就两个:第一个参数传了字符串,或者正则没带g。
'aa'.replace('a', 'b'); // 'ba',字符串参数只处理第一处 'aa'.replace(/a/, 'b'); // 'ba',不带 g 的正则也只处理第一处 'aa'.replace(/a/g, 'b'); // 'bb',带 g 才全局替换如果目标环境支持 ES2021 及以后,还可以直接用replaceAll():
'aa'.replaceAll('a', 'b'); // 'bb'但replaceAll()有个限制:如果第一个参数传的是正则,这个正则必须带g,不带g会直接抛TypeError。所以日常我仍然推荐习惯性地使用带g的正则。
5.2 替换内容里的 $ 符号被吞掉或变成奇怪结果
replace()的替换字符串里,$是一个特殊字符。$1、$2引用捕获组,$&代表整个匹配,$\`` 代表匹配位置左边的内容,$'代表匹配位置右边的内容,$$代表一个字面量$`。
所以你试图把abc中的a替换成$&时,得到的结果不是字面量$&,而是整个匹配内容:
'abcd'.replace('a', '$&'); // 'aabcd',$& 被解析成了匹配到的 'a'想要输出字面量$&,必须写成$$&:
'abcd'.replace('a', '$$&'); // '$&bcd'这个坑最容易出现在用户提交的替换文本场景里。如果用户输入的替换内容里恰好有$,再经过replace()处理,结果完全不是预期。处理方法是在把用户输入放进替换字符串之前,先把所有$替换成$$:
const userReplacement = '$100'; const safeReplacement = userReplacement.replaceAll('$', '$$'); 'abcd'.replace('a', safeReplacement); // '$100bcd'静下心想,replace()的替换字符串本身就是一个微型模板,$是模板语法的一部分,不是普通文本。
5.3 想匹配点号星号却失效:正则转义分不清
这里有两类容易混淆的情况。
第一类:在正则里想匹配字面意义上的点号或星号,必须转义。/./g匹配任意字符,/\./g才匹配点号。
第二类:通过new RegExp()从字符串构造正则时,字符串里的反斜杠还要再多一层转义。写字符串字面量'\\.'传给RegExp,等价于正则里的\.。
const price = 'apple: 5.5, banana: 6.6'; price.replace(/\./g, '#'); // 'apple: 5#5, banana: 6#6' price.replace(new RegExp('\\.', 'g'), '#'); // 同样正确 price.replace(new RegExp('.', 'g'), '#'); // '#########...',错误,因为 . 匹配了所有字符排查这类问题时,我的思路是先确认正则模式本身是否正确,可以先把正则打印出来看看:
console.log(new RegExp('\\.')); // /\./ console.log(new RegExp('.')); // /./一眼就能看出问题。
5.4 同一个带 g 正则对象复用时的 lastIndex 坑
带g的正则是“有状态”的,它内部维护着一个lastIndex属性,记录上次匹配结束的位置。这个属性直接影响exec()和test()的结果,但容易让人困惑的是它和replace()之间的关系。
先说一个很多人都会遇到的现象:
const re = /\d/g; console.log(re.test('a1')); // true console.log(re.test('a1')); // false,第二次因为 lastIndex 不为 0 且后面没有匹配了第一次test()成功后,re.lastIndex变成了2,第二次再test()同一个字符串时,它会从位置 2 开始继续找,找不到数字就返回false,并把lastIndex重置为 0。连续两次调用结果不一样,很多人第一次遇到都会懵。
关于replace(),规范要求接收全局正则时,会先把lastIndex重置为 0 再从开头匹配,所以大多数情况下replace()本身不受之前exec()残留的lastIndex影响。但如果你在一个循环里用同一个带g的正则手动exec(),中途break退出,这个正则的lastIndex会停在半路。之后如果再拿它去test()其他字符串,结果会从半路位置开始,非常诡异。
我的建议是:日常写代码不要复用同一个带g的正则对象跨场景使用。如果需要重新开始匹配,显式把lastIndex重置为 0:
const re = /\d/g; re.lastIndex = 0; // 重置后再使用 const text = 'a1 b2'; while ((m = re.exec(text)) !== null) { // ... }或者更干脆,每次需要新匹配就创建一个新的正则对象,避免状态残留。
5.5 常见问题速查表
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 只替换了第一处匹配 | 第一个参数是字符串,或正则没带g | 正则加g,或使用replaceAll() |
替换文本中的$结果不对 | $在替换模式里是特殊字符 | 用$$表示字面$,或先做一次替换 |
| 点号星号匹配不到或匹配过多 | 正则里元字符没有转义 | 匹配字面量时写成\.、\*,new RegExp要多一层\\ |
同一个正则反复test()结果不稳定 | 带g的正则lastIndex状态残留 | 重置lastIndex = 0,或每次新建正则对象 |
回调函数没return | 返回值被转成undefined字符串 | 确保显式返回字符串 |
| 动态关键词误匹配 | 关键词里的元字符被当成正则语法 | 用escapeRegExp先转义关键词 |
最后再分享一个我自己的习惯。遇到需要连续多次replace()的文本清洗任务,我不会直接链式堆一大串,而是把规则按顺序放进数组,用reduce()串起来:
const rules = [ [/\s+/g, ' '], [/[<>]/g, ''], [/^\s+|\s+$/g, ''] ]; const raw = ' <div>hello world</div> '; const clean = rules.reduce( (text, [pattern, replacement]) => text.replace(pattern, replacement), raw ); console.log(clean); // 'hello world'这样每条规则一目了然,调整顺序也方便,比一大串链式调用更容易维护。如果你在项目里也经常用replace()处理字符串,我强烈建议把escapeRegExp、零宽断言、回调函数这几个工具真正揉进自己的代码习惯里,它们会让你处理起字符串来顺手很多。