日本語の URL が %E6%B8%8B… になる理由:URL エンコードのしくみ
検索結果のリンクをコピーすると日本語が %E6%B8%8B… に変わる理由、encodeURI・encodeURIComponent・URLSearchParams の違い、空白の %20 と +、%2520 のような二重エンコードの原因をまとめました。
「渋谷 カフェ」で検索して、そのページのリンクを LINE に貼ると、こんな文字列になります。
https://www.google.com/search?q=%E6%B8%8B%E8%B0%B7+%E3%82%AB%E3%83%95%E3%82%A7
アドレスバーには確かに「渋谷 カフェ」と表示されていたのに、コピーすると記号の列です。それでも開けば同じ検索結果が出ます。リンクが壊れたわけではありません。これがパーセントエンコーディング、一般に URL エンコードと呼ばれるものです。
%E6 は文字ではなくバイト
URL に使える文字は、英字・数字といくつかの記号だけと決まっています(RFC 3986)。日本語を入れるには、まず文字を UTF-8 のバイト列に変え、1バイトずつ % と2桁の16進数で書きます。
ひらがなや漢字の多くは UTF-8 で3バイトなので、1文字が %XX 3つ、9文字分になります。「渋谷」の2文字が %E6%B8%8B%E8%B0%B7 の18文字に増えるのはこのためです。%E6 だけを取り出しても意味はありません。3バイトがそろって初めて1文字になります。
逆に、戻すときにバイトが足りないとエラーになります。JavaScript の decodeURIComponent('%E3%81') は URIError: URI malformed を投げます。長い URL を途中で切って貼り付けたらページがエラーになった、というのはたいていこれです。
そのまま残す文字、変える文字
RFC 3986 は、URL の文字を2種類に分けています。
| 分類 | 文字 | 扱い |
|---|---|---|
| 非予約文字 | A–Z a–z 0–9 - . _ ~ |
常にそのまま |
| 予約文字 | : / ? # [ ] @ ! $ & ' ( ) * + , ; = |
区切りとして使うときはそのまま、値の中ではエンコード |
予約文字は URL の骨組みです。? から後ろがクエリ、& はパラメータの区切り、= は名前と値の区切りです。検索語そのものに & が含まれる(たとえば「M&A 入門」)なら、必ず %26 にしなければなりません。そうしないと、サーバーはそこでパラメータが終わったと読みます。
3つの方法で結果が違う理由
JavaScript にはエンコードの方法がいくつかあり、残す文字が少しずつ違います。同じ入力で比べると違いがはっきりします。
| 方法 | そのまま残すもの | a b&c の結果 |
|---|---|---|
encodeURI() |
非予約文字と ; , / ? : @ & = + $ #、それに ! * ' ( ) |
a%20b&c |
encodeURIComponent() |
非予約文字と ! * ' ( ) |
a%20b%26c |
URLSearchParams |
英数字と * - . _ |
a+b%26c |
encodeURI() は URL 全体を受け取る前提です。区切りを壊してはいけないので /、?、& を残します。そのぶん値の中の & も残るため、検索語1つをこの関数でエンコードすると、先ほどの事故が起きます。
encodeURIComponent() は URL の一部分、つまりクエリの値やパスの1階層をエンコードするためのものです。ほとんどの場合はこちらが正解です。ただし ! * ' ( ) は RFC 3986 では予約文字なのにエンコードしません。これらの記号を特別に扱う相手(一部の署名方式など)に送るときは、自分で変換する必要があります。
URLSearchParams は、HTML フォームがデータを送る形式(application/x-www-form-urlencoded)に従います。クエリ文字列を組み立てるなら、文字列を手でつなげるよりこちらが安全です。
const params = new URLSearchParams({ q: "渋谷 カフェ", page: "2" });
`/search?${params}`; // "/search?q=%E6%B8%8B%E8%B0%B7+%E3%82%AB%E3%83%95%E3%82%A7&page=2"
空白は %20 にも + にもなる
上の結果では、空白が + になっています。RFC 3986 のパーセントエンコーディングでは空白は %20 ですが、HTML フォームの形式では + です。どちらも標準で、使う場所が違うだけです。
困るのは、両方が混ざったときです。+ を空白として読むのはフォーム形式の規則なので、decodeURIComponent('a+b') は a+b をそのまま返します。一方 new URLSearchParams('q=a+b').get('q') は a b です。電話番号の +81 90… や 1+1 のような式がクエリに入ると、受け取る側がどちらの規則で読むかによって + が消えます。値に + が入りうるなら、送る側で %2B に変えておくのが確実です。
同じブラウザの中でも分かれます。new URL('https://example.com/?q=a b').href は空白を %20 に、URLSearchParams は + にします。
%2520 が見えたら二重エンコード
ログに %2520 や %25E6 が出ていたら、ほぼ間違いなくエンコード済みの文字列をもう一度エンコードしています。% 自体をエンコードすると %25 になるからです。
"a b" → "a%20b" → "a%2520b"
よくあるのは、フロントエンドで encodeURIComponent した値を、さらに URLSearchParams や HTTP ライブラリの params オプションに渡すパターンです。ライブラリは受け取った値を生の文字列とみなして、もう一度エンコードします。エンコードは URL を組み立てる最後の1か所で1回だけ、と決めておけば、このバグはなくなります。
アドレスバーは人のために戻して見せている
では、最初にアドレスバーで「渋谷 カフェ」と見えていたのは何だったのでしょう。多くのブラウザは読みやすさのために、アドレスバーでだけパーセントエンコーディングを戻して表示し、サーバーへのリクエストやコピーしたリンクではエンコードされた形を使います。見えているものと実際にやり取りされているものは、別の文字列です。
なお、https://日本語.jp のようなドメイン部分はパーセントエンコーディングを使いません。ドメインは xn-- で始まる Punycode という別の方式に変わります。パーセントエンコーディングが扱うのはパスとクエリです。
貼り付けてその場で確認する
URLエンコード・デコードツールにコピーしたリンクを貼ると、日本語に戻して表示し、クエリパラメータを表に分けて見せます。エンコードするときは値1つ(encodeURIComponent)か URL 全体(encodeURI)かを選べ、デコードするときは + を空白とみなすかどうかも選べます。上の a b&c を両方の方式で入れてみると、表の違いをそのまま確かめられます。
参考
- RFC 3986, Uniform Resource Identifier (URI): Generic Syntax
- WHATWG, URL Standard(application/x-www-form-urlencoded)
- ECMAScript 仕様(ECMA-262)、
encodeURIとencodeURIComponent