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

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

正規URL: https://3mikan.com/archives/6085

著者: みかん
公開: 2026-09-08T12:59:00.000Z
更新: 2026-09-08T12:59:00.000Z

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

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

## まずは手元で再現する

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

```sh
node url-search-params.mjs
```

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

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

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

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

```js
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標準の解析規則](https://url.spec.whatwg.org/#concept-urlencoded-parser)

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

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

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

```js
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の生成・文字列化](https://url.spec.whatwg.org/#urlsearchparams)

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

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

```js
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()`を呼ぶと、変換済みの文字列を、もう一度変換することになります。

```js
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+B`が`A%2BB`になり、その`%`が文字列化の際に`%25`へ変わりました。送りたい値が`A+B`なら、直す場所は受け取り側ではなく、余分な事前変換をしている作成側です。

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

```js
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](https://tc39.es/ecma262/multipage/global-object.html#sec-decodeuricomponent-encodeduricomponent)

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

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

```js
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の各メソッド](https://url.spec.whatwg.org/#urlsearchparams)

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

```js
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()`は`null`、`label=`に対する結果は空文字`''`です。両方をまとめて「値がない」と扱ってよいかも、項目ごとに決めます。

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

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

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

```js
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の文字列化の違い](https://url.spec.whatwg.org/#urlsearchparams)

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

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

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

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

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

## 確認した一次情報

- [WHATWG URL: application/x-www-form-urlencoded parsing](<https://url.spec.whatwg.org/#concept-urlencoded-parser>): 確認日 2026-09-08
- [WHATWG URL: URLSearchParams](<https://url.spec.whatwg.org/#urlsearchparams>): 確認日 2026-09-08
- [Node.js v24.14.0: URLSearchParamsのオブジェクト入力](<https://nodejs.org/download/release/v24.14.0/docs/api/url.html#new-urlsearchparamsobj>): 確認日 2026-09-08
- [ECMAScript: decodeURIComponent](<https://tc39.es/ecma262/multipage/global-object.html#sec-decodeuricomponent-encodeduricomponent>): 確認日 2026-09-08
