3MIKAN
仮想通貨直コン

URLSearchParamsで+が空白になる理由|二重エンコード・同名パラメーターの扱い

URLで渡したA+BがA Bになるのはなぜ?URLSearchParamsに渡す値とクエリ文字列を分け、%252B、getとgetAll、setとappendの違いを、単独実行できるサンプルで確認します。

3MIKANのキャラクターがプラス形の部品を透明なケースに収め、搬送台で元の形のまま届けようとするイラスト

検索条件や表示名をURLで渡したら、A+BA Bになっていた。そこでencodeURIComponent()を足すと、今度は%252Bという見慣れない文字列が現れた。こうした不具合は、元の値と、URLへ載せるために変換した文字列を混同すると起こります。

基本は、作る側では変換前の値をURLSearchParamsへ渡し、読む側ではget()などの結果をそのまま扱うことです。この記事ではJavaScriptで検索URLやDAppの表示条件を組み立てる人向けに、プラス記号、二重エンコード、同名パラメーターを順に確認します。

まずは手元で再現する

実行用サンプルを開く・保存すると、以下の例をまとめて試せます。url-search-params.mjsという名前で保存し、Node.jsで実行してください。

node url-search-params.mjs

確認環境はNode.js v24.14.0です。追加パッケージ、アカウント、ネットワーク通信は不要で、サンプル内のURLにもアクセスしません。

サンプルは各結果を表示し、期待値と違えばエラーで止まります。最後に「すべての実行例で、結果が期待値と一致しました。」と出れば、正常例と反例の両方を再現できています。以下のJavaScriptの枠も、それぞれ別の.mjsファイルに保存して実行できます。

クエリ文字列の+は、空白として読み取られる

URLの?以降にあるlabel=A+Bのような部分を、クエリ文字列と呼びます。同じように見える三つの入力を、URLSearchParamsで読んでみます。

for (const query of ['label=A+B', 'label=A%2BB', 'label=A%20B']) {
  console.log(JSON.stringify(new URLSearchParams(query).get('label')));
}
// "A B"
// "A+B"
// "A B"
クエリ文字列 読み取った値
label=A+B "A B"
label=A%2BB "A+B"
label=A%20B "A B"

これはURLSearchParamsが使うフォーム形式の規則です。クエリ文字列を読むとき、先に生の+を空白へ置き換え、その後で%2Bなどを復号します。だから%2Bから戻ったプラス記号は、空白への置き換えを受けません。URL標準の解析規則

「URL内のプラスは、どこでも空白になる」という意味ではありません。ここではURLSearchParamsによるクエリの解析を扱っています。

作るときは、変換前の値を渡す

プラス記号を保つために、入力値へ自分で置換処理を足す必要はありません。文字列を手でつなぐ代わりに、名前と値を分けて渡します。

const params = new URLSearchParams({ label: 'A+B', note: 'two words' });
const query = params.toString();
console.log(query);
// label=A%2BB&note=two+words

const restored = new URLSearchParams(query);
console.log(restored.get('label'));
console.log(restored.get('note'));
// A+B
// two words

オブジェクトの値として渡したA+Bは、まだクエリ文字列ではありません。toString()がプラス記号を%2B、空白を+として書き出すため、読み戻すと区別が保たれます。URLSearchParamsの生成・文字列化

値に&=が含まれていても、同じ渡し方を使います。サンプルではA+B & みかん=1が一つの値として戻ることも確認しています。

URL全体を扱うなら、まずURLを作ります。URLSearchParamsへURL全体の文字列を渡すのではなく、searchParamsを使ってクエリを操作します。

const url = new URL('https://example.test/search');
url.searchParams.set('label', 'A+B');
console.log(url.href);
// https://example.test/search?label=A%2BB

const received = new URL(url.href);
console.log(received.searchParams.get('label'));
// A+B

%252Bが出たら、元の値と比べる

URLSearchParamsへ渡す前にencodeURIComponent()を呼ぶと、変換済みの文字列を、もう一度変換することになります。

const params = new URLSearchParams({ label: encodeURIComponent('A+B') });
console.log(params.toString());
console.log(new URLSearchParams(params.toString()).get('label'));
// label=A%252BB
// A%2BB

この例では、最初の変換でA+BA%2BBになり、その%が文字列化の際に%25へ変わりました。送りたい値がA+Bなら、直す場所は受け取り側ではなく、余分な事前変換をしている作成側です。

