URL / HTML / Unicode エスケープ使い分け完全ガイド

カラフルなコード表示 Photo: Unsplash

URL に日本語を書いたら %E3%81%82%E3%81%84 みたいな呪文に化けた、HTML に & をそのまま書いたらバリデータに怒られた、JSON の文字列に \u3042 と書いてあるけどこれって何者?——「エスケープ」とひとことで呼ばれるけれど、Web まわりで登場する符号化は少なくとも 3 種類あります。URL エンコードHTML エンティティUnicode エスケープ。目的も書き方もまったく違うのに、名前が似ていて混ざりがち。本記事はこの 3 つを一本の地図にまとめて、「どこで何を使うか」を即答できるレベルに整理します。percent encoding の歴史から絵文字のサロゲートペア、二重エンコードの落とし穴まで、一気に駆け抜けましょう。

なぜ URL には % エンコードが必要なのか

URI の仕様は 1994 年の RFC 1738「Uniform Resource Locators (URL)」で正式化され、2005 年に RFC 3986 として改訂されました。仕様は URL に書ける文字を ASCII の印字可能文字のうちごく限られたサブセットに限定しています。理由はシンプルで、当時のネットワーク機器・プロトコル・メール経路はすべて 7 ビット ASCII 前提で作られており、8 ビット目や制御文字を安全に通す保証がなかったから。空白・日本語・多くの記号はそのまま書けないので、バイト値を %XX(16 進 2 桁)の形で表現する仕組みが導入されました。これが percent encoding です。URL に登場する %E3%81%82 のような列は、UTF-8 バイトを 16 進で書き下しているだけ、というわけです。

URL / HTML / Unicode の 3 つのエスケープ

まずは全体像です。「エスケープ」という言葉は広くて、文脈によって指しているものが違います。Web 開発で日常的に出会う符号化は、ざっくり次の 3 種類に分かれます。それぞれ「目的」「対象文字」「出力フォーマット」がぜんぶ別物だと理解するのが第一歩です。

  • URL エンコード(percent encoding): URL の一部として書くときに使う。対象は予約文字・ASCII 印字不能文字・非 ASCII 文字すべて。出力は %XX の列。
  • HTML エンティティ(文字参照): HTML ソースの中に書くときに使う。対象は & < > " ' の 5 文字が基本。出力は &amp; のような名前付き参照か &#x26; のような数値参照。
  • Unicode エスケープ: ソースコードや JSON の文字列リテラルに非 ASCII 文字を書くときに使う。対象は BMP(\uXXXX)と BMP 外(\u{XXXXX}\UXXXXXXXX)。出力は \u3042 のようなバックスラッシュ始まりの列。

3 つとも「生のままだと困るので別の表現に置き換える」という点では同じですが、困る理由と置き換え方法がそれぞれ違います。URL は「RFC 3986 に書けない文字があるから」、HTML は「構文と衝突するから/XSS を防ぎたいから」、Unicode エスケープは「ソースコードに ASCII だけで書きたいから(または見えない文字を明示したいから)」。この 3 つを混同すると、URL の中に &amp; を書いて壊したり、HTML 属性に URL エンコードをかけて二重エスケープになったり、JSON に %20 を書いて人間が混乱したりします。読者の頭の中でもこの 3 つを分ける引き出しを作っておきましょう。

ちなみに「エスケープ」という単語の元はタイプライターのエスケープキー由来で、「次に続く文字を特殊な意味で解釈させる脱出口」のニュアンスです。バックスラッシュもパーセント記号もアンパサンドも、この脱出口を示すマーカーとして使われている、と見ると 3 つの共通点と違いが見えてきます。

URL エンコード(percent encoding, RFC 3986)

percent encoding の原理は単純です。「ある文字をそのまま URL に書けないなら、その文字の バイト値を 16 進 2 桁にして、頭に % を付ける」。たったこれだけ。たとえば空白は ASCII で 0x20 なので %20# は 0x23 なので %23+ は 0x2B なので %2B。1 バイトにつき %XX の 3 文字を消費するので、英字だけの URL と比べて日本語の URL は一気に冗長になります。

ここで一番大事なのは「バイト単位で処理する」という点です。文字ではなく バイト なので、マルチバイト文字は事前にバイト列へ落としておく必要があります。現在はほぼすべての処理系で UTF-8 が前提になっており、「あ」を UTF-8 で表すと 0xE3 0x81 0x82 の 3 バイトなので、URL エンコードすると %E3%81%82 の 9 文字になります。1 文字が 9 文字になる、これが「日本語の URL がやたら長くなる」正体です。

JavaScript で触る場合、標準は encodeURIComponent です。こちらは RFC 3986 にかなり忠実で、unreserved 文字(A-Z a-z 0-9 - _ . ~)以外はすべてエンコードします。もうひとつ encodeURI という関数もありますが、こちらは「URL 全体を壊さずにエスケープする」目的で、: / ? # などの予約文字をそのまま通します。使い分けの鉄則は、パスやクエリのパーツ(値)には encodeURIComponent、URL 全体を緩くエスケープしたいときだけ encodeURI。迷ったら encodeURIComponent を選ぶのが安全です。

// JavaScript
encodeURIComponent('あ');             // → '%E3%81%82'
encodeURIComponent('hello world');    // → 'hello%20world'
encodeURIComponent('a&b=c');          // → 'a%26b%3Dc'
encodeURI('https://example.com/あ');  // → 'https://example.com/%E3%81%82'
encodeURI('a&b=c');                   // → 'a&b=c'(& も = もそのまま!)

見てのとおり encodeURI&= もエスケープしないので、クエリ値を作る用途にはまったく向いていません。「URL ぜんぶをざっくり安全化する」と思って encodeURI を使うとハマるので気をつけてください。

URL で予約されている文字

RFC 3986 は URL の各文字を 3 つのグループに分類しています。unreserved(そのまま書ける)、reserved(構文上の意味を持つのでエスケープが必要な場合あり)、そして それ以外(常にエスケープが必要)。reserved はさらに gen-delims(URI 構造を区切る)と sub-delims(コンポーネント内の区切り)の 2 種類に分かれます。整理すると次の表のとおりです。

分類文字意味
unreservedA-Z a-z 0-9 - _ . ~どこでもそのまま書ける
gen-delims: / ? # [ ] @URI の大きな構造を区切る
sub-delims! $ & ' ( ) * + , ; =クエリやパス内の区切り
その他 ASCII<space> < > " { } | \ ^ ` など常にエスケープ必須
非 ASCII日本語・絵文字などUTF-8 バイト列に落としてエスケープ

gen-delims は URL の骨格を決める記号です。: はスキームとそれ以降、/ はパスの階層、? はクエリの開始、# はフラグメントの開始、@ はユーザー情報。これらをパス部やクエリ値の中にそのまま入れると、パーサが「構造の区切り」と誤読してしまいます。だからクエリ値に # を入れたいときは %23 にする必要があるし、パスセグメントに / を含めたいときは %2F にエスケープしないと階層が壊れる、というわけです。

sub-delims は「場所による」が一番厄介なグループです。&= はクエリの key=value&key=value を区切る意味を持つので、値の中に入れたいなら %26 %3D にしなければいけません。一方でパス部に書くなら &= はそのまま通せます。「この位置で構文的な意味を持つか」で判断が変わる、というのが RFC 3986 の設計です。

URL で日本語を扱う

日本語・中国語・絵文字などの非 ASCII 文字を URL に入れるときは、前述のとおり UTF-8 でバイト列に落としてから %XX する が基本ルールです。現在のブラウザ・サーバー・ライブラリはほぼすべて UTF-8 を前提に設計されているので、特別な指定をしなければ自動的に UTF-8 で処理されます。古い時代には Shift_JIS や EUC-JP でエンコードされた URL も存在しましたが、今となっては互換性の事故にしかつながらないので、生成側は必ず UTF-8 で統一しましょう。

実例を並べておきます。

文字  UTF-8 バイト列      percent encoding
あ    E3 81 82            %E3%81%82
い    E3 81 84            %E3%81%84
犬    E7 8A AC            %E7%8A%AC
😀    F0 9F 98 80         %F0%9F%98%80

気をつけたいのは ドメイン名(ホスト部)です。ここだけは percent encoding ではなく、Punycode(RFC 3492)という別の符号化を使います。たとえば「日本語.jp」のような国際化ドメイン名(IDN)は、内部的には xn--wgv71a119e.jp のような ASCII 文字列に変換されてから DNS に問い合わせされます。ブラウザのアドレスバーには日本語で表示されていても、実体は Punycode です。URL エンコードと Punycode は別世界なので、ホスト部にいきなり %E3%81%82.jp と書いても正しく解決されません。混同しやすいので注意してください。

もうひとつ現場で混乱しがちなのが Web フォームです。HTML フォームから送信されるデータの符号化は、<form enctype="..."> 属性で決まります。デフォルトの application/x-www-form-urlencoded では、フィールドの値が percent encoding されて送られますが、空白だけは特別扱いで + に変換されます。これが次の章のテーマです。

クエリとパスで挙動が違う点

URL の「同じ記号」がパスとクエリで意味を変える、という罠があります。代表格が + です。

  • クエリ文字列の中(application/x-www-form-urlencoded 形式)では、+ は空白として解釈されます。歴史的経緯で、空白を毎回 %20 と書く代わりに短い + で書けるようになっているためです。
  • パス部では、+リテラルの + 文字として扱われます。RFC 3986 のサブデリミタに含まれているので、そのままの意味です。

つまり同じ /foo+bar?q=foo+bar は、前者が「foo+bar」というパス、後者は「foo bar」というクエリ値、と解釈されます。サーバー側のフレームワークが自動でこの変換をかけてくれるため、開発者からは見えにくいのですが、手でパース処理を書くときにはハマりどころです。

文字パスでの扱いクエリでの扱い
空白%20+ または %20
+リテラルの +空白として解釈
&リテラルの &(OK)パラメータ区切り、値に入れるなら %26
=リテラルの =(OK)key/value 区切り、値に入れるなら %3D
/パス階層区切りリテラルの /(OK)

JavaScript の encodeURIComponent は空白を %20 にエンコードします。一方、URLSearchParams オブジェクト経由で組み立てると空白は + になります。同じ「空白」でも API によって出力が違うので、サーバー側が両方を受け取れる実装になっているかは確認しておく価値があります。Python だと urllib.parse.quote はデフォルトで空白を %20urllib.parse.quote_plus+、と使い分ける関数が用意されています。

# Python
from urllib.parse import quote, quote_plus
quote('hello world')        # → 'hello%20world'
quote_plus('hello world')   # → 'hello+world'
quote('あ')                 # → '%E3%81%82'
// PHP
urlencode('hello world');    // → 'hello+world'(クエリ向け)
rawurlencode('hello world'); // → 'hello%20world'(パス向け)

PHP の urlencoderawurlencode の違いはまさにここです。urlencode はクエリ用(+ で空白を表す)、rawurlencode はパス用(%20)、と覚えておくと選び間違えが減ります。

HTML エンティティ(&amp; &lt; &nbsp;

HTML の中にテキストを書くとき、<& のような記号は特別な意味を持っています。そのまま書くと HTML パーサがタグの開始や文字参照の開始と誤解してしまうので、代わりに 文字参照(character reference)で書き直します。これが HTML エンティティです。最低限覚えておくべきは 5 文字。

文字名前付き参照10 進参照16 進参照
&&amp;&#38;&#x26;
<&lt;&#60;&#x3C;
>&gt;&#62;&#x3E;
"&quot;&#34;&#x22;
'&apos;&#39;&#x27;

文字参照には 3 種類の書き方があります。名前付き参照&amp;)、10 進数値参照&#38;)、16 進数値参照&#x26;)。名前付き参照は HTML5 で一気に増えて、現在は 2000 個以上あります(&nbsp;&copy; はおなじみ、&trade; で ™、&hellip; で …、&rarr; で → まで)。数値参照は Unicode コードポイントを直接指定できるので、名前のない文字や絵文字までカバーできます。たとえば 😀 は &#128512; または &#x1F600; と書けます。

よく勘違いされるのは &nbsp;(non-breaking space, U+00A0)と普通の半角スペース(U+0020)です。見た目はほぼ同じですが、&nbsp; は「ここで改行しないでほしい」というヒントを持った特殊な空白で、連続したスペースも HTML で潰されずに残ります。通常の空白は連続しても 1 個に潰されるので、意図的に複数スペースを表示したいときや、数字と単位の間で折り返しを防ぎたいとき(「100 km」の 100 と km の間など)に使い分けます。

現代のテンプレートエンジン(React、Vue、Eleventy の Nunjucks、Django、Rails の ERB など)は、デフォルトで変数展開時に HTML エンティティへ自動エスケープしてくれます。明示的に「エスケープしないで」と指定しない限り、ユーザー入力が <script> のままテンプレートに埋まることはありません。次章で見るように、これが XSS 対策の第 1 防衛線になっています。

XSS 対策としての HTML エスケープ

HTML エンティティが最も活躍するのが XSS(Cross-Site Scripting)対策です。ユーザーが入力した文字列をそのまま HTML に埋め込むと、悪意ある <script> が実行されてしまう可能性があります。だからテキストとして表示する前に、メタ文字を文字参照に置き換えて「データ」として解釈させる必要があります。

// 入力
const input = '<script>alert(1)</script>';

// エスケープ後(HTML テキストとして安全)
// → '&lt;script&gt;alert(1)&lt;/script&gt;'

ただし、ここで重要なのは 「HTML のどこに出すか」によって必要なエスケープが変わる という点です。同じユーザー入力でも、HTML テキストとして出すのか、属性値として出すのか、JavaScript 文字列の中に出すのか、CSS 値として出すのか、URL として出すのかで、エスケープ方法が違います。OWASP の XSS Prevention Cheat Sheet では、この「出力コンテキスト」を 6 種類に分類しています。

出力コンテキスト必要なエスケープ
HTML 本文(テキストノード)5 文字(& < > " ')を文字参照化
HTML 属性値(引用符あり)5 文字を文字参照化、できれば全非英数字も
HTML 属性値(引用符なし)空白・タブ・改行まで含めて広めにエスケープ
JavaScript 文字列リテラルJS の文字列エスケープ(\' \" \\ \n など)
URL 属性値(href srcURL エンコード + javascript: スキームの検査
CSS 値CSS エスケープ(\XX)、expression() は拒否

「6 パターンを手で書き分けるの無理では?」と思うのが普通で、だからこそ現代のテンプレートエンジンは出力コンテキストを自動判定してくれます。React の JSX {variable} はテキストノード扱い、Vue の v-bind は属性値扱い、といった具合です。基本的には フレームワークのエスケープを信頼して、dangerouslySetInnerHTMLv-html のような「エスケープしない出力」をやむを得ず使うときだけ、自分でサニタイズする のが正攻法です。DOMPurify のような専用ライブラリを通すのが安全策の定番です。

innerHTML に入れる前に必ずエスケープ

「自分で element.innerHTML = userInput と書いている」時点で危険信号です。innerHTML 代入は HTML として解釈するので、<script><img onerror> が実行されます。テキストとして設定したいだけなら element.textContent = userInput を使えば自動でエスケープされます。HTML 構造ごと入れ替えたいときは DOMPurify などのサニタイザを必ず経由させましょう。

Unicode エスケープ(\u3042 \U0001F600

Unicode エスケープは「プログラムのソースコードや JSON のような文字列リテラルの中で、非 ASCII 文字をバックスラッシュ記法で書く」ための仕組みです。URL エンコードが URL という 伝送経路 のためのもの、HTML エンティティが HTML という 文書形式 のためのものだとすれば、Unicode エスケープは ソースコード という文脈のための仕組み、と整理できます。

主な書き方は言語によって少しずつ違いますが、だいたい次のような形です。

言語 / 形式BMP(U+0000〜U+FFFF)BMP 外(U+10000 以上)
JavaScript\u3042\u{1F600}(ES2015 以降)
JSON\u3042サロゲートペア 2 つ(\uD83D\uDE00
Python\u3042\U0001F600 または \N{GRINNING FACE}
C / C++\u3042\U0001F600
Java\u3042サロゲートペア 2 つ
Rust\u{3042} / \u{1F600}(中括弧で統一)

JavaScript は ES2015 で \u{...} 記法が追加される前、BMP 外の文字はサロゲートペアで書くしかありませんでした。現在でも JSON の仕様は \uXXXX の 4 桁形式のみなので、BMP 外の文字を JSON に入れると 2 つの \u で表現されます。たとえば 😀 は JSON 上で "\uD83D\uDE00" と書かれます。この 2 つの 16bit 値がサロゲートペア、次章の主役です。

// JavaScript
'\u3042';         // → 'あ'
'\u{1F600}';      // → '😀'(ES2015 以降)
'\uD83D\uDE00';   // → '😀'(サロゲートペア表記)

# Python
'\u3042'                 # → 'あ'
'\U0001F600'             # → '😀'
'\N{GRINNING FACE}'      # → '😀'(名前参照)

Python の \N{...} は Unicode の正式な文字名で書ける珍しい形式です。コメント代わりに何の絵文字か明記したいときに便利。なお、C 言語系では \u が 4 桁、\U(大文字)が 8 桁を表します。混同しないよう注意。

もうひとつ Unicode エスケープの使いどころが、見えない文字の明示 です。ゼロ幅スペース(U+200B)、結合文字、BOM、そっくりに見えるが実は別の文字(キリル文字の а と ラテン文字の a)など、コピペで混入したときに目で判別できない文字は、\u200B のように書けば一目瞭然。JSON のログ出力で見つける、みたいな使い方ができます。

サロゲートペアと絵文字

ここは Unicode とプログラミング言語の接点で、いちばん混乱しやすい領域です。まずは事実を並べます。

  • Unicode の全コードポイントは U+0000〜U+10FFFF。上限は約 110 万個。
  • U+0000〜U+FFFF を BMP(Basic Multilingual Plane、基本多言語面)と呼ぶ。ここには漢字・ひらがな・カタカナを含むほとんどの「普通の文字」が収まる。
  • U+10000 以上は 補助面(supplementary planes)。絵文字の大部分、一部の記号、歴史的文字(古代文字など)、一部の漢字(CJK 拡張 B 以降)がここにある。

問題は UTF-16 です。UTF-16 は 1 コード単位が 16 ビット(= 0x0000〜0xFFFF の 65536 通り)しかないので、U+10000 以上の文字はそのままでは表せません。そこで「2 つの 16bit 値のペアで 1 文字を表す」仕組みが用意されました。これが サロゲートペア です。使う範囲は U+D800〜U+DFFF(High Surrogate と Low Surrogate)で、この 2048 個の領域だけは「文字」ではなく「ペアの片割れ」として予約されています。

JavaScript の文字列は内部的に UTF-16 を採用しているため、サロゲートペアがそのまま露出します。代表例:

'あ'.length;       // → 1(BMP 内、16bit 1 つ)
'𝄞'.length;       // → 2(U+1D11E、16bit 2 つ)
'😀'.length;       // → 2(U+1F600、16bit 2 つ)
'👨‍👩‍👧'.length;   // → 8(父+ZWJ+母+ZWJ+娘、計 4 文字 + 3 ZWJ ≒ 8 単位)

// コードポイント単位で数えたいとき
[...'😀'].length;  // → 1
Array.from('😀').length; // → 1

「絵文字 1 つなのに length が 2 になる」のはこれが原因です。string.length は UTF-16 の コード単位数 を返す仕様なので、補助面の文字は必ず 2 と数えられます。文字単位(コードポイント単位)で数えたいときは [...str]Array.from(str) でイテレータ経由にすれば、スプレッド演算子がサロゲートペアをまとめて 1 つに扱ってくれます。

さらに複雑になるのが、ZWJ シーケンス(Zero-Width Joiner)です。「家族」絵文字(👨‍👩‍👧)は「父」「母」「娘」の 3 つの絵文字を ZWJ(U+200D)でつないだシーケンスで、見た目は 1 文字でも内部的には 7〜8 個の 16bit 単位で構成されます。肌色のトーンを指定する絵文字(👍🏽)も、絵文字 + 色調修飾子(Fitzpatrick Modifier)の組み合わせ。「ユーザーが見ている 1 文字」を正確に数えたいときは、Intl.Segmenter(ES2022)のような「書記素クラスタ単位」で分割する API を使う必要があります。

// 書記素単位で数える(モダンブラウザ)
const seg = new Intl.Segmenter('ja', { granularity: 'grapheme' });
[...seg.segment('👨‍👩‍👧😀あ')].length; // → 3

絵文字を含むテキストの「文字数カウント」がなぜこんなに厄介かというと、「文字」の定義が少なくとも 3 つ(コード単位 / コードポイント / 書記素クラスタ)ある、という Unicode の本質的な複雑さのせいです。SNS の文字数制限や入力バリデーションでは、どの単位で数えているかを仕様に書いておくべきです。

二重エンコードの落とし穴

現場でよく見るバグに「二重エンコード」があります。一度エンコードされた文字列を、別のレイヤーがもう一度エンコードしてしまうパターンです。

// 元の文字列
const s = 'hello world';

// 1 回目のエンコード
const once = encodeURIComponent(s);
// → 'hello%20world'

// もう一度かけると %20 の % がさらにエンコードされる
const twice = encodeURIComponent(once);
// → 'hello%2520world'   ← %25 は % のエンコード!

こうなると、受け取り側が 1 回デコードしても hello%20world という エンコードされた文字列のような何か が出てくるだけで、元の「hello world」は取り戻せません。さらにもう 1 回デコードすれば戻りますが、「何回デコードすれば正解なのか」をデータから判別するのは不可能です。

「どの層でデコードされるか」を必ず把握する

二重エンコードの根本原因は、Web フレームワークが「自動デコードする層」と「しない層」をごちゃまぜにしていることです。たとえば Express の req.query は自動デコード済みですが、req.url は生のまま。Rails の params も自動デコード済みです。この前提を知らずに、自動デコード済みの値に decodeURIComponent を重ねがけすると、意図せず本物の %20 まで空白に変換されてデータが壊れます。「自分の層より前でデコードされているか」を先にドキュメントで確認する癖をつけましょう。

もうひとつ典型的なのがブラウザのリダイレクトです。ブラウザはアドレスバーに日本語が入力されるといったんエンコードしてからサーバーに送ります。サーバーが受け取った URL を「デコード済み」だと思い込んで再度エンコードしてリダイレクトすると、次に届くリクエストは %2520 まみれになります。ロケーションヘッダを組み立てるときは「自分がエンコードすべきか、それとも受け取ったままでよいか」を毎回チェックすることが大事です。

デバッグのコツは「エンコードの段数を数えること」。一度デコードして % が残っていたら、まだエンコードされた層が 1 つある、と判断できます。% がゼロになるまで繰り返しデコードしてはいけない(正当な % を含む文字列まで壊してしまう)ので、層の数は仕様として決めておく必要があります。

使い分けチャート

ここまでの内容を「テキストをどこに出すか」で整理すると、次のような早見表にまとまります。迷ったらここに戻ってきてください。

出力先使うエスケープツール / API
URL のパスセグメントURL エンコードencodeURIComponent / rawurlencode / quote
URL のクエリ値URL エンコード(空白 + も可)URLSearchParams / urlencode / quote_plus
URL のフラグメント(# 以降)URL エンコードencodeURIComponent
HTML 本文(テキスト)HTML エンティティ(5 文字)テンプレートエンジンの自動エスケープ
HTML 属性値HTML エンティティ + 引用符統一テンプレートエンジンの自動エスケープ
JavaScript 文字列リテラルJS 文字列エスケープ + UnicodeJSON.stringify が無難
JSON 文字列JSON 仕様のエスケープ(\uXXXXJSON.stringify
ソースコードリテラルUnicode エスケープ\u{XXXX} / \U0001XXXX
メール本文(7bit 経路)Quoted-Printable / Base64MIME ライブラリ

表を眺めていると、「HTML の属性値に URL を入れる」ような 2 段構えの文脈 があることに気づくはずです。たとえば <a href="/search?q=ユーザー入力"> の中身は、内側に URL エンコードをかけてから、さらに外側に HTML エンティティをかける必要があります。?q=a&b をそのまま書くと &b が HTML エンティティとして解釈されてしまうので、?q=a&amp;b と書くのが正解です。テンプレートエンジンの自動エスケープを使っていれば、内側の URL エンコードは encodeURIComponent で事前に済ませ、外側のエンティティ化はフレームワークにまかせる、という分業がきれいに効きます。

3 行で覚える使い分け

「URL の一部にする → percent encoding」「HTML に出す → 文字参照」「ソースに埋める → Unicode エスケープ」。この 3 行を覚えておけば、少なくとも方向性を間違えることはなくなります。そして 2 つの文脈が重なるときは「内側を先にエスケープ、外側を後からエスケープ」の順序を守る。これで 9 割方事故は防げます。

3 ツールの使い方

ベンリーでは本記事で扱った 3 つのエスケープそれぞれに対応するツールを用意しています。どれもブラウザ内で完結するので、入力データがサーバーに送信されることはありません。機密文字列をそのまま貼っても安心です。

URL エンコード / デコード

テキストを percent encoding に変換したり、その逆をしたりできます。encodeURIComponent 相当のモード(すべての予約文字をエスケープ)と、encodeURI 相当のモード(URL の骨格を残す)を切り替えられるので、「この部分だけエンコードしたい」といった微調整にも対応します。日本語はもちろん絵文字も UTF-8 前提で処理されます。

HTML エンティティ変換

テキストを HTML 文字参照に変換、または逆変換します。基本の 5 文字だけをエスケープするモード、すべての非 ASCII 文字をエスケープするモード、名前付き参照と数値参照の切り替えなど、出力形式を細かく指定できます。ブログ記事の HTML 片をそのまま貼り付けて、テンプレートに埋め込める形に整えたいときに便利です。

Unicode 変換

文字 ↔ コードポイント ↔ UTF-8/UTF-16 バイト列の相互変換ができます。「この絵文字のコードポイントは何?」「この \uD83D\uDE00 は何の文字?」といった疑問に即答できるツールで、本記事で扱ったサロゲートペアの検証にも使えます。ゼロ幅スペースなどの見えない文字を貼り付けて \u200B のような表記に変換すれば、目で見てわからない混入も発見できます。

3 つのツールは相互に補完しあう関係にあります。たとえば「HTML のテキストとして表示したい URL」を作りたいとき、まず URL エンコードツールで percent encoding し、次に HTML エンティティツールで 5 文字を文字参照化する、という 2 段階の変換を手で確認できます。フレームワークの自動エスケープを信頼しきれないときのセーフティネットとして使ってください。

文字ごとの比較表(参考)

同じ文字が 3 つの符号化でどう見えるかを並べておきます。頭の中の引き出しを整理するときに眺めてみてください。

文字URL エンコードHTML エンティティUnicode エスケープ(JS)
空白%20&nbsp;(nbsp の場合)\u0020
&%26&amp;\u0026
<%3C&lt;\u003C
%E3%81%82&#12354;\u3042
😀%F0%9F%98%80&#128512;\u{1F600}

同じ 😀 の表現が 3 つの符号化でまったく別物になっているのが見えると思います。URL では UTF-8 バイト列 4 つ分、HTML ではコードポイントを 10 進で直書き、Unicode エスケープではコードポイントを 16 進で直書き。どれも「文字そのもの」ではなく「文字を指し示すための別表現」なのだ、という本質が見えてきます。

よくある質問

encodeURIencodeURIComponent の違いは?

encodeURI は「URL 全体をざっくりエスケープ」する目的の関数で、: / ? # & = などの予約文字はそのまま通します。スキーム・ホスト・パス・クエリの構造を保ちたいときに使います。一方の encodeURIComponent は「URL の一部(値)をエスケープ」する目的で、予約文字も含めて A-Z a-z 0-9 - _ . ~ 以外はすべてエンコードします。迷ったら encodeURIComponent を使うのが鉄則。クエリ値やパスセグメントを組み立てる場面では常にこちらです。encodeURI は「URL 全体をそのまま渡せる安全版」と誤解されがちですが、値の中の & を通してしまうので、クエリ組み立てには絶対に使ってはいけません。

& はなぜ HTML で &amp; にしないといけない?

& は HTML で「文字参照の開始」を意味する特別な記号だからです。& を見つけた HTML パーサは、その後に amp;#38; のような参照が続くことを期待します。生の & をそのまま書くと、後ろの文字列によっては意図しない文字参照として解釈されてしまったり、構文エラーになったりします。たとえば &copy はセミコロンがなくても © と解釈されるブラウザがあります。安全のため、「リテラルの & を書きたいなら常に &amp;」と覚えておきましょう。HTML5 では「続く文字列が既知のエンティティに一致しない場合はリテラル扱いでよい」という寛容ルールもありますが、将来どの文字列がエンティティ名になるかわからないので、明示的にエスケープするのが正解です。

絵文字を入力すると文字数が 2 になる理由は?

JavaScript の string.length は「UTF-16 のコード単位数」を返す仕様だからです。絵文字の多くは Unicode の U+10000 以上(BMP 外)に定義されており、UTF-16 で表現するには 16bit 2 つ(サロゲートペア)が必要です。そのため 😀 や 🎉 のような絵文字は length が 2 と数えられます。コードポイント単位で正しく数えたいときは [...str].lengthArray.from(str).length を使えば 1 になります。さらに「見た目の 1 文字」(家族絵文字や肌色付き絵文字などの ZWJ シーケンス)を 1 と数えたいときは Intl.Segmenter で書記素クラスタ単位に分割する必要があります。文字数制限を実装するときは「どの単位で数えるか」を仕様に書いておくのが大事です。

URL の + は空白?

場所によります。クエリ文字列(? 以降)では、application/x-www-form-urlencoded の仕様により + は空白として解釈されます。歴史的経緯で、空白を毎回 %20 と書く代わりに + で書けるようになっているためです。一方でパス部(? より前)では、+ はリテラルの「プラス記号」として扱われます。つまり同じ URL 内でも位置によって意味が違う、という地味にイヤな仕様です。JavaScript の URLSearchParams は空白を + に変換しますが、encodeURIComponent%20 に変換します。サーバー側はどちらも受け取れるように実装されているのが普通ですが、手で URL をパースするときはこの違いを忘れないようにしましょう。

&nbsp; と普通の空白の違いは?

&nbsp;(non-breaking space, U+00A0)は「折り返し禁止の空白」で、通常の半角スペース(U+0020)とは別の文字です。見た目はほぼ同じですが、2 つの大きな違いがあります。1 つ目は 連続スペースの扱い。HTML では通常の空白を連続して書いても 1 個に潰されますが、&nbsp; は何個書いてもそのまま残ります。インデントや意図的な間隔調整に使えます。2 つ目は 改行の扱い。通常の空白は行の折り返し位置になれますが、&nbsp; は「ここでは折り返さないでください」というヒントを持っています。たとえば「100 km」や「Dr. Smith」の間に &nbsp; を入れると、100kmDr.Smith が別々の行に分かれるのを防げます。タイポグラフィを整えたいとき、気配りの 1 段上の選択肢として覚えておくと便利です。

3 つのエスケープを手元で試したいなら

ベンリーの URL エンコード・HTML エンティティ・Unicode 変換ツールは、すべてブラウザ内で完結するので機密文字列を貼っても安全です。本記事の例を実際に打ち込んで、3 つの符号化の違いを目で確かめてみてください。

URL エンコードツール → HTML エンティティツール → Unicode 変換ツール →