日本語の URL が %E6%B8%8B… になる理由:URL エンコードのしくみ

検索結果のリンクをコピーすると日本語が %E6%B8%8B… に変わる理由、encodeURI・encodeURIComponent・URLSearchParams の違い、空白の %20 と +、%2520 のような二重エンコードの原因をまとめました。

開発Web

「渋谷 カフェ」で検索して、そのページのリンクを 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進数で書きます。

パーセントエンコーディングの流れを示す図。韓国語の「검」(U+AC80)は UTF-8 で EA、B2、80 の3バイトで、パーセントエンコードすると %EA%B2%80 になります。日本語の「あ」(U+3042)は E3、81、82 の3バイトで %E3%81%82 になります。下には、空白が RFC 3986 の方式では %20、HTML フォームの方式では + になるという比較があります。

ひらがなや漢字の多くは 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

ブログへ