
给一段文字加高亮,最省事的写法是把匹配到的内容用标签包住:
const marked = text.replace(keyword, "<mark>$&</mark>");
替换串写死的时候这招很好用,$& 就是刚才匹配到的那一截。麻烦出在替换串不由自己写的时候。搜索框旁边挂一个「替换为」输入框,或者模板从配置文件里读,填进去的内容刚好带一个 $,输出就变形:匹配到的词出现两遍,或者凭空多出一段后文。两边代码长得一样,结果差很远。
替换串按模板处理,先扫描再拼接
ECMA-262 里这段逻辑叫 GetSubstitution。引擎拿到替换串不会直接拼进结果,它从左往右扫,碰到 $ 就往后多看一个字符,对得上模式表的才展开,对不上原样留着。
== 1 $& 是匹配到的原文 ==
'abc'.replace('b', '$&$&') = "abbc"
'abc'.replace('b', '[$&]') = "a[b]c"
'a-b-c'.replace('-', '<$&>') = "a<->b-c"
$& 展开成这次匹配到的原文。abc 里把 b 换成 $&$& 得到 abbc,替换串里写了几个 $&,原文就复制几份。高亮函数能工作,靠的就是这一条。
前置与后置部分会把自己吐回来
匹配点左边那一整段,用美元符号紧跟一个反引号取到;右边那一整段,用美元符号紧跟一个单引号取到。两种写法给的都不是匹配本身,是它旁边的文字。
== 2 反引号与单引号:前置部分和后置部分 ==
'abc'.replace('b', '$`') = "aac"
'abc'.replace('b', "$'") = "acc"
'a-b-c'.replace('-', '($`)[$\']') = "a(a)[b-c]b-c"
abc 里的 b 换成前置模式得到 aac,前一段 a 被复制一份放到匹配处;换成后置模式得到 acc,复制的是后面那段 c。在 a-b-c 里替换第一个连字符,两段凑出来是 a(a)[b-c]b-c,原文 b-c 出现了两次。替换次数一多,膨胀得很快,原文有多长就多复制一份进去。
想要一个字面美元符号,写成两个
$$ 是模式表里唯一的转义写法,落地成一个 $,而且只吃一次:$$& 先变成 $,后面的 & 不再回头看,输出就是 $& 这两个字面字符。
== 3 $$ 得到一个美元符号 ==
'abc'.replace('b', '$$') = "a$c"
'ab'.replace('a', '$$&') = "$&b"
'price'.replace('price', '$$9.99') = "$9.99"
价格模板写成 $$9.99 得到 $9.99,第一个 $ 当转义起点,第二个落地。反过来,想输出字面的 $& 就绕不过去:$& 会被展开,$$& 才是那两个字符。
组号越界时,标记原样留在输出里
这一条最容易判断错。$n 里的 n 超过实际捕获组的数量,引擎不报错,也不会替换成空串,而是把 $n 原样放进结果。
== 4 捕获组编号在有组与无组时的差别 ==
'abc'.replace(/b/, '[$1]') = "a[$1]c"
'abc'.replace(/(b)/, '[$1]') = "a[b]c"
'abc'.replace('b', '[$1]') = "a[$1]c"
'a1b2'.replace(/(\d)/, '<$1>') = "a<1>b2"
'a1b2'.replace(/(\d)/g, '<$1>') = "a<1>b<2>"
abc 这个例子看得很清楚:正则没有捕获组时给出 a[$1]c,方括号里留着 $1 原文;加上括号变成 /(b)/ 才给出 a[b]c。传字符串 'b' 和不带组的正则一个待遇。只有一个组的时候写 $2,同样原样保留。
== 6 不存在的组号:$0 与 $99 ==
'abc'.replace(/(b)/, '[$0]') = "a[$0]c"
'abc'.replace(/(b)/, '[$99]') = "a[$99]c"
'abc'.replace(/(b)/, '[$2]') = "a[$2]c"
$0 不属于模式表,组号从 1 开始,所以它永远是两个字面字符。$99 在只有一个组时也是原样输出。这两点跟「越界就当空串」的直觉相反,写模板的时候最容易在这里翻车。
$10 为什么只剩 $1 展开
两位数字会被拆开看。引擎先按两位的组号试一次,越界就退回一位,剩下的数字当普通字符。
'ab'.replace(/(a)(b)/, '[$10]') = "[a0]"
'ab'.replace(/(a)(b)/, '[$1][$2]') = "[a][b]"
两个捕获组时 $10 解成 $1 加一个字符 0,结果是 [a0]。规范里写明了两位组号越界要退一位处理,所以只有十个以上捕获组的正则里,$10 才是第十组;组数不够的时候它是第一组后面粘一个零。
命名组要带尖括号
$<name> 对应 (?<name>...) 这种命名捕获组,展开成那一组匹配到的内容。日期重排是最常见的用法,三个组换个顺序就完事。
== 5 命名捕获组与 $<name> ==
'2026-10-20'.replace(/(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/, '$<d>/$<m>/$<y>') = "20/10/2026"
'abc'.replace(/b/, '$<x>') = "a$<x>c"
'abc'.replace(/(?<mid>b)/, '[$<mid>]') = "a[b]c"
名字对不上、或者正则压根没有命名组,$<x> 一样原样保留,abc 给出 a$<x>c。这条比编号那组更安静,尖括号看着就像普通符号,出错时不会立刻注意到。
换成函数,返回值不再被扫描
replacer 传函数时,替换串的扫描环节整个跳过。函数返回什么就插什么,$& 在返回值里只是两个字符。
== 7 函数 replacer 的返回值不再展开 ==
'abc'.replace('b', () => '$&') = "a$&c"
'abc'.replace('b', () => '$$') = "a$$c"
'abc'.replace(/(b)/, () => '[$1][$<x>]') = "a[$1][$<x>]c"
每次回调收到 (match, p1, offset, string):
"1" , "1" , 1 , "a1b2"
"2" , "2" , 3 , "a1b2"
回调收到的参数里,排在最前的是匹配到的原文,末尾两个是偏移量和原串,中间夹着各个捕获组。a1b2 用 /(\d)/g 替换时,两次调用的偏移量是 1 和 3,拼出来的标记直接落地,没有任何二次解释。要往结果里塞用户数据,函数形式更稳:不用记住哪个字符要转义,模板里的 $ 也不会被当成模式展开。
replaceAll 不收非全局正则
replaceAll 对正则的要求比 replace 严:正则没带 g 就直接抛 TypeError,而不是悄悄只换第一个。
== 8 replaceAll 遇到非全局正则 ==
'a-b-c'.replaceAll(/-/, '_') 抛出 TypeError
message = "String.prototype.replaceAll called with a non-global RegExp argument"
'a-b-c'.replaceAll(/-/g, '_') = "a_b_c"
'a-b-c'.replace('-', '_') = "a_b-c"
报错原文是 String.prototype.replaceAll called with a non-global RegExp argument。传字符串不受这条限制,'a-b-c'.replaceAll('-', '_') 会把两个连字符都换掉;同样的字符串交给 replace 只换第一处。这一对差异是两者最容易混的地方。
美元符号后面跟普通字符
模式表之外的组合一律原样保留。$x 输出 $x,单写一个 $ 也照原样,$ 后面跟空格同样不动。
== 9 美元符号后面跟普通字符 ==
'abc'.replace('b', '$x') = "a$xc"
'abc'.replace('b', '$') = "a$c"
'abc'.replace('b', '$ b') = "a$ bc"
'a.b'.replace('.', '$...') = "a$...b"
'abc'.replace('b', '$$x') = "a$xc"
== 9 结束 ==
模式表里的展开符号就那几个:$$、$&、$'、$<名字> 以及反引号形式,其余都当普通字符。'a.b'.replace('.', '$...') 给出 a$...b,三个点一个都没被吃掉。
替换串来自外部时有两条路:让调用方传函数,或者先把串里的 $ 全部换成 $$。我倾向后者,转义只要做一次,不用去猜模板作者的意图,也不依赖调用方记得传函数。