結論
JavaScript / TypeScript の String.prototype.replace で、第 2 引数に「外部由来の文字列」をそのまま渡すのは避けたほうが安全です。$1 $& $` $' などが特殊文字として展開されるため、想定外の出力になることがあります。
つまり、replace の第 2 引数に渡す文字列は、完全なリテラル文字列ではありません。$ から始まる一部の文字列は、replacement pattern として解釈されます。
なお、これは String.prototype.replaceAll でも同じです。第 2 引数に文字列を渡す場合は、$& や $1 などが replacement pattern として解釈されます。
// 危険なコード
const userInput = '<span>price $1 USD</span>';
const result = html.replace(/(<img[^>]*>)/g, userInput);
// → $1 はキャプチャグループ参照として展開される
何が起きるか
replace の第 2 引数が文字列の場合、以下のシーケンスが「リテラル文字」ではなく「特殊文字」として解釈されます。
| 記号 | 意味 |
|---|---|
$$ |
リテラルの $
|
$& |
マッチ全体 |
$` |
マッチ前の文字列 |
$' |
マッチ後の文字列 |
$n(n は数字) |
n 番目のキャプチャグループ |
これは MDN の String.prototype.replace ドキュメント で明記されている仕様です。
再現例
const html = '<img src="x.png" alt="">';
const replacement = 'price $1';
// パターン 1: キャプチャあり
console.log(html.replace(/(<img[^>]*>)/, replacement));
// → 'price <img src="x.png" alt="">'
// $1 がキャプチャ全体に置き換わってしまう
// パターン 2: キャプチャなし
console.log(html.replace(/<img[^>]*>/, replacement));
// → 'price $1'
// 第 1 引数にキャプチャがない場合、$1 はそのまま残る
// ただし、挙動を誤解しやすく混乱の元になる
// パターン 3: $&
console.log(html.replace(/<img[^>]*>/, 'BEFORE $& AFTER'));
// → 'BEFORE <img src="x.png" alt=""> AFTER'
// $& はマッチ全体に展開される
どんな時に困るか
「外部から入ってきた文字列を、別の文字列に埋め込む」場面では注意が必要です。
- ユーザー入力をテンプレートに埋め込む
- DB / KV から取得した値を HTML / コードに挿入する
- 翻訳リソースに含まれる金額表示(
$1,000など)
特に問題が起きやすいのは「$1 が出現する自然言語の文字列」を扱うときです。価格表示、株式記号($AAPL)、変数参照のサンプルコード、正規表現の解説記事などが該当します。
// ありがちなバグ
const product = { description: 'Save $1 on every purchase!' };
const html = template.replace(/({{description}})/g, product.description);
// → $1 が '{{description}}' に展開されてしまう
解決策 1: 関数版を使う
第 2 引数に関数を渡すと特殊展開は起きません。関数の戻り値がそのままリテラル文字列として挿入されます。
const result = html.replace(/<img[^>]*>/g, () => userInput);
これが最もシンプルで安全です。実装の意図が「ユーザー入力をそのまま挿入する」なら、関数版を選ぶのが自然です。
ただし注意点として、関数の引数経由で match や capture groups を取りたい場合は、それらを使った文字列構築を関数内で書く必要があります。
const result = html.replace(/<img\s+src="([^"]+)"/g, (match, src) => {
// src を使って何か作る
return `<img src="${escapeHtml(src)}" loading="lazy"`;
});
解決策 2: $ を escape する
事前に文字列内の $ を $$ に置換しておく方法もあります。
const escaped = userInput.replace(/\$/g, '$$$$');
const result = html.replace(/<img[^>]*>/g, escaped);
ここで $$$$ と書くのは、最初の userInput.replace() の replacement 文字列として $$ を出力したいからです。replace の replacement 文字列では $$ がリテラルの $ になるため、$$$$ と書くと結果として $$ が生成されます。
その後、生成された $$ を本来の html.replace() の replacement として渡すと、そこで $$ がリテラルの $ として扱われます。少し分かりにくいため、通常は関数版のほうが扱いやすいです。
関数版が一律で安全か
関数版でも「関数の戻り値内で他の特殊文字に化けるリスク」はありません。replace の関数引数は MDN 仕様で「戻り値はリテラル文字列として扱う」と明記されています。
'abc'.replace(/b/, () => '$&');
// → 'a$&c' ($& は展開されない)
したがって「外部入力を replacement にする」場面では、関数版を標準にしておくと事故が減ります。
問題が起きやすい場面
実コードで遭遇しやすいのは、「ある程度信頼している取得元から受け取った HTML 断片を、別の HTML の開始タグ部分へ差し込む」ような処理です。
なお、HTML に外部入力を挿入する場合は、この記事で扱う $ 展開の問題とは別に、HTML エスケープやサニタイズも必要です。ここでは replace の replacement 文字列としての挙動に話を絞ります。
function replaceElementStartTag(
html: string,
tagName: string,
replacementHtml: string,
) {
if (!/^[a-z][a-z0-9-]*$/i.test(tagName)) {
throw new Error('invalid tag name');
}
const pattern = new RegExp(`<${tagName}[^>]*>`, 'gi');
return html.replace(pattern, () => replacementHtml);
}
replacementHtml が <span>price $1 USD</span> のような何気ない文字列だと、出力が <span>price <img ...> USD</span> のように予想外に変形することがあります。エンドユーザー向けページの場合、「描画は壊れていないが内容が変」という状態になりやすいため、テストでも気付きにくいです。
早期検出するためのテスト
it('replacement に $ を含む文字列があっても特殊展開されない', () => {
const html = '<img src="x">';
const result = replaceElementStartTag(html, 'img', '<span>price $1 USD</span>');
expect(result).toContain('$1'); // 文字どおり残ること
});
このテストを追加するだけで、「うっかり文字列版に戻す」リグレッションを防ぎやすくなります。
まとめ
-
String.prototype.replace/replaceAllの replacement に文字列を渡すと、$1や$&などが特殊展開されます - replacement 文字列は完全なリテラルではなく、
$から始まる一部の記法が replacement pattern として解釈されます - 外部由来の文字列をそのまま挿入したい場合は、文字列版ではなく関数版
() => replacementを使うのが安全です - escape 方式 (
replace(/\$/g, '$$$$')) もありますが、可読性が悪いです - 「
$を含む replacement のテスト」を 1 件入れておくとリグレッション防止になります