写了好几年 JavaScript,我慢慢发现一个规律:那些看起来最简单的 API,恰恰是最容易被低估的。String.prototype.replace就是这么个典型。说它简单,因为几乎每个前端每天都会碰到;说它被低估,是因为绝大多数人只停留在str.replace('a', 'b')的用法上,遇到稍微复杂一点的文本处理需求,就开始绕路——有人用split再join,有人手写循环,有人把正则写得惨不忍睹。而实际上,replace 这两个参数的正确用法,足以让它成为你手里的"文本处理工具箱"。
这篇文章我不会讲那种入门级别的字符串替换,而是把 JS string replace 的高级用法彻底摊开:第一个参数选字符串还是正则、第二个参数选字符串还是函数、$特殊替换模式怎么用、捕获组怎么在替换过程中参与计算、以及实战里最常见的几个工具函数。看完之后你会发现,很多以前要写十几行的逻辑,一行 replace 就解决了。
1. 为什么 replace 值得专门写一篇:从"替换字符串"到"文本处理利器"
1.1 绝大多数人只用了 replace 不到 20% 的能力
先聊一个我经常在代码评审里看到的场景:需求是把字符串里所有的空格替换成下划线。新人的写法往往是这样:
const str = 'a b c d'; const result = str.split(' ').join('_'); // 'a_b_c_d'没错,结果是对的。但这段代码读完,你脑子里要先做一次"拆分再合并"的映射,才能理解它到底干了什么。换成 replace 加正则:
const str = 'a b c d'; const result = str.replace(/ /g, '_'); // 'a_b_c_d'一个方法调用,意图清清楚楚。这还只是最基础的差异。真正拉开差距的地方在于,replace 的第二个参数可以是一个函数,这个函数能拿到匹配到的所有信息,然后动态决定替换成什么。配合正则的捕获组,它完全能胜任"查找、提取、重组、格式化"这一整条链路。
很多人在遇到这类需求时,第一反应是"用正则去 match",拿到数组之后再写 for 循环去处理。这当然也是一种方案,但 replace 的做法更优雅:找到目标的同时直接完成替换或者重组,一次性到位,不需要中间态。
1.2 replace 的完整签名:两个参数背后的真实规则
在深入之前,先把 API 本身啃透。String.prototype.replace(searchValue, replaceValue)接收两个参数:
- searchValue:可以是一个字符串,也可以是一个正则表达式对象。如果传字符串,它只会被当作普通文本来查找,并且只替换第一个匹配到的地方(这点很多人踩坑)。
- replaceValue:可以是一个字符串,也可以是一个函数。如果传字符串,里面可以嵌入
$开头的特殊模式来引用匹配结果;如果传函数,函数会被调用,返回值就是最终替换进去的内容。
有一点要牢记:replace返回的是替换后的新字符串,原字符串不会被修改。因为字符串在 JavaScript 里是不可变的,所以每次调用 replace,只要匹配发生,就会创建一个全新的字符串。这个特性对性能有影响,后面我会专门讲。
另外有个非常容易忽略的细节——当 searchValue 是字符串时,replaceValue 里的$模式仍然会被解析。比如:
'abc'.replace('b', '$$'); // 'a$c'你没看错,$$被解析成了字面量的$。很多人在"动态拼接替换文本"的时候会踩到这个坑:明明想插入的是美元符号$,结果发现结果比预期少了点什么。这个机制我们稍后在$模式详解里展开,这里先记住一个结论:只要第二个参数是字符串,$就是特殊字符,想插入字面量$必须写成$$。
2. 正则模式与字符串模式:replace 第一个参数的选型逻辑
2.1 字符串模式的"只替换第一个"陷阱
先说字符串作为第一个参数。它的查找逻辑是纯粹的"完全匹配":在目标字符串里找到第一处与搜索值完全相同的位置,然后替换掉。注意,是完全匹配,不是"包含"。
const str = 'hello world, hello js'; str.replace('hello', 'hi'); // 'hi world, hello js'第二个 "hello" 安然无恙。这个行为让很多人意外,因为直觉上"替换"应该是把所有相同的都换掉。但规范就是这么定的:字符串模式不需要正则引擎去扫描全部,找到第一个就收工。
所以当你需要把所有匹配项都替换掉时,有两个选择:一是用正则表达式并加g标志,二是直接用 ES2021 引入的String.prototype.replaceAll。后者要求第一个参数必须是字符串或者正则,但是正则必须带g标志,否则直接抛 TypeError。
'hello world, hello js'.replaceAll('hello', 'hi'); // 'hi world, hi js'看到这里你应该明白一个选择逻辑:如果搜索目标是一个固定的字面量文本,并且要做全量替换,优先用 replaceAll;如果搜索目标是一个模式(比如"连续数字""行首空白"),就必须用正则 +g。
2.2 正则模式的全局标志 g:能否替换全部的关键
正则作为第一个参数时,有三个标志和 replace 的关系最密切:g、i、m。
g(global):全局匹配。没有它,正则也只替换第一个匹配结果。i(ignoreCase):忽略大小写。m(multiline):多行模式下^和$才能匹配每一行的开头和结尾,而不是整个字符串的开头结尾。
举一个组合使用的例子:
const code = `const foo = 1; const Foo = 2; const BAR = 3;`; code.replace(/const /g, 'let '); // 替换所有 'const '如果是想把所有带 Foo 字样的大小写变体统一替换,就加i:
'foo Foo fOo'.replace(/foo/gi, 'bar'); // 'bar bar bar'这里有个经验之谈:正则在 replace 里最常见的 bug 就是忘记加g。你以为做了全量替换,结果只换了一个。我自己就因为在配置表里少写一个g,导致线上生成了半套错误数据,排查了快两个小时。后来我养成一个习惯:写正则需要全局替换时,先写/pattern/g再补其他标志,把它当成默认配置。
2.3 捕获组与反向引用:在替换中提取和复用匹配内容
正则真正强大的地方在于捕获组(capturing group)。括号()括起来的部分,在匹配过程中会把对应的子串单独记录下来。这些捕获组在 replace 里有两种用途:
- 在正则内部,可以用
\1、\2这样的反向引用来引用前面已经捕获的内容,用于实现"连续相同的字符"这类匹配。 - 在替换字符串里,可以用
$1、$2来引用捕获组。
先看反向引用的经典场景:找出一段文本中重复出现的单词。
const str = 'the the cat sat on the mat'; str.replace(/\b(\w+)\s+\1\b/gi, '$1'); // 'the cat sat on the mat'在这个例子里,\1引用的是前面(\w+)捕获到的内容。也就是说,只有"某个单词 + 空白 + 同一个单词"这种模式才会被匹配,然后替换成单个单词。这一步其实就是简单的去重。
再看$1在替换字符串中的用法——日期格式转换是最通俗的例子:
const date = '2024-01-15'; date.replace(/(\d{4})-(\d{2})-(\d{2})/, '$3/$2/$1'); // '15/01/2024'这个操作的本质是:先把整段日期用三个捕获组拆开,再用$3/$2/$1重组。它做的不只是"替换",而是"解析 + 重组"。这种思路一旦打开,replace 就不再只是字符替换工具,而是一个小型文本处理器。
关于捕获组还有几个容易被忽略的点:
$10会被解析成第 10 个捕获组,而不是第 1 个捕获组加字符串'0'。如果你的正则只有 9 个以内分组,又想输出"第 1 组后面跟着 '0'",需要写成$1加0分开,比如${1}0这种写法在某些环境不支持,更稳妥的是用函数拼接。- 如果
$1指向的捕获组在匹配中没参与(比如用了|分支,某个分支没走),$1会替换成空字符串。 - 替换字符串里如果出现了未定义的分组引用,比如只有两个分组却写了
$3,那么$3会作为字面量文本保留下来,不会报错。这个行为在调试时特别容易迷惑人。
3. $ 特殊替换模式:隐藏在替换字符串里的迷你模板引擎
3.1 六个 $ 模式速查
replace 第二个参数传字符串时,$后面可以跟一组特殊模式。它们本质上是一个"迷你模板引擎",让你在替换字符串里直接引用匹配上下文。我把它们整理成了速查表:
| 模式 | 含义 | 示例 |
|---|---|---|
$$ | 插入一个字面量$ | 'a'.replace('a', '$$')→'$' |
$& | 插入匹配到的整个子串 | 'abc'.replace('b', '<$&>')→'a<b>c' |
$` | 插入匹配位置之前的所有内容 | 'abc'.replace('b', '$')→'aac'` |
$' | 插入匹配位置之后的所有内容 | 'abc'.replace('b', "$'")→'acc' |
$n | 插入第 n 个捕获组的内容(n 为 1-9) | '2024'.replace(/(2)(0)/, '$2$1')→'0224' |
$<name> | 插入命名捕获组的内容 | 'a'.replace(/(?<x>a)/, '$<x>')→'a' |
这里要重点解释一下$`和$',因为这俩在实际工作中用得少,很多人看不懂示例结果。
const str = 'abc'; str.replace('b', '$`'); // 结果是 'aac' str.replace('b', "$'"); // 结果是 'acc'第一个:原始字符串是'abc',匹配到'b'。$`表示"匹配位置前面的内容",也就是'a',所以替换结果是'a' + 'a' + 'c',即'aac'。第二个:$'表示"匹配位置后面的内容",也就是'c',所以结果是'a' + 'c' + 'c',即'acc'。
这两个模式在日常业务里不常用,但在做括号包裹、引用处理、日志脱敏等场景下可以写出非常紧凑的代码。比如给所有匹配项加包围标记:
'foo bar baz'.replace(/bar/, '[$&]'); // 'foo [bar] baz'用$&的好处是:你不用预先知道匹配到的到底是什么内容,正则匹配到什么,它就原样放进替换结果里。
3.2 用 $ 模式做批量包装与文本脱敏
$&在实际业务里最典型的场景,是给文本中的关键词加高亮标记。比如用户输入的搜索词,要在搜索结果里把这个词标记出来:
function highlight(text, keyword) { const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return text.replace(new RegExp(`(${escaped})`, 'gi'), '<mark>$1</mark>'); } highlight('JavaScript is fun, but JS can be tricky', 'js'); // 'JavaScript is fun, but <mark>JS</mark> can be tricky'这个例子里还有一个细节值得注意:keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')。\\$&的含义是"反斜杠加上匹配到的字符本身"。用户输入的搜索词里如果带了.或者*这些正则元字符,直接拼进正则里会导致正则语法错误或者匹配逻辑偏差,所以必须先转义。这里的$&就派上了用场——不管你输入的是什么字符,我都能原样拿回来并在前面加一个反斜杠。
3.3 命名捕获组:$ 让代码可读性翻倍
当捕获组多了以后,$1、$2、$3这种数字引用很容易让人看花眼。ES2018 引入了命名捕获组,允许你给分组起名字,然后在替换字符串里用$<name>来引用:
const log = 'level=INFO time=2024-01-15 message=hello'; const parsed = log.replace( /level=(?<level>\w+) time=(?<time>[\d-]+) message=(?<msg>\w+)/, '<$<msg>|$<time>|$<level>>' ); // '<hello|2024-01-15|INFO>'用命名捕获组最大的价值不是为了省那几个字符,而是让正则的语义自解释。三个月后回看这段代码,$<time>一眼就知道是时间字段,$3你还得回去数括号。
还有一个容易被忽略的点:正则里的\k<name>语法可以引用命名捕获组本身,用于实现更复杂的模式匹配。比如匹配"同一个单词重复两遍"可以写成:
'hello hello'.replace(/(?<word>\w+)\s+\k<word>/, '$<word>'); // 'hello'我记得在重构一个旧项目的日志解析模块时,把数字分组改成命名分组后,整个正则的可维护性提升了一个档次。如果你们团队的代码要长期维护,强烈建议从今天开始,捕获组超过两个就用命名捕获组。
4. 第二个参数传函数:动态替换的真正核心
4.1 回调函数的参数拆解:match、p1、offset、string
真正的重头戏来了。replace 的第二个参数传函数,是它从"替换工具"升级为"文本处理引擎"的关键。
函数的参数列表是这样的:
str.replace(regex, function(match, p1, p2, ..., offset, string) { return replacement; });逐个解释:
- match:匹配到的完整子串(等价于
$&)。 - p1, p2, ...:依次对应正则里的第 1、第 2 ... 个捕获组。如果正则里没有捕获组,这些参数就不存在。
- offset:本次匹配在原字符串中的起始位置(从 0 计数)。
- string:被匹配的原始完整字符串。
一个标准示例:
const str = 'abc123DEF'; str.replace(/([a-zA-Z]+)(\d+)/, (match, letters, digits, offset, src) => { console.log(match); // 'abc123' console.log(letters); // 'abc' console.log(digits); // '123' console.log(offset); // 0 return letters.toUpperCase() + digits; }); // 'ABC123DEF'很多人第一次看到这个签名会懵:为什么函数参数不直接给一个对象,而是这样散着传?历史原因如此,早期设计就是这样延续下来的。实际写的时候,最常见的错误是把 offset 当成了 p1。记住一个规律:除了第一个参数 match 是固定的,剩下的位置和正则里的捕获组一一对应,捕获组之后才是 offset 和 string。
如果使用箭头函数,并且正则里没有捕获组,参数列表会变得很短:
'hello'.replace(/l/, (match, offset) => { console.log(match, offset); // 'l' 2 return 'L'; }); // 'heLLo'(注意只替换了第一个 l,因为没有 g 标志)4.2 动态计算场景:用函数返回值做判断
函数参数的第一个价值,是替换内容需要动态计算,不能预先写成静态字符串。
比如数字格式化:把超过 1000 的数字替换成带单位的缩写:
const format = (str) => str.replace(/\d+/g, (num) => { const n = Number(num); if (n >= 10000) return (n / 10000).toFixed(1) + '万'; if (n >= 1000) return (n / 1000).toFixed(1) + 'k'; return num; }); format('今日新增用户 15432,活跃 8763'); // '今日新增用户 1.5万,活跃 8.8k'这种场景下,静态替换字符串完全做不到——你无法预知匹配到的数字是多少。而函数参数天生就是干这个的:匹配到内容 → 转成数字 → 判断区间 → 生成替换文本。
另一个常见场景是按条件选择性保留内容:
const str = '苹果 12元,香蕉 8元,樱桃 30元'; str.replace(/(\w+) (\d+)元/g, (match, name, price) => { if (Number(price) < 10) return match; // 低价商品保留原样 return `${name} ${Number(price) * 0.9}元`; // 高价商品打九折 }); // '苹果 12元,香蕉 8元,樱桃 27元'这里注意:当函数返回的是原始match时,这段文本就等于"没被修改"。这个技巧在处理"只改部分匹配项"的需求时非常实用。
4.3 函数 + 捕获组的组合拳:从"替换字符"到"处理数据"
前面说到 replace 可以完成"解析 + 重组",配合函数参数后它还能多做一件事:在重组过程中执行逻辑。
举个例子,把一段 URL 里的路径参数提取出来并重新拼装:
const url = '/api/users/1234/posts/5678'; const newUrl = url.replace( /\/api\/(?<resource>\w+)\/(?<id>\d+)\/posts\/(?<postId>\d+)/, (match, resource, id, postId, offset, original) => { // 这里可以做权限校验、日志记录、ID 加密等 return `/internal/${resource}/${id}/post/${Number(postId) + 1}`; } ); // '/internal/users/1234/post/5679'看到没,我在回调函数里能做任意运算,比如对postId加一、做格式化、查缓存、拼接额外参数。这种能力让 replace 在实际工程里拥有了极高的自由度。
更极致的场景是解析"类模板字符串"的文本。假设你有一段配置文本,里面嵌入了几个占位符,需要替换成真实数据:
const template = 'USER:{{name}}, AGE:{{age}}, CITY:{{city}}'; const data = { name: 'Alice', age: 18, city: 'Shanghai' }; const rendered = template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => { return data[key] !== undefined ? data[key] : match; }); // 'USER:Alice, AGE:18, CITY:Shanghai'这个模式的巧妙之处在于:占位符{{...}}被捕获组拆开,key 直接作为对象的键去取值;如果数据里没有这个键,就保留原始占位符,不会把模板搞坏。后面我会在实战章节里基于这个思路做一个更完整的模板渲染函数。
4.4 新手常踩的坑:回调里的作用域和返回值
函数参数虽然灵活,但有几个坑是高频事故现场。
坑一:回调函数内使用 this 却拿不到预期对象。如果回调不是箭头函数,replace 在调用它时,内部的this是 undefined(严格模式下)或者全局对象(非严格模式)。所以想在回调里访问外层数据,最稳妥的是用箭头函数,或者在外层先把需要的值存到局部变量里。
const dict = { foo: 'bar' }; // 错误示范:this 指向不对 'foo'.replace(/foo/, function(match) { return this[match]; // this 不是 dict }); // 正确做法:箭头函数继承外层 this 'foo'.replace(/foo/, (match) => dict[match]); // 'bar'坑二:回调没有 return。如果回调函数里忘了return,replace 会用undefined作为替换结果。这在字符串拼接时表现成字符串"undefined",非常隐蔽。
'abc'.replace(/b/, (match) => { match.toUpperCase(); // 忘了 return }); // 结果是 'aundefinedc',不是 'aBc'坑三:回调函数不能是异步的。replace 的回调是同步执行的,你不能在里面 await 一个异步接口,然后期望 replace 等它返回。如果替换内容依赖异步数据,必须先拿到数据再执行替换,或者换用其他方案(比如逐段处理和 Promise 链)。
5. 实战:用 replace 实现的几个高频工具函数
5.1 数字千分位格式化:一行正则的经典
先来看最多人问的:怎么把数字字符串格式化成千分位形式。网上一搜能搜到一堆答案,但很多都是几十行的循环逻辑。用 replace 加一个正则,一行搞定:
const formatThousands = (num) => { return String(num).replace(/\B(?=(\d{3})+(?!\d))/g, ','); }; formatThousands(1234567.89); // '1,234,567.89'拆解一下这个正则:
\B匹配非单词边界。在数字字符串里,就是相邻两个数字字符之间的位置。它保证我们不会在字符串开头插入逗号。(?=(\d{3})+(?!\d))是一个正向肯定预查,它匹配一个位置,这个位置后面必须跟着"一个或多个 3 位数字组",并且这组数字的末尾不能再跟数字。(?!\d)是正向否定预查,确保(\d{3})+匹配的是整段数字的完整结尾。
这个例子是我心中"正则 + replace"的最佳教学案例:它没有捕获组参与替换,纯粹靠预查判断位置,然后插入逗号。理解了这个正则在做什么,再看其他格式化需求会轻松很多。
5.2 驼峰与中划线互转:正则 + 函数的教科书案例
CSS 属性名转驼峰、路由参数转中划线,这类需求在前端几乎天天见。replace 加函数参数,两个方向都能优雅实现。
中划线转驼峰:
function hyphenToCamel(str) { return str.replace(/-([a-z])/gi, (match, letter) => letter.toUpperCase()); } hyphenToCamel('font-size'); // 'fontSize' hyphenToCamel('border-left-width'); // 'borderLeftWidth'这个写法的核心思路:匹配到-加一个字母,然后用这个字母的大写替换整个匹配。注意正则里([a-z])这个捕获组,只捕获字母本身,所以-f会被替换成F,连带去掉了-。
驼峰转中划线:
function camelToHyphen(str) { return str.replace(/([A-Z])/g, '-$1').toLowerCase(); } camelToHyphen('fontSize'); // 'font-size' camelToHyphen('borderLeftWidth'); // 'border-left-width'这个更直白:匹配大写字母,在前面插入一个-,然后整体转小写。$1让被匹配的大写字母得以保留。
这类函数在实际工程里还能继续扩展。比如处理React组件的样式对象转 CSS 变量名,或者把后端返回的snake_case字段转成驼峰。思路完全一致,换个正则而已。
5.3 模板字符串占位符渲染:维护一套简易模板引擎
前面在动态计算小节里介绍过占位符替换的基本思路。这里补全成一个完整的简易模板渲染函数,支持默认值和嵌套数据读取:
function renderTemplate(template, data) { return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (match, path) => { const value = path.split('.').reduce((acc, key) => { return acc == null ? acc : acc[key]; }, data); return value === undefined || value === null ? match : String(value); }); } const tpl = 'Hello, {{ user.name }}! Your score is {{ score }}.'; const data = { user: { name: 'Bob' }, score: 88 }; renderTemplate(tpl, data); // 'Hello, Bob! Your score is 88.'这个函数做了三件事:
- 用正则把
{{ }}占位符拆出来,捕获组拿到占位符内部的路径字符串,比如user.name。 - 通过
reduce在 data 上逐层取值,这样user.name才能正确拿到嵌套数据。 - 取不到值时返回原始占位符,避免渲染出
undefined或null字符串。
这个版本虽然离 Vue 的插值语法还有很多距离,但作为一个通用配置模板渲染器,在生成报表、拼接邮件、渲染约定格式的合同文本这些场景里完全够用。
5.4 文本批量清洗:多规则链式处理
真实业务里的文本清洗往往是多条规则叠加:去掉多余空格、统一标点、修正错误换行、去掉行首行尾空白字符。用 replace 写链式处理,可读性和维护性是最好的:
function cleanText(text) { return text .replace(/\r\n/g, '\n') // 统一换行符 .replace(/\s+/g, ' ') // 多个空白合并为一个空格 .replace(/\s+([.,:;!?])/g, '$1') // 标点前不留空格 .replace(/([.,:;!?])(?=\w)/g, '$1 ') // 英文标点后补一个空格 .replace(/[“”]/g, '"') // 统一中文引号为英文引号 .replace(/[‘’]/g, "'") // 统一中文单引号 .trim(); } cleanText('你好,世界!\r\nThis is a test, right? '); // '你好,世界!\nThis is a test, right? 你好…'注意每个正则都要加g标志,否则只会处理一处。我在实际项目中维护过类似的数据清洗管线,后来把它升级成"规则数组 + reduce"的形式:每条规则是一个[pattern, replacement]对,需要增删规则时只需要操作数组,不需要改动主体逻辑:
const rules = [ [/\r\n/g, '\n'], [/\s+/g, ' '], [/\s+([.,:;!?])/g, '$1'], [/[“”]/g, '"'], [/[‘’]/g, "'"], ]; const cleanText = (text) => rules.reduce( (acc, [pattern, replacement]) => acc.replace(pattern, replacement), text );这种设计的好处是:每个规则独立、可单测、可配置。如果产品哪天说"中文引号不用统一",删掉那一行就行,完全不影响其他规则。
6. 性能与坑点:replace 进阶路上的经验账
6.1 replaceAll 与全局正则:ES2021 新选择该不该用
String.prototype.replaceAll是 ES2021 的新特性。它的行为简单直接:用字符串或正则做全量替换。它和"正则 +g"的核心差异在于:
replaceAll的第一个参数如果是正则,这个正则必须带g标志,否则抛异常。- 如果只是做固定字符串的全量替换,
replaceAll不需要写正则,语义上也更直观。
关于性能和选型,我的实测结论是:
| 场景 | 推荐写法 | 原因 |
|---|---|---|
| 固定字符串,全量替换 | str.replaceAll('a', 'b') | 可读性最好,无正则开销 |
| 正则模式,全量替换 | str.replace(/a/g, 'b') | 必须用正则,g标志别漏 |
| 只替换第一处 | str.replace('a', 'b') | 符合语义,性能最优 |
| 需要动态计算替换结果 | str.replace(/(\w+)/g, fn) | 回调函数是唯一选择 |
有一个兼容性常量:replaceAll在旧版 Node 和部分老浏览器里不可用。如果你的运行环境很老,项目里又有大量"全量替换固定字符串"的需求,稳妥的做法是用一个replace配合正则,或者简单封装:
const replaceAll = (str, search, replacement) => { if (!search) return str; return str.replace(new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), replacement); };6.2 能不用正则就不用正则:性能对比与取舍
正则虽然强大,但它不是免费的。在长文本上跑复杂的正则,代价相当可观。以下几点是我的经验:
第一,能做字符串处理的优先用字符串方法。比如只是把'abc'里的'b'全部替换成'x',用replaceAll显然比写一个正更快、更安全。正则的解析和回溯都需要时间。
第二,避免灾难性回溯。如果正则写得不好,比如/(a+)+b/这种嵌套量词的结构,遇到不匹配的长字符串时,回溯次数会爆炸。我曾经在一个自动生成 SQL 的模块里,因为一条写得不严谨的正则,把接口的响应时间从 50ms 拖到了 4 秒。后来排查出来,就是该用split的地方用了过于宽泛的正则去桶。写正则时牢记一条原则:量词不要嵌套,必要时用非贪婪模式。
第三,高频调用场景下,正则对象要复用。很多人在循环里用new RegExp(...)构造正则,每循环一次都编译一次。应该把正则提到循环外面:
// 不推荐:每次循环都重新构造正则 for (const item of list) { item.replace(/\d+/g, ''); } // 推荐:正则对象只构造一次 const digitRegex = /\d+/g; for (const item of list) { item.replace(digitRegex, ''); }注意一个问题:如果正则带了g标志,并且手动调用过exec,它的lastIndex会被修改。在 replace 里这个影响通常不大,但如果你要复用一个全局正则对象做多次匹配,最好明确知道lastIndex的状态。
6.3 replace 的边界条件:别让它做能力之外的事
replace 虽然很能打,但有些事情它并不擅长,硬用会得不偿失。
比如嵌套结构的解析。如果要处理的文本存在递归嵌套,比如括号匹配、HTML 标签层级,这种场景靠正则 + replace 是写不出可靠方案的。正则本质上是有限状态机,处理不了真正的递归语法。这时候应该用专门的分析器,或者逐字符遍历的解析函数。
比如需要引用上下文做复杂状态判断。回调函数能拿到 offset 和原始字符串,但如果你需要"前面出现过几次某个模式"这种跨匹配状态的逻辑,在 replace 回调里要么在闭包外维护状态,要么直接改写事件式处理方式。在闭包里维护变量是可行的:
let count = 0; 'foo foo foo'.replace(/foo/g, (match) => { count++; return count === 2 ? 'bar' : match; }); // 'foo bar foo'但这种写法有个隐患:如果匹配中断,count 的状态依然保留。需要小心处理。
再比如正则回溯和贪婪匹配过度。如果文本很长,正则需要遍历全部的匹配,replace 天然是 O(n) 起步。遇到性能敏感的场景,先想想正则是不是简化了,或者是不是可以用indexOf+slice手动拼接。
另外,有个很多初学者忽略的行为:replace 的正则里如果用了^或$,加上m标志之后,匹配的是每行的开头和结尾。这个在清洗多行文本时经常遇到。比如给每行添加行号:
const text = 'apple\nbanana\ncherry'; text.replace(/^/gm, (match, offset) => `${offset / 5 + 1}: `); // '1: apple\n2: banana\n3: cherry'这个例子能跑通吗?^匹配的位置是每行开头,回调里的 offset 是实际偏移量,用它除以每行长度再加一,能得到行号。这个思路在生产环境可以优化成更严谨的实现,但至少说明了 replace 配合m标志能处理很多行级别的格式化需求。
回头再看,replace 这个 API 的设计其实非常精妙:第一个参数负责"定位",第二个参数负责"重组";字符串模式适合精确匹配,正则模式适合模式匹配,$模式适合静态模板,函数模式适合动态计算。把这四种能力组合起来,它从"找字符换字符"的工具,变成了一个能力完整的文本处理引擎。
最后分享一个我的个人习惯:写复杂的 replace 之前,我会先在注释里用自然语言描述一遍"我要匹配什么、匹配之后怎么重组、有什么边界条件",然后再去写正则和回调函数。这样做的好处是,正则本身的表达力和"一眼看懂"往往是矛盾的,先写人话再写代码,比直接对着正则猜语义要高效得多。还有一个贴身建议:给团队做 code review 时,看到超过一行的 replace 正则,一定要求作者在注释里补上一个输入示例和一个输出示例。这样既方便确认逻辑,也避免后人改坏。