encodeURIComponentとURLSearchParamsで記号の扱いが違う — +と空白を比べる
URLのクエリに値を入れるとき、JavaScript には encodeURIComponent と URLSearchParams の2つの道具がある。どちらも日本語や空白を %xx にするので同じものに見えるが、残す記号の種類と空白の書き方が違う。片方でエンコードした文字列をもう片方の流儀でデコードすると、+ が空白になったり、ならなかったりする。
この記事では ハシトシステムのURLエンコード・デコードツール のコードを入口に、ASCIIの記号33個を encodeURIComponent / encodeURI / URLSearchParams に通して、どれが残るかを Node.js で数える。
ツールは encodeURIComponent と decodeURIComponent をそのまま呼ぶ
ツールの変換部分は次の数行である(index.html の convert() から)。
1 | |
エンコードは 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 | |
1 | |
出力:
1 | |
空白を含む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 で行う。処理はブラウザ内で完結する。