「1文字」は嘘: 書記素クラスタを理解する
string.length が嘘をつく理由、書記素クラスタの正体、そして Intl.Segmenter による解決。
「1文字」という幻想
この文字列は何文字でしょうか: 👨👩👧👦
多くの人は「1文字」と答えます。しかし JavaScript に聞くと、数え方によって3つの異なる答えが返ります:
| 方法 | 結果 | 何を数えているか |
|---|---|---|
| "👨👩👧👦".length | 11 | UTF-16 コードユニット |
| [..."👨👩👧👦"].length | 7 | コードポイント |
| Intl.Segmenter | 1 | 書記素クラスタ(見た目の文字) |
家族絵文字は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書記素クラスタを表し、クリックで内部構造を確認できます。