【JavaScript】スペースの URL エンコードの結果は %20 と + のどちらが正解なのか

クエリパラメータのエンコードとデコードには encodeURIComponent() 関数と decodeURIComponent() 関数を使うことが多いのではないしょうか。一方で URL インタフェースや URLSearchParams インタフェースでも URL エンコードおよびデコードが可能です。筆者自身、これらのエンコード結果の違いにモヤモヤしていました。今回、その詳細を調べましたので、これらの具体的な違い、歴史的経緯、そして、どちらを使うべきか、についてまとめてみたいと思います。

パラメータ値をエンコードして URL を生成する

サーバーに GET メソッドでパラメータを送りたい場合、パラメータ値を URL エンコードしないといけません。その URL 生成を URL インタフェースを使って書くと、このようになります。

// URL インターフェイスを使う方法
let url  = new URL('https://webfrontend.ninja/samples');
url.searchParams.set('author','たろう');
url.searchParams.set('title', 'Hello World');
const req_url = url.toString();
console.log(req_url);

この結果は次のようになります。

https://webfrontend.ninja/samples?author=%E3%81%9F%E3%82%8D%E3%81%86&title=Hello+World

期待通りの結果です。日本語のようなマルチバイトの文字はパーセントエンコーディングされ、一部の記号を除き英数字はそのままとなります。

encodeURIComponent() 関数による URL エンコードとの違い

では、先ほどと同じことを encodeURIComponent() 関数でやってみましょう。

// encodeURIComponent() 関数を使う方法
const req_url = 'https://webfrontend.ninja/samples'
+ '?author=' + encodeURIComponent('たろう')
+ '&title=' + encodeURIComponent('Hello World');
console.log(req_url);

この結果は次のようになります。

https://webfrontend.ninja/samples?author=%E3%81%9F%E3%82%8D%E3%81%86&title=Hello%20World

一見、結果は変わらないように見えますが、実は “Hello World” のエンコード結果が少し異なります。比べてみましょう。

Hello+World    // URL インターフェイスを使った場合
Hello%20World  // encodeURIComponent() 関数を使った場合

このように半角スペースは、URL インターフェイスでは "+" に、encodeURIComponent() 関数では "%20" に変換されます。その他、ASCII 文字の記号の一部のエンコード結果が異なります。ほとんどの記号はパーセントエンコーディングされますが、以下の記号は URL インターフェイスと encodeURIComponent() 関数とで変換の結果が異なります。

文字URLencodeURIComponent
SP (スペース)+%20
! (ビックリマーク)%21!
' (シングルクォーテーション)%27
( (丸括弧)%28(
) (丸括弧閉じ)%29)
~ (チルダ)%7E~

このように、URL インターフェイスはスペース以外の記号をパーセントエンコーディングするのに対し、encodeURIComponent() 関数は上記の記号に関しては変換せずそのままとします。

なお、* (アスタリスク)、- (ハイフン)、.(ドット)、_ (アンダースコア) の 4 文字は、URL インターフェイスでも encodeURIComponent() 関数でも変換されません。

さて、この違いは何なのかというと、採用した国際標準仕様の種類やバージョンの違いからです。このあたりを少し深堀りしてみましょう。

国際標準化団体と仕様

URL エンコードが関係する国際標準化団体は、筆者が把握した限りでも 4 つもあります。Ecma International、IETF、W3C、WHATWG です。いずれもウェブを構成する技術には欠かせない標準化団体ばかりです。

まず、URI を規定しているのは IETF の「RFC 3986 – Uniform Resource Identifier (URI): Generic Syntax」です。タイトルの通り、RFC 3986 は URI の構文を規定しており、URI にどのような文字を使って良いかなども規定しています。URL は URI のサブセットですので、URL もそれに従います。そのため、クエリパラメータの値に使って良い文字は、この RFC 3986 に従うのが前提となります。

URL インターフェイスは WHATWG の URL という仕様で規定されています。クエリパラメータのエンコードに関しては RFC 3986 に従っているように見えて、実は少し違います。このあたりの詳細は後述します。

