
替换串里带美元符号的场景不罕见:把命中的词包一层标签,把金额拼进结果,把用户填的模板套进正文,都要走 String.prototype.replace。这个 API 把替换串当成模板来解释,$ 开头的几个序列各有含义,而且解释与第一个参数的类型无关,给普通字符串照样触发。实测输出全部来自本机脚本 tools/verify-replace-specials.mjs,Node v26.8.1。
错法一:以为 $& 只是把查找词原样写回。
'a-b-c'.replace('-', '$&$&') 的直觉结果是把连字符换成两个连字符,或者干脆不变,实测给的是 a--b-c。$& 代表整个匹配,写两遍就把匹配复制两遍。同一条路数上,'xAy'.replace('A', '[$]')得到x[x]y,`` $ `` 把匹配之前的全部内容塞了进来。
错法二:要输出美元符号时手写一个 '$'。
金额拼接里写成 '$&' 的意图往往是「前面加个美元号」,实测 '价 12-b'.replace('-', '$&') 返回 价 12-b,连字符原地保留。想拿到字面量美元符号要写两个:'a-b'.replace('-', '$$') 才是 a$b。单个 $ 后面跟的不是这些特殊字符时按字面量处理,'a-b'.replace('-', '$_') 得到 a$_b,这也是 $_ 常被误认为有含义的原因。
错法三:写 $1 却忘了分组。
字符串模式下 'abc'.replace('b', '$1') 返回 a$1c,正则但一个分组都没有时 'abc'.replace(/b/, '$1') 也是 a$1c。加上分组才对:'abc'.replace(/(b)/, '$1') 返回 abc。这类错误不报错,$1 就那么留在结果里,比抛异常难查。
错法四:拿 $10 当第 1 组加一个 0。
实测两组的情况下 'ab'.replace(/(a)(b)/, '$10') 返回 a0,引擎把它读成第 1 组接字面量 0;而模式里真有十组时 '$10' 取的是第 10 组。同一个替换串在组数变化后含义会变,用两位数拼「第几组 + 尾数」不可靠,$01 这类写法只会取第 1 组,实测 'ab'.replace(/(a)(b)/, '$01') 返回 a。
错法五:把外部可控的文本直接当替换串。
'xAy'.replace('A', "[$']") 返回 x[y]y,$' 把匹配之后的全部剩余内容插进结果。用户提交的一段替换文本里带上 $& 或 $',输出长度就会跟着膨胀,文本也会串位。这是五条里唯一带安全味道的一条,也是唯一一条靠检查替换串内容来防堵会很难受的。
替换串只展开一次,展开产生的内容不会再被解释。'a$&b'.replace(/a(\$&)b/, '[$1]') 返回 [$&],捕获组里带着的美元符号原样落进结果;'ab'.replace(/(a)/, '$1$&$1') 返回 aaab。所以替换结果里再出现 $& 之类,不会被二次替换,要防的只有最开始那一次展开。反过来说,拼接替换串时越靠近入口就越安全:把用户输入放进捕获组,比放进替换串省心。
node v26.8.1
=== 1. 替换串里的 $ 特殊序列(字符串模式也能触发)===
'a-b-c'.replace('-', '$&$&') -> "a--b-c"
'a-b-c'.replace('-', '$&') -> "a-b-c"
'xAy'.replace('A', '[$`]') -> "x[x]y"
'xAy'.replace('A', "[$']") -> "x[y]y"
'a-b'.replace('-', '$$') -> "a$b"
'a-b'.replace('-', '$_') -> "a$_b"
=== 2. 想写美元符号却写成了变量 ===
金额拼接 '价 12-b'.replace('-', '$&') -> "价 12-b"
转义一次才对 '-'.replace('-', '\$&') -> "\\-"
路径分隔符 'a/b'.replaceAll('/', '\\') -> "a\\b"
=== 3. 用了正则但没分组 ===
'abc'.replace('b', '$1') 字符串模式 -> "a$1c"
'abc'.replace(/b/, '$1') 正则无分组 -> "a$1c"
'abc'.replace(/(b)/, '$1') 正则有一个分组 -> "abc"
=== 4. $10 到底指第 10 组还是第 1 组加个 0 ===
'2026-09-21'.replace(/(\d+)-(\d+)/, '$2/$1') -> "09/2026-21"
'2026-09-21'.replace(/(\d+)-(\d+)/, '$20') -> "090-21"
'ab'.replace(/(a)(b)/, '$10') 两组 -> "a0"
'ab'.replace(/(a)(b)/, '$01') -> "a"
十组时 '$10' -> "j"
=== 5. 函数式替换不解析 $ ===
'a-b'.replace('-', () => '$&') -> "a$&b"
'a-b'.replace('-', (m) => '\\' + m) -> "a\-b"
=== 6. replaceAll 与全局正则 ===
'a-a'.replaceAll('-', '$&$&') -> "a--a"
'a-a'.replaceAll('-', () => '$&') -> "a$&a"
'a-a'.replace(/-/, '$&$&') 非全局只换第一处 -> "a--a"
'a-a'.replaceAll(/a/, 'b') 非全局正则会抛错 -> 抛错 TypeError: String.prototype.replaceAll called with a non-global RegExp argument
=== 7. 具名分组与残缺写法 ===
'a1'.replace(/(?<d>\d)/, '[$<d>]') -> "a[1]"
'a1'.replace(/(?<d>\d)/, '$<nope>') -> "a"
'a1'.replace(/(?<d>\d)/, '$<d>') 具名分组正常替换 -> "a1"
'a1'.replace(/(?<d>\d)/, '[$<d]') 名字没闭合 -> "a[$<d]"
'abc'.replace('b', '$1$2') 只有一组 -> "a$1$2c"
=== 8. 字符串模式与正则模式行为一致 ===
'$'.replace('$', '$&') -> "$"
'价格 12-34'.replace(/-(\d+)/, '元$1') -> "价格 12元34"
=== 9. 展开只做一次(补充实测,见脚本末尾三行)===
"a$&b".replace(/a(\$&)b/, "[$1]") -> "[$&]"
"ab".replace(/(a)/, "$1$&$1") -> "aaab"
"xAy".replace("A", () => "[$&][$`][$1]") -> "x[$&][$`][$1]y"
回调返回什么就写进去什么
把替换串换成函数,回调返回什么就写进去什么,$ 系列不再被解析:'a-b'.replace('-', () => '$&') 返回 a$&b。需要转义反斜杠时写 (m) => '\\' + m 也省事,实测返回 a\-b,不用在替换串里数反斜杠的层数。
写死的模板里只想拿一个美元符号,写 $$ 更短,实测 'a-b'.replace('-', '$$') 返回 a$b,一个字符的代价。replaceAll 的替换串走同一套解析,差别在第一参数:给正则必须带 g,实测 'a-a'.replaceAll(/a/, 'b') 直接抛 TypeError,字符串参数则不受这个限制。
两条取舍。用户可控的替换串一律走回调,理由在错法五,这一条我没有找到既省事又安全的替代写法;模板里自己写死的替换串,用 $$ 更短,改成回调反而多一层函数。具名分组那条还有个没实测的地方:$<name> 在名字没闭合时本机 Node 把它当字面量原样输出,浏览器端的解析是否一致还没验,跨端脚本里建议直接绕开具名分组,改用函数式替换收参数。