文字コードと UTF-8 / Unicode の基礎 - 文字化けの原因と、length が合わない理由

文字コードと UTF-8 / Unicode の基礎 - 文字化けの原因と、length が合わない理由

作成日:
読了:11
更新日:

「保存したら文字化けした」「絵文字を入れたら DB がエラーになった」「JavaScript の length が見た目の文字数と合わない」——文字コードまわりのハマりどころは、原因を知らないと延々と悩まされます。

この記事では、Unicode と UTF-8 の関係から、文字化けの仕組みと対策、JavaScript の文字数カウントの罠までを、Unicode 公式・RFC 3629・WHATWG Encoding Standard を一次ソースに整理します。

文字集合とエンコーディングは別物

最初に押さえるべき区別がこれです。

  • 文字集合(Unicode): どの文字にどの番号(コードポイント U+XXXX)を割り当てるかの「台帳」
  • エンコーディング(UTF-8 / UTF-16 / UTF-32): その番号を実際のバイト列にどう変換するかの「方式」

たとえば「あ」は Unicode で U+3042 という番号(これは方式に依らず不変)。それを UTF-8 では E3 81 82 の3バイト、UTF-16 では 30 42 の2バイト、と別々のバイト列で表します。「Unicode という文字コードで保存」ではなく「Unicode を UTF-8 で符号化して保存」が正確な言い方です。

NOTE

HTML の <meta charset> や HTTP の charset= の「charset」は、厳密には文字集合ではなくエンコーディング(方式)を指して使われています。用語は歴史的に少しズレています。

Unicode の基礎

  • コードポイントの範囲は U+0000 〜 U+10FFFF。コードスペースの広さは 1,114,112 個(17面 × 65,536)
  • 面(Plane)は0〜16の17個。最頻出の文字を含む Plane 0 を BMP(基本多言語面、U+0000〜U+FFFF)と呼ぶ
  • BMP を超える範囲(U+10000 以降)が補助文字で、絵文字の多くはここ(Plane 1, SMP)にある

「1,114,112」は枠の広さであって、実際に文字が割り当てられている数(Unicode 16.0 で約15万)とは別物です。

UTF-8 の仕組み

UTF-8 は 1〜4バイトの可変長で、Web の事実上の標準です。RFC 3629 のビットパターンはこうなっています。

UTF-8 のバイト構造(RFC 3629)
コードポイント範囲     | UTF-8 バイト列(2進)
---------------------+------------------------------------------
U+0000  - U+007F     | 0xxxxxxx
U+0080  - U+07FF     | 110xxxxx 10xxxxxx
U+0800  - U+FFFF     | 1110xxxx 10xxxxxx 10xxxxxx
U+10000 - U+10FFFF   | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

ここから来る重要な性質が2つあります。

  • ASCII 互換: U+007F までは1バイトで、値も ASCII と同一。だから ASCII だけのファイルは UTF-8 と完全に同じバイト列になる
  • 自己同期性: 継続バイトは必ず 10xxxxxx で始まるので、途中の位置からでも文字の境界を復元できる

具体的なバイト列を見ると、可変長の感覚がつかめます。

文字コードポイントUTF-8バイト数
AU+0041411
U+3042E3 81 823
U+6F22E6 BC A23
U+20ACE2 82 AC3
🦊U+1F98AF0 9F A6 8A4

WHATWG Encoding Standard は「制作者は UTF-8 を使わなければならない」、HTML Standard は「charset の有効な値は utf-8 のみ」と定めています。新規のコンテンツで UTF-8 以外を選ぶ理由は基本的にありません。

JavaScript の length が合わない理由

JavaScript の文字列は UTF-16 ベースです。String.prototype.length が返すのは「文字数」ではなく UTF-16 コードユニットの数。ここに罠があります。

BMP を超える文字(絵文字など)は サロゲートペアとして2コードユニットで格納されるため、length が見た目とズレます。

3通りの数え方で値が変わる
"A".length            // 1
"".length           // 1
"🦊".length           // 2  ← サロゲートペアで 2
"🦊".codePointAt(0)   // 129418(U+1F98A)
 
// コードポイント単位で数える
[..."🦊"].length      // 1
Array.from("🦊").length // 1

さらに、結合文字(例: é を「e」+「結合アクセント U+0301」で表す)や、絵文字の ZWJ シーケンス(複数の絵文字を U+200D で繋いで1つに見せる)が絡むと、もっとズレます。