encodeURIComponent() 関数は Ecma International の ECMAScript という仕様の中で規定されています。その規定の中で RFC 2396 が言及されてます。実は、RFC 2396 は、前述の RFC 3986 によって置き換えられています。つまり、encodeURIComponent() は IETF の古い仕様に基づいて作られていることになります。

では、最新の RFC 3986 と古い RFC 2396 とでは何が違うのかというと、URI で自由に使っても良いとされる文字です。そのような文字は「Unreserved Characters (非予約語)」として定義されています。アルファベットと数字は当然ながら自由に利用できますので、ここでは記号に限って紹介します。

最新の RFC 3986 の Unreserved Characters では "-"".""_""~" の 4 文字が自由に利用できる文字に該当します。一方、古い RFC 2396 の Unreserved Characters では "-""_"".""!""~""*""'""("")" の 9 文字が該当します。違いをまとめると、どちらの仕様でも "-"".""_""~" の 4 文字は自由に使えるけれども、"!""*""'""("")" の 5 文字は RFC 2396 でしか許されていない、ということです。

したがって、"-"".""_""~" の 4 文字は URL インターフェイスとencodeURIComponent() 関数のいずれにおいてもパーセントエンコーディングされなかったわけです。そして、"!""*""'""("")" の 5 文字は、URL インターフェイスではパーセントエンコーディングされるけれども、encodeURIComponent() 関数ではパーセントエンコーディングされなかった、というわけです。

これで、URL インターフェイスとencodeURIComponent() 関数とのエンコードの結果の違いのほとんどは説明がつきました。最後に残るのは半角スペースの扱いだけです。

なぜ RFC に反して半角スペースは + に変換されるのか

URL インターフェイスでは半角スペースは "+" に変換されました。ところが、RFC 3986 では "+" は Unreserved Characters には含まれておらず、Reserved Characters として規定されています。つまり "+" は自由に使ってはいけない文字ということになります。では、なぜ RFC 3986 に準拠しているはずの URL インターフェイスは RFC 3986 の規定を破って半角スペースを "+" に変換するのでしょうか。

それは、ウェブブラウザーは WHATWG の仕様を優先するからです。WHATWG はブラウザーの挙動を標準化しています。汎用的な IETF の URI の規定を参考にしながらも、ウェブの歴史的事情も考慮して、必要に応じて URL の仕様を微調整をしたということです。WHATWG URL 仕様では、仕様の目的(Goals)の一つに次の文言が書かれています。

Align RFC 3986 and RFC 3987 with contemporary implementations and obsolete the RFCs in the process.

適切な日本語訳か分かりませんが「RFC 3986 と RFC 3987 を近年の実装に合わせて調整し、それにより、それら RFC を廃止する」と書かれています。ウェブブラウザーの世界においては、少なくとも URL エンコードに関しては WHATWG URL 仕様に従うというとになります。

WHATWG URL 仕様では、「percent-encode after encoding」というセクションでエンコーディング方法が規定されています。ここで半角スペース (0x20) を U+002B (+) にする旨が記載されています。

では、なぜ WHATWG URL 仕様では半角スペース (0x20) を U+002B (+) にする仕様にしたのかというと、それはウェブの歴史的経緯からでしょう。筆者がざっと調べた限りでは、ブラウザーがそうするようにと規定されたのは HTML 2.0 からだと思われれます。

HTML 2.0 は 1995 年に IETF の RFC 1866 として標準化されました。ここで初めてフォーム投稿のデフォルトのエンコード方法として application/x-www-form-urlencoded が定められました。そこで半角スペースを + に置き換えることが規定されました。これは URL のクエリも同様です。以降、ウェブの世界では半角スペースを + に置き換えるエンコーディングが主流となっていきます。

HTML 2.0 までは IETF にて標準化されましたが、HTML 3 は W3C にて標準化されました。ここでは URL エンコードに関する詳細は言及されませんでしたが、その後、HTML 4 では HTML 2.0 と同様に半角スペースの扱いが規定されています。最終的に W3C は HTML5 を標準化しましたが、URL の仕様に関しては URL という名前の仕様として分離されました。その後、HTML 仕様と同様に、URL 仕様の標準化は W3C から WHATWG に移管され、現在に至ります。