ただし、%25があるだけで不具合とは判断できません。元の値が「パーセント・2・B」という三文字なら、次の結果が正しい表現です。

const params = new URLSearchParams({ label: '%2B' });
console.log(params.toString());
const value = new URLSearchParams(params.toString()).get('label');
console.log(value);
console.log(decodeURIComponent(value));
// label=%252B
// %2B
// +

読み取った%2Bをさらに復号すると、元とは違う+へ変えてしまいます。受け取り側で何度も復号して帳尻を合わせると、こうした正しい入力まで壊します。

なお、decodeURIComponent('A+B')自体はA+Bを返します。この関数に、プラス記号を空白として読む規則はありません。また、get()ですでにA Bになった後から復号しても、失ったプラス記号は戻りません。ECMAScriptのdecodeURIComponent

同じ名前が複数ある場合、getだけでは判断しない

次は、同名の値を二つ含むchain=1&chain=10です。ここでは文字列の扱いだけを試し、実際のネットワークへの接続や切り替えは行いません。

const params = new URLSearchParams('chain=1&chain=10');
console.log(params.get('chain'));
console.log(JSON.stringify(params.getAll('chain')));
// 1
// ["1","10"]

params.append('chain', '137');
console.log(params.toString());
// chain=1&chain=10&chain=137

params.set('chain', '10');
console.log(params.toString());
// chain=10

get()は最初の値、getAll()は該当する値すべてを返します。append()は追加、set()は置き換えで、set()の後には同名の別の値は残りません。URLSearchParamsの各メソッド

一つだけ受け付ける項目なら、最初の値を採用する前に個数を確認します。次の関数は、値がない場合、空文字の場合、同名の値が複数ある場合を受け付けない例です。

function readSingleParam(params, name) {
  const values = params.getAll(name);
  if (values.length !== 1 || values[0] === '') {
    throw new Error(`${name}には空でない値を一つだけ指定してください`);
  }
  return values[0];
}

console.log(readSingleParam(new URLSearchParams('label=A%2BB'), 'label'));
// A+B

これはURL標準の必須条件ではなく、この例で選んだ受け入れ方です。サンプルでは未指定、label=label=A&label=Bがエラーになることも確認します。空白だけの文字列や許可する値の一覧までは検査していないので、実際の項目に合わせた確認は別に必要です。

未指定に対するget()nulllabel=に対する結果は空文字''です。両方をまとめて「値がない」と扱ってよいかも、項目ごとに決めます。

複数値を作る場合にも注意があります。new URLSearchParams({ chain: ['1', '10'] })は同名キーを二つ作らず、一つの値1,10にします。二つの値が必要なら、append()を繰り返すか、[['chain', '1'], ['chain', '10']]という名前と値の組を渡します。Node.js v24.14.0のオブジェクト入力

値が同じでも、URLの文字列表現は変わる

最後に、クエリを操作した後でURLそのものを比較する例です。値が変わっていなくても、文字列としては同じとは限りません。

const url = new URL('https://example.test/?q=A%20B~');
console.log(url.search);
url.searchParams.sort();
console.log(url.search);
console.log(url.searchParams.get('q'));
// ?q=A%20B~
// ?q=A+B%7E
// A B~

この例はキーが一つなので、並べ替えによる順序の変化はありません。それでもsearchParamsによる書き直しで、空白の表現が%20から+へ、~%7Eへ変わります。URLとURLSearchParamsの文字列化の違い

元のURL文字列との完全一致を確かめたい処理では、この違いを無視できません。たとえば、URLを署名対象にする仕様なら、どの文字列を使い、どんな変換を許すのかをその仕様で確認します。「読み取った値が同じだから、署名も同じ」とは判断しないでください。

不具合を追うときは、三つの値を分けて確認する

最初に、作成側へ渡した元の値、toString()などで得たクエリ文字列、受け取り側のget()の結果を並べます。たとえばA+Bを送るなら、途中の表現はlabel=A%2BB、読み取った値はA+Bになります。

そのうえで、作成前の余分なエンコード、読み取り後の余分なデコード、同名キーの見落としを確認します。すでに空白へ変わった結果だけを見ても、それが元から空白だったのか、プラス記号だったのかは区別できません。

URLの文字列と読み取った値を分けて考えると、変換処理をむやみに足さず、情報が変わった箇所を絞れます。文字列の見た目と実際のデータの違いは、JavaScriptの文字数とUTF-8のバイト数も併せて確認すると整理しやすくなります。

確認した一次情報