Unicode と絵文字:絵文字 1 つが複数のコードポイントになる仕組み

約5分

絵文字を含むテキストを扱うと、"👨‍👩‍👧".length が 8 になったり、絵文字 1 文字がメモリ上で複数バイトを占めたりと、想定外の挙動に出会います。Unicode と絵文字の構造を整理します。

Unicode の基本:コードポイント

Unicode は世界の文字を一意の番号(コードポイント)で識別する規格。最大 0x10FFFF まで(約 110 万)。

例:

  • A = U+0041
  • = U+3042
  • 🍎(リンゴ絵文字) = U+1F34E

UTF-16 とサロゲートペア

JavaScript の文字列は内部的に UTF-16 で保存されます。UTF-16 は 1 文字を 16 bit(2 バイト)で表すのが基本ですが、U+FFFF を超える文字(絵文字の多くがここ)は 2 つの 16 bit ユニットで表現します。これがサロゲートペアです。

'🍎'.length; // 2(2つの16bitユニット)

length プロパティは「文字数」ではなく「16bit ユニットの数」を返すので、絵文字を含むと意図と合わなくなります。

1 絵文字を正しく数える方法

[...'🍎'].length  // 1(イテレータがコードポイント単位)
[...'👨‍👩‍👧'].length  // 5(後述のZWJシーケンス)

[...str]Array.from(str) で UTF-16 ユニットではなくコードポイント単位で分割できます。ただしこれでも ZWJ シーケンスは 1 文字になりません。

ZWJ シーケンス:複数の絵文字を結合

👨‍👩‍👧(家族絵文字)は実は 5 つのコードポイントから構成されます:

👨 (U+1F468) + ZWJ (U+200D) + 👩 (U+1F469) + ZWJ + 👧 (U+1F467)

ZWJ(Zero-Width Joiner、U+200D) は「これらをくっつけて 1 つの絵として表示せよ」というヒント。レンダラーが対応していれば家族絵文字として、未対応なら 3 つの個別絵文字として表示されます。

ZWJ シーケンスを 1 文字として認識するには Intl.Segmenter が必要:

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
[...segmenter.segment('👨‍👩‍👧')].length; // 1

肌色修飾子:Fitzpatrick スケール

人物絵文字の肌色は 修飾子で指定できます:

👍 (基本) + 🏽 (中間色、U+1F3FD) = 👍🏽

肌色修飾子は U+1F3FB〜U+1F3FF の 5 段階(Fitzpatrick スケール基準)。基本絵文字の直後に付けると、レンダラーが結合して表示。

国旗:地域インジケータ符号

国旗絵文字は 地域インジケータシンボル(Regional Indicator Symbols)2 文字の組み合わせ:

🇯🇵 = 🇯 (U+1F1EF, Regional Indicator J) + 🇵 (U+1F1F5, Regional Indicator P)

「J」と「P」を結合すると「日本」の旗。レンダラーが ISO 3166-1 の 2 文字国コードに対応する旗があれば表示する仕組み。

バリエーションセレクタ:絵文字 vs テキスト表示

一部の文字( 電話、 傘 など)は文脈によって絵文字風かテキスト風かが変わります:

☎     ← レンダラー次第
☎️ (☎ + U+FE0F) ← 強制的に絵文字風
☎︎ (☎ + U+FE0E) ← 強制的にテキスト風

U+FE0F と U+FE0E がバリエーションセレクタ

バイト数:UTF-8 で 4 バイト

UTF-8 で絵文字を保存すると:

ASCII 1 文字: 1 バイト
ラテン拡張: 2 バイト
日本語: 3 バイト
絵文字(多く): 4 バイト

データベースのカラム長を文字数ではなくバイト数で計算しているシステムだと、絵文字を含むデータで意図せずトランケートされることがあります。

MySQL の utf8 は実は 3 バイトまでしか扱えず、4 バイトの絵文字を保存しようとするとエラーになります。utf8mb4 を指定する必要があります(最近の MySQL はデフォルトで utf8mb4)。

実装で注意する場面

1. 文字数カウント

ユーザー名の最大文字数チェックなどで str.length を使うと、絵文字 1 つで複数文字とカウントされる:

// ❌ 絵文字 1 つが 2 以上にカウント
if (str.length > 50) reject();

// ✅ Intl.Segmenter で書記素単位
const seg = new Intl.Segmenter('en', { granularity: 'grapheme' });
const count = [...seg.segment(str)].length;
if (count > 50) reject();

2. 文字列の切り詰め

str.slice(0, 20) は UTF-16 ユニット単位なので、絵文字の途中で切れて壊れることがある:

'🍎🍎🍎'.slice(0, 5); // '🍎🍎�'  ← 壊れた絵文字

Intl.Segmenter[...str].slice(0, 5).join('') を使う。

3. 正規表現

絵文字のマッチには Unicode フラグ u が必要:

/🍎/u.test('🍎')  // true
/🍎/.test('🍎')   // 正しく動かないことがある

4. データベース保存

Postgres / SQLite / 最近の MySQL は問題なし。レガシー MySQL の utf8(3 バイト)は要注意。

まとめ

  • 絵文字 1 つが UTF-16 で 2 ユニット占めるためサロゲートペアになる
  • ZWJ シーケンスは複数のコードポイントを 1 つの絵に結合
  • 肌色・国旗・バリエーションセレクタなど修飾子が多い
  • 文字数カウントは Intl.Segmenter で書記素単位

絵文字とそのコードポイント情報を確認したいときは、本サイトの絵文字ピッカーでカテゴリから選んでコピーできます。