このように、WHATWG およびウェブブラウザーベンダーは、長年の経緯を考慮し、RFC 3986 をベースに、半角スペースの扱いを微調整したものを、ウェブブラウザーの URL エンコーディングに採用しているのです。

以上のことから、URL インターフェイスは、RFC 3986 に準拠した実装ではなく、WHATWG URL 仕様に準拠した実装ということになります。

encodeURIComponent() 関数と URL インターフェイスのどちらを使うべきか

encodeURIComponent() 関数と URL インターフェイスのどちらを使うべきかという疑問に答えるとしたら、当然 URL インターフェイスでしょう。

もちろん、最新かつウェブブラウザーの仕様に準拠しているという事実が採用の理由の一つではありますが、現実問題、最新だから問題ないとはいかないものです。JavaScript で URL を生成しても、サーバーがそれを適切に解釈できなければ意味がありません。

では、サーバー側は適切に URL インターフェイスによるエンコードを解釈できるのでしょうか。サーバー側の実装の多くは、少なくともパーセントエンコーディングされたものは問題なくデコードできるでしょう。そう考えると、encodeURIComponent() 関数より URL インターフェイスのほうが無難なはずです。しかし、サーバー側で + を半角スペースにデコードできるかがネックになります。

長年ウェブの世界ではウェブブラウザーは半角スペースを + に変換してきたわけですから、問題はほとんど発生しないでしょう。もしサーバー側で + を半角スペースにデコードしないという問題が発生した場合、それはデコードライブラリの選択が間違っていると考えられます。

もし仮に RFC 3986 完全準拠のライブラリがあったとして、そのライブラリでデコードすれば、+ が半角スペースにデコードされないかもしれません。しかし、ブラウザーからの HTTP リクエストでクエリをデコードするというユースケースにおいて、RFC 3986 完全準拠のライブラリを選択していること自体が間違いです。そもそも目的が違います。ブラウザーからの HTTP リクエストでクエリをデコードすることを目的としたライブラリを探すべきでしょう。

そう考えると、少なくとも新規開発のウェブシステムにおいてであれば、サーバーの実装面を考慮したとしても、フロントエンドの JavaScript 側で encodeURIComponent() 関数を使う理由が見当たりません。サーバー側で + を半角スペースにデコードできないライブラリを使わざるを得ない、という状況に陥った場合にのみ、encodeURIComponent() 関数を使うことになるのでしょう。

もちろん、すべてのケースにおいて URL インターフェイスで大丈夫とは言い切れません。まずは、サーバー側で + が半角スペースにデコードされるのかだけは事前に確かめておくと良いですね。たぶん、大丈夫だとは思いますが。。。

URL エンコード文字列をデコードする

本記事の冒頭で URL インターフェイスを使った URL エンコードのコードを紹介しましたが、ここではデコードの方法を見てみましょう。

次のコードは、クエリパラメータ付きの URL から、特定のパラメータ値を抜き出しています。

const url = new URL('https://webfrontend.ninja/samples?title=Hello+World');
const title = url.searchParams.get('title');
console.log(title); // Hello World

期待通り + は半角スペースに戻されています。URL インターフェイスは %20 も期待通りに半角スペースに戻してくれます。

const url = new URL('https://webfrontend.ninja/samples?title=Hello%20World');
const title = url.searchParams.get('title');
console.log(title); // Hello World

しかし、decodeURIComponent() 関数は + を半角スペースに戻すことはしませんので注意してください。

console.log(decodeURIComponent('Hello+World')); // Hello+World

基本的に、encodeURIComponent() 関数でエンコードされたものは URL インターフェイスで期待通りにデコードできますが、URL インターフェイスでエンコードされたものは decodeURIComponent() 関数で期待通りにデコードすることはできません。

まとめ

URL インタフェースはすべてのメジャーブラウザーで実装済みです。今後、encodeURIComponent() 関数と decodeURIComponent() 関数を使うことは無くなっていくのでしょう。URL インターフェイスと encodeURIComponent() 関数の違いついての筆者のモヤモヤは解消されたのですが、みなさんはいかがでしょうか。筆者の解釈に間違いなどがあれば、ご指摘いただけると幸いです。

今回は以上で終わりです。最後まで読んでくださりありがとうございました。それでは次回の記事までごきげんよう。

Share