encodeURIComponentとURLSearchParamsで記号の扱いが違う — +と空白を比べる

URLのクエリに値を入れるとき、JavaScript には encodeURIComponent と URLSearchParams の2つの道具がある。どちらも日本語や空白を %xx にするので同じものに見えるが、残す記号の種類と空白の書き方が違う。片方でエンコードした文字列をもう片方の流儀でデコードすると、+ が空白になったり、ならなかったりする。

この記事では ハシトシステムのURLエンコード・デコードツール のコードを入口に、ASCIIの記号33個を encodeURIComponent / encodeURI / URLSearchParams に通して、どれが残るかを Node.js で数える。

ツールは encodeURIComponent と decodeURIComponent をそのまま呼ぶ

ツールの変換部分は次の数行である(index.html の convert() から)。

1
2
3
4
5
if(mode === 'encode'){
result = (method === 'uri') ? encodeURI(src) : encodeURIComponent(src);
} else {
result = decodeURIComponent(src);
}

エンコードは encodeURIComponent と encodeURI を切り替えられ、デコードは常に decodeURIComponent を使う。decodeURIComponent が例外を投げたときは「デコードに失敗しました」と表示する。処理はブラウザ内で完結する。

ここで押さえておきたいのは、decodeURIComponent は + を空白に戻さないことである。HTMLフォームの送信やブラウザの URLSearchParams が作るクエリでは空白が + になるので、そうした文字列をこのツールに貼ると + はそのまま残る。

3つの関数が残す記号

仕様で決まっている「エンコードしない文字」は次のとおり。

関数 英数字以外でエンコードしない記号 根拠
encodeURIComponent - _ . ! ~ * ' ( ) ECMAScript(MDN の説明も同じ)
encodeURI 上記に加えて ; / ? : @ & = + $ , # ECMAScript
URLSearchParams(application/x-www-form-urlencoded) * - . _ WHATWG URL Standard

WHATWG URL Standard は、application/x-www-form-urlencoded の percent-encode set を「ASCII英数字と * - . _ を除くすべてのコードポイント」と定めている。さらにこの形式では空白(0x20)を %20 ではなく + で書く。

RFC 3986 の unreserved(そのまま書いてよい文字)は英数字と - . _ ~ の4記号である。MDN は、RFC 3986 では ! ' ( ) * が予約されているので、それらもエンコードしたい場合は encodeURIComponent の結果をさらに置き換える関数を例として載せている。

つまり3つの流儀で、~ と ! ' ( ) の扱いが割れる。

  • encodeURIComponent は ~ も ! ' ( ) * も残す。
  • URLSearchParams は * だけ残し、~ と ! ' ( ) は %xx にする。
  • RFC 3986 に厳密に合わせると、~ は残し、! ' ( ) * は %xx にする。

なお、ツールの FAQ は encodeURIComponent がエンコードする記号の例に ! ' ( ) * を挙げているが、実際にはこの5つはエンコードされない(下の実行結果を参照)。

実際に試す

Node.js 24.11.1(Windows 11)で実行した。依存パッケージは要らない。ブラウザの開発者ツールのコンソールでも同じ関数が使える。次の内容を url-encode-diff.mjs として保存する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// url-encode-diff.mjs : encodeURIComponent / encodeURI / URLSearchParams がどの ASCII 記号を残すかを比べる
const ascii = [];
for (let c = 0x20; c < 0x7f; c++) {
const ch = String.fromCharCode(c);
if (!/[A-Za-z0-9]/.test(ch)) ascii.push(ch);
}
const kept = (fn) => ascii.filter((ch) => fn(ch) === ch).join('');
const form = (s) => new URLSearchParams({ k: s }).toString().slice(2);

console.log('記号の数 ', ascii.length);
console.log('encodeURIComponent が残す', kept(encodeURIComponent));
console.log('encodeURI が残す ', kept(encodeURI));
console.log('URLSearchParams が残す ', kept(form));

// 空白と + の扱い
const s = 'a b+c';
console.log('encodeURIComponent', encodeURIComponent(s));
console.log('URLSearchParams ', form(s));
console.log('decodeURIComponent("a+b%20c")', JSON.stringify(decodeURIComponent('a+b%20c')));
console.log('URLSearchParams("q=a+b%20c")', JSON.stringify(new URLSearchParams('q=a+b%20c').get('q')));

// RFC 3986 の unreserved(ALPHA DIGIT - . _ ~)だけを残す
const rfc3986 = (str) =>
encodeURIComponent(str).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
console.log('rfc3986 が残す ', kept(rfc3986));

// 壊れた入力
for (const bad of ['100%', '%E3%81', '%zz']) {
try { decodeURIComponent(bad); console.log(bad, 'ok'); }
catch (e) { console.log(JSON.stringify(bad), e.name, '/ URLSearchParams:', JSON.stringify(new URLSearchParams('q=' + bad).get('q'))); }
}
// 孤立サロゲート
try { encodeURIComponent('\uD83D'); } catch (e) { console.log('lone surrogate', e.name, '/ toWellFormed:', encodeURIComponent('\uD83D'.toWellFormed())); }
1
node url-encode-diff.mjs

出力:

1
2
3
4
5
6
7
8
9
10
11
12
13
記号の数                 33
encodeURIComponent が残す !'()*-._~
encodeURI が残す !#$&'()*+,-./:;=?@_~
URLSearchParams が残す *-._
encodeURIComponent a%20b%2Bc
URLSearchParams a+b%2Bc
decodeURIComponent("a+b%20c") "a+b c"
URLSearchParams("q=a+b%20c") "a b c"
rfc3986 が残す -._~
"100%" URIError / URLSearchParams: "100%"
"%E3%81" URIError / URLSearchParams: "�"
"%zz" URIError / URLSearchParams: "%zz"
lone surrogate URIError / toWellFormed: %EF%BF%BD

空白を含むASCII記号33個のうち、encodeURIComponent は9個、encodeURI は20個、URLSearchParams は4個をそのまま残した。表の内容と一致する。

+ の読み違い

出力の5〜8行目が、いちばん事故になりやすいところである。

  • a b+c を encodeURIComponent にかけると a%20b%2Bc、URLSearchParams にかけると a+b%2Bc になる。どちらも、元の + は %2B にしているので、それぞれの流儀でデコードすれば元に戻る。
  • 混ぜると壊れる。URLSearchParams が作った a+b を decodeURIComponent で読むと a+b のまま(空白にならない)。逆に、encodeURIComponent を通さずに + を含む値をクエリへ書いたものを URLSearchParams で読むと、+ が空白に化ける。

電話番号の +81 やメールアドレスの user+tag@example.com をクエリに載せたら空白になった、という不具合はたいてい後者である。読み取り側がクエリをフォーム形式(+ を空白とみなす)で読むのかどうかを確かめ、組み立て側の流儀をそれにそろえる。迷ったら、組み立ても読み取りも URLSearchParams(または URL の searchParams)に任せるのが安全である。

壊れた入力への反応も違う

decodeURIComponent は、% の後ろが16進2桁でない場合(100%、%zz)や、UTF-8として途中で切れたバイト列(%E3%81)で URIError を投げる。ツールがエラー表示をしているのはこの例外である。

一方 URLSearchParams は例外を投げない。解釈できない % は文字のまま残し、壊れたUTF-8は置換文字 U+FFFD(�)にする。エラーに気づきたい場面では decodeURIComponent のほうが向き、ユーザー入力を落とさずに読みたい場面では URLSearchParams のほうが向く。

エンコード側では、対になっていないサロゲート(絵文字を文字単位で切ったときの片割れなど)を encodeURIComponent に渡すと URIError になる。MDN は String.prototype.toWellFormed() で U+FFFD に置き換えてから渡す方法を紹介しており、Node.js 24 では %EF%BF%BD になった。

使い分け

  • クエリ文字列を組み立てる・読む: URLSearchParams。空白は +、+ は %2B になり、読み取り側も同じ規則で戻す。
  • パスの1区間やフラグメントに値を埋める: encodeURIComponent。パスでは + は空白の意味を持たないので、%20 で書くこちらが合う。
  • 署名やキャッシュキーで RFC 3986 と完全に一致させたい: encodeURIComponent のあとで ! ' ( ) * を %xx に置き換える。OAuth 1.0 の署名のように、エンコード後の文字列を比べる仕様ではここがずれると署名が合わない。
  • encodeURI は完成したURL全体用。& や = や # を残すので、値のエンコードに使うとクエリが分割される。

まとめ

  • encodeURIComponent は - _ . ! ~ * ' ( ) を残し、URLSearchParams は * - . _ だけを残して空白を + にする。RFC 3986 の unreserved は - . _ ~ である。
  • decodeURIComponent は + を空白に戻さない。フォーム形式のクエリを読むときは URLSearchParams を使う。
  • 壊れた % 列に対して、decodeURIComponent は URIError を投げ、URLSearchParams は文字のまま残すか U+FFFD にする。

日本語をメールのヘッダに載せるときのエンコード(RFC 2047)は「MIMEエンコードワードの75文字制限」で、Basic認証の btoa とUTF-8は「Basic認証のbtoaとUTF-8」で扱った。

ツール: URLエンコード・デコード(ハシトシステム)。encodeURIComponent と encodeURI を切り替えてエンコードし、デコードは decodeURIComponent で行う。処理はブラウザ内で完結する。


encodeURIComponentとURLSearchParamsで記号の扱いが違う — +と空白を比べる
https://blog.hashito.biz/2026/10/15/encodeuricomponent-urlsearchparams-plus-space-unescaped-symbols/
著者
hashito
作成日
2026年10月15日
著作権