URL エンコードに2系統あることを正確に理解する(form-urlencoded vs RFC 3986)
「URL エンコード」と一口に言っても、実は仕様が2系統存在します。encodeURIComponent と application/x-www-form-urlencoded は微妙に違うルールを使っていて、これを混同すると「半角スペースが + になったり %20 になったりする」謎挙動の原因になります。本記事ではこの2系統の違いを整理します。
系統A:RFC 3986(URI 仕様)
RFC 3986 は URI の文法を定める標準です。URL の予約文字を定義し、それ以外は パーセントエンコーディング(%XX 形式)でエンコードする方式を規定しています。
予約文字(エンコードしないとマズい)
gen-delims: : / ? # [ ] @
sub-delims: ! $ & ' ( ) * + , ; = 非予約文字(エンコード不要)
A-Z a-z 0-9 - _ . ~ RFC 3986 では スペース(半角空白)は %20 にエンコードされます。+ は予約文字なので、スペースの代替には使われません。
JavaScript の encodeURIComponent() はこの仕様にほぼ従います:
encodeURIComponent('hello world'); // → "hello%20world"
encodeURIComponent('a+b'); // → "a%2Bb" (+ もエンコードされる)
encodeURIComponent('日本語'); // → "%E6%97%A5%E6%9C%AC%E8%AA%9E" 系統B:application/x-www-form-urlencoded(フォーム送信用)
HTML フォームの送信形式は別の仕様です。HTML 5 仕様および古くは RFC 1738 に由来する形式で、RFC 3986 と次の点が違います:
- スペースは
+にエンコード(%20ではない) - 改行は
%0D%0A(CRLF)に正規化 - それ以外の予約文字は同じくパーセントエンコード
JavaScript の URLSearchParams はこの仕様に従います:
const p = new URLSearchParams();
p.append('q', 'hello world');
p.toString(); // → "q=hello+world" (空白が + になる) 違いが目立つのは「スペース」と「+」
実装で噛み合わなくなる主役はスペースと + です。
| 入力 | RFC 3986 | form-urlencoded |
|---|---|---|
| 半角スペース | %20 | + |
+ | %2B | %2B |
= | %3D | %3D |
& | %26 | %26 |
改行 (\n) | %0A | %0D%0A |
| 日本語 (UTF-8) | %E3%81%82 | %E3%81%82 |
「URL のクエリ文字列」と「フォーム送信のボディ」は同じ形に見えますが、出力する関数を間違えると微妙に壊れます。
「クエリ文字列のスペースは +? %20?」の答え
歴史的には:
- RFC 1738(古い URL 仕様):クエリ文字列のスペースは
+ - RFC 3986(現代の URI 仕様):クエリ文字列のスペースは
%20 - HTML フォーム送信:
+を使う(互換性のため)
現代のサーバーは両方を受け入れるのが普通です。デコード時には + を空白に戻し、%20 も空白に戻すロジックが入っているライブラリが大半です。
ただしエンコード時には、用途に合わせて関数を選ぶ必要があります:
- URL のパス部分:
encodeURIComponent(/は予約文字なので別途処理) - URL のクエリ文字列:どちらでも動くが、フォームと同じにしたいなら
URLSearchParams - フォームボディ:
URLSearchParams.toString()またはFormData
デコード関数も対応する2系統がある
エンコードが2系統あるので、デコードも対応する2系統があります。
decodeURIComponent (RFC 3986 系)
%XX だけをデコードします。+ は空白に戻しません。
decodeURIComponent('hello+world'); // → "hello+world" (+ はそのまま)
decodeURIComponent('hello%20world'); // → "hello world" URLSearchParams (form-urlencoded 系)
+ を空白に戻し、かつ %XX もデコードします。
new URLSearchParams('q=hello+world').get('q'); // → "hello world" location.search.slice(1) をそのまま decodeURIComponent に渡すと、フォーム送信由来のクエリ文字列のスペースが + のまま残ります。クエリ文字列のデコードは URLSearchParams を使うのが安全です。
実装でつまずくパターン
1. URL に直接 + が含まれていてエンコードが必要
メールアドレスのエイリアス記法 user+tag@example.com をクエリパラメータに入れると、デコード時に + が空白になってしまいます:
?email=user+tag@example.com
↓ URLSearchParams でデコード
email = "user tag@example.com" (+ が空白になる) 正しくは送信側で + を %2B にエンコードしておきます:
?email=user%2Btag@example.com
↓ URLSearchParams でデコード
email = "user+tag@example.com" ✓ encodeURIComponent を通せば + は %2B になるので、クエリ値は素直に encodeURIComponent でエンコードするのが安全です。
2. ハッシュフラグメントの扱い
# 以降のハッシュフラグメントは、サーバーには送られず、クライアント側で読まれます。エンコードルールは RFC 3986 に従うべきですが、ブラウザによって正規化の挙動が違うことがあります。日本語をハッシュに入れて location.hash を取ると、ブラウザによってデコード済み・未済が異なるという罠があります。
3. UTF-8 以外のエンコーディング
歴史的には Shift_JIS や EUC-JP でエンコードされた URL も存在します。encodeURIComponent は常に UTF-8 で扱うので、レガシーな URL を扱うときは元のエンコーディングを意識する必要があります(現在は実用上ほぼ UTF-8 のみ)。
まとめ:選び方
ざっくり以下を覚えておけば困りません:
| 用途 | 関数 |
|---|---|
| URL の各部分(パス、クエリ値、フラグメント)を作る | encodeURIComponent |
| クエリ文字列を作る | URLSearchParams |
| クエリ文字列をパースする | URLSearchParams |
| パスやフラグメントをデコード | decodeURIComponent |
「URL に何かを入れる」ときは encodeURIComponent で迷わない、が原則です。
エンコード前後の文字列を試したいときは、本サイトの URL エンコードツールでパーセントエンコードと form-urlencoded の違いを並べて表示できます。差を視覚化したいときに使えます。