🔤

「1文字」は嘘: 書記素クラスタを理解する

string.length が嘘をつく理由、書記素クラスタの正体、そして Intl.Segmenter による解決。

「1文字」という幻想

この文字列は何文字でしょうか: 👨‍👩‍👧‍👦

多くの人は「1文字」と答えます。しかし JavaScript に聞くと、数え方によって3つの異なる答えが返ります:

方法結果何を数えているか
"👨‍👩‍👧‍👦".length11UTF-16 コードユニット
[..."👨‍👩‍👧‍👦"].length7コードポイント
Intl.Segmenter1書記素クラスタ(見た目の文字)

家族絵文字は7つのコードポイントで構成: 4つの人物絵文字を3つのゼロ幅接合子(ZWJ)で結合。UTF-16 では U+FFFF 超の絵文字は各2ユニット(サロゲートペア)のため、.length は 11。

3つの異なるカウント

3つのカウントの違いは Unicode を正しく扱う基本です:

単位意味例: 👍🏽
UTF-16 コードユニット.length が返す値。サロゲートペアを含む。4 ユニット
コードポイントUnicode の基本単位 (U+XXXX)。[...str] で取得。2 ポイント
書記素クラスタ人間が「1文字」と認識する単位。1 クラスタ

👍🏽 は2コードポイント: 👍 (U+1F44D) + 肌色修飾子 🏽 (U+1F3FD)。各 U+FFFF 超で UTF-16 では各2ユニット。しかし見た目は1文字 = 1書記素クラスタ。

国旗絵文字: 地域インジケータの組み合わせ

国旗絵文字は地域インジケータ記号のペアで1書記素クラスタを形成:

国旗コードポイント意味
🇯🇵U+1F1EF + U+1F1F5地域 J + P
🇺🇸U+1F1FA + U+1F1F8地域 U + S
🇬🇧U+1F1EC + U+1F1E7地域 G + B

3つの国旗ですが "🇯🇵🇺🇸🇬🇧".length は 12。Intl.Segmenter だけが正しく3書記素クラスタを識別。

正規化でカウントが変わる

café は Unicode で2通りの表現が可能:

形式コードポイント表現
NFC(合成)c a f é (4 CP)é = U+00E9
NFD(分解)c a f e ◌́ (5 CP)e + 結合アキュート = U+0065 U+0301

画面上は同一で4書記素クラスタ。しかしコードポイント数(4 vs 5)と .length は異なる。

実用上の影響

文字カウントの間違いは実際のバグを引き起こします:

  • 文字列の切り詰め: .length / 2 で切るとサロゲートペアが分断され U+FFFD に。
  • カーソル移動: 「1文字」移動は書記素クラスタ全体をスキップすべき。
  • 入力バリデーション: 「最大10文字」は書記素クラスタで数えるべき。
  • 文字列反転: [...str].reverse().join("") は ZWJ シーケンスや国旗絵文字を壊す。

解決策: Intl.Segmenter

Intl.Segmenter(2024年以降の全モダンブラウザで利用可能)は書記素クラスタ境界で正しく分割:

const segmenter = new Intl.Segmenter(undefined, {
  granularity: "grapheme"
});

const text = "👨‍👩‍👧‍👦🇯🇵café";
const segments = [...segmenter.segment(text)];
console.log(segments.length); // 6(正解!)

// 比較:
console.log(text.length);      // 18(UTF-16 ユニット)
console.log([...text].length); // 12(コードポイント)

このツールは Intl.Segmenter を内部で使用。グリッドの各セルが1書記素クラスタを表し、クリックで内部構造を確認できます。

関連記事