文字列.length(UTF-16)[...str].length(コードポイント)書記素(Intl.Segmenter
A111
🦊211
é(e + U+0301)221
👨‍👩‍👧‍👦(家族・ZWJ)1171

「人間が見て1文字」を数えたいなら、書記素クラスタ単位で数える Intl.Segmenter が正解です。

人間が見る文字数は Intl.Segmenter で
const str = "👨‍👩‍👧‍👦";
str.length;                 // 11(UTF-16 コードユニット)
[...str].length;            // 7(コードポイント)
 
const seg = new Intl.Segmenter("ja", { granularity: "grapheme" });
[...seg.segment(str)].length; // 1(書記素 = 人間が見る1文字)

NOTE

絵文字の .length は「2」とは限りません。肌の色違い・国旗・ZWJ シーケンスなどで構成が変わり、値も変わります。文字数制限のバリデーションを .length でやると、絵文字を含む入力で意図しない挙動になります。

正規化(NFC / NFD)

é は「U+00E9(合成済み)」と「U+0065 + U+0301(e + 結合アクセント)」の2通りで表せます。見た目は同じでもバイト列が違うので文字列比較は不一致になります。これを揃えるのが正規化です。

  • NFC: できるだけ合成する(一般テキストに推奨)
  • NFD: 分解する
正規化で揃える
const a = "é";              // U+00E9
const b = "";        // e + 結合アクセント
a === b;                    // false
a.normalize("NFC") === b.normalize("NFC"); // true

検索・比較・重複チェックで「同じに見えるのに一致しない」ときは、まず正規化を疑ってください。

文字化け(mojibake)の原因と対策

根本原因はいつも同じで、保存時と読み込み時のエンコーディング不一致です。同じバイト列でも、解釈に使うエンコーディングが違えば別の文字列になります(UTF-8 のテキストを Shift_JIS として読む、など)。典型的な発生源と対策を挙げます。

発生源対策
ファイルの保存エンコーディングUTF-8(BOM なし)に統一
HTML の宣言漏れ<meta charset="utf-8"> を先頭付近(1024バイト以内)に置く
HTTP ヘッダContent-Type: text/html; charset=utf-8 を明示
レガシー日本語Shift_JIS / EUC-JP / ISO-2022-JP を新規採用しない
URL エンコードパーセントエンコーディングは UTF-8 前提で行う

MySQL の utf8 は UTF-8 ではない(絵文字が入らない)

実務で多い事故がこれです。MySQL の utf8(= utf8mb3)は1文字最大3バイトで BMP しか扱えず、絵文字(4バイト)を保存できません。絵文字を入れようとすると Incorrect string value エラーになります。

  • utf8mb4 を使うのが正解(BMP + 補助文字に対応、最大4バイト)
  • utf8 は将来 utf8mb4 を指す予定とされるが、現状は utf8mb3 のエイリアスで非推奨

接続文字セット・カラム・テーブルすべてを utf8mb4 に揃えてください。

実務の指針

  • とにかく UTF-8 に統一(ファイル・通信・DB すべて)
  • HTML は <meta charset="utf-8">、ファイルは UTF-8(BOM なし)
  • MySQL は utf8mb4utf8 は避ける)
  • 入出力の境界でエンコーディングを明示する(HTTP ヘッダ等)
  • 文字数カウント・比較は用途に応じて単位を選ぶ(UTF-16 / コードポイント / 書記素)。人間の文字数は Intl.Segmenter
  • 「同じに見えて一致しない」は正規化(NFC)で揃える

まとめ

  • Unicode(文字集合・コードポイント)UTF-8/16(エンコーディング)は別物
  • UTF-8 は1〜4バイトの可変長で ASCII 互換・自己同期。Web の標準
  • JavaScript は UTF-16 ベースlength はコードユニット数で、絵文字や結合文字で見た目とズレる。人間の文字数は Intl.Segmenter
  • 見た目が同じで一致しないのは正規化(NFC)で解決
  • 文字化けはエンコーディング不一致が原因。UTF-8 統一・<meta charset>MySQL は utf8mb4 で防ぐ

文字コードは「なんとなく動く」で済ませると、絵文字や多言語が絡んだ瞬間に破綻します。Unicode と UTF-8 の関係数え方の単位を押さえておけば、文字化けも length の謎もぐっと減らせます。

参考リンク