URL エンコードに2系統あることを正確に理解する(form-urlencoded vs RFC 3986)

約7分

「URL エンコード」と一口に言っても、実は仕様が2系統存在します。encodeURIComponentapplication/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 と次の点が違います:

  1. スペースは + にエンコード%20 ではない)
  2. 改行は %0D%0A(CRLF)に正規化
  3. それ以外の予約文字は同じくパーセントエンコード

JavaScript の URLSearchParams はこの仕様に従います:

const p = new URLSearchParams();
p.append('q', 'hello world');
p.toString(); // → "q=hello+world"  (空白が + になる)

違いが目立つのは「スペース」と「+」

実装で噛み合わなくなる主役はスペースと + です。

入力RFC 3986form-urlencoded
半角スペース%20+
+%2B%2B
=%3D%3D
&%26%26
改行 (\n)%0A%0D%0A
日本語 (UTF-8)%E3%81%82%E3%81%82

「URL のクエリ文字列」と「フォーム送信のボディ」は同じ形に見えますが、出力する関数を間違えると微妙に壊れます。

「クエリ文字列のスペースは +%20?」の答え

歴史的には:

  1. RFC 1738(古い URL 仕様):クエリ文字列のスペースは +
  2. RFC 3986(現代の URI 仕様):クエリ文字列のスペースは %20
  3. 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 の違いを並べて表示できます。差を視覚化したいときに使えます。