てもとツール

2026-05-07

Intl.Segmenterで「人間が見て1文字」を扱う完全ガイド

絵文字やZWJ結合文字をJavaScriptで正確に1文字として数えるにはIntl.Segmenterが必要です。String.lengthとの違い・ブラウザ対応・実装パターンを解説します。

この記事の要点

  • String.lengthは内部のコード単位を数えるため絵文字で「見た目と違う数値」になる
  • 書記素クラスタとは人間が1文字と認識する最小単位でIntl.Segmenterで取得できる
  • Intl.SegmenterはChrome 87・Firefox 125・Safari 16.4・Node.js 16以上で利用可能
  • 文字数制限フォームやテキスト切り捨てではIntl.Segmenterを使うのが正解
  • 文字数カウンターで書記素クラスタ・String.length・UTF-8バイト数を同時に確認できる

ブログのフォームで「あと○文字」を表示するとき、家族絵文字 👨‍👩‍👧‍👦 が「11文字分」消費されることがあります。ユーザーが困惑するのは、String.length が内部の符号単位を数えるためです。Intl.Segmenter を使うと、絵文字も結合文字も「見た目通りの1文字」として正確にカウントできます。

String.length が「人間の感覚」と異なる理由

JavaScript の文字列は UTF-16 で内部管理されます。UTF-16 では、基本多言語面(BMP: U+0000〜U+FFFF)の文字は1コード単位で表現されます。それ以外の文字(BMP外)は2コード単位のサロゲートペアを使います。

'a'.length        // 1  ← BMP文字
'あ'.length       // 1  ← BMP文字
'𝕳'.length        // 2  ← BMP外(U+1D573)サロゲートペア
'😀'.length       // 2  ← 絵文字もBMP外(U+1F600)
'👨‍👩‍👧‍👦'.length    // 11 ← ZWJ結合 + サロゲートペア×4

'👨‍👩‍👧‍👦' が11になる理由は、この絵文字が5文字を**ZWJ(ゼロ幅接合子)**でつないだシーケンスだからです。

👨 + ZWJ + 👩 + ZWJ + 👧 + ZWJ + 👦
= 2 + 1 + 2 + 1 + 2 + 1 + 2 = 11 コード単位

String.length はこの分解された単位を数えるため、見た目1文字が11と返ります。


書記素クラスタとは

**書記素クラスタ(grapheme cluster)**は、Unicode が定義する「人間が1文字と認識する最小単位」です。

  • 基本文字 + 結合文字(例: é = e + 結合アクセント)
  • ZWJ で結合した絵文字シーケンス(例: 👨‍👩‍👧‍👦
  • ハングル音節(例: 여 = ㅇ + ㅕ + 子音なし)
  • 地域指示子ペア(例: 🇯🇵 = 🇯 + 🇵)

これらはどれも「見た目1文字」ですが、String.length では複数のコード単位として数えられます。


Intl.Segmenter の基本

Intl.Segmenter は ECMAScript Internationalization API の一部です。テキストを書記素・単語・文に分割するイテレータを返します。

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

function countGraphemes(str) {
  return [...segmenter.segment(str)].length;
}

countGraphemes('hello');      // 5
countGraphemes('こんにちは');  // 5
countGraphemes('😀');         // 1  ← String.length は 2
countGraphemes('👨‍👩‍👧‍👦');      // 1  ← String.length は 11
countGraphemes('é');          // 1  ← e + 結合アクセント = 2コード単位

segment() が返すイテレータをスプレッドして length を取ると、書記素クラスタ数が得られます。Intl.Segmenter インスタンスは再利用可能なので、カウント関数の外に1度だけ生成するのが効率的です。

ロケール引数の影響

granularity: 'grapheme' の場合、ロケール引数('ja')は書記素分割にほぼ影響しません。ロケールが重要になるのは granularity: 'word'(単語分割)のときです。日本語は空白を持たないため、ロケールを指定しないとうまく分割できません。

// 単語分割でのロケール差
const wordSegJa = new Intl.Segmenter('ja', { granularity: 'word' });
const wordSegEn = new Intl.Segmenter('en', { granularity: 'word' });

const text = 'てもとツールは便利です。';
[...wordSegJa.segment(text)].filter(s => s.isWordLike).map(s => s.segment);
// ['てもとツール', 'は', '便利', 'です']  ← 日本語ロケールで自然な分割

String.normalize() との違い

String.prototype.normalize('NFC') は、同じ見た目の文字を同じコードポイントに正規化します。ただし、書記素クラスタ数を変えるわけではありません。

const e_accent = 'é';  // e + 結合アクセント(2コード単位)
const é = 'é';          // 合成済みé(1コード単位)

e_accent === é           // false
e_accent.normalize() === é  // true  ← normalize で一致するが
e_accent.length          // 2
e_accent.normalize().length  // 1  ← NFC後は1コード単位
countGraphemes(e_accent)  // 1  ← どちらも書記素クラスタは1

フォームの文字数バリデーションでは、normalize 前後で String.length が変わるケースがあります。書記素クラスタ数で制限した方が、人間の直感と一致します。


ブラウザ・Node.js 対応状況

環境対応バージョン
Chrome / Edge87+ (2020年11月〜)
Firefox125+ (2024年4月〜)
Safari16.4+ (2023年3月〜)
Node.js16.0+ (ICU full data 必要)
Deno1.8+

現在(2026年)の主要ブラウザシェアではほぼ全カバーです。古い環境をサポートする場合は @formatjs/intl-segmenter ポリフィルを使えます。ただし、バンドルサイズが増加する点に注意してください。


実装パターン集

パターン1: フォームの文字数制限

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

function CharLimitInput({ maxChars = 140 }: { maxChars?: number }) {
  const [value, setValue] = useState('');
  const count = [...segmenter.segment(value)].length;
  const remaining = maxChars - count;

  return (
    <div>
      <textarea
        value={value}
        onChange={e => setValue(e.target.value)}
        maxLength={maxChars * 2}  // サロゲートペアの最大倍率でざっくり制限
      />
      <span style={{ color: remaining < 0 ? 'red' : 'gray' }}>
        {remaining}
      </span>
    </div>
  );
}

maxLength 属性はコード単位ベースの制限です。書記素クラスタ数で制御したい場合は、UI 表示を Intl.Segmenter ベースにして送信時にバリデーションするのが確実です。

パターン2: テキストの切り捨て

function truncateByGrapheme(str, maxLength, ellipsis = '…') {
  const segments = [...segmenter.segment(str)];
  if (segments.length <= maxLength) return str;
  return segments
    .slice(0, maxLength - 1)
    .map(s => s.segment)
    .join('') + ellipsis;
}

truncateByGrapheme('こんにちは👨‍👩‍👧‍👦', 4);
// 'こんに…'  ← 絵文字1文字分を正確に確保

String.slice() はコード単位ベースのため、サロゲートペアの途中で切断して文字化けすることがあります。Intl.Segmenter を使うと安全に切り捨てられます。

パターン3: useDeferredValue との組み合わせ(React)

大量テキストに Intl.Segmenter を使うと処理が重くなる場合があります(10万字程度でも数十ms)。React の useDeferredValue を使うと入力中の UI が重くなりません。

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

function HeavyCounter() {
  const [input, setInput] = useState('');
  const deferred = useDeferredValue(input);

  const count = useMemo(
    () => [...segmenter.segment(deferred)].length,
    [deferred]
  );

  return (
    <>
      <textarea onChange={e => setInput(e.target.value)} />
      <p>書記素クラスタ: {count}</p>
    </>
  );
}

文字数カウンターでの活用

文字数カウンターの「詳細集計」を開くと、書記素クラスタ数・String.length・UTF-8バイト数を並べて確認できます。

文字数カウンターの詳細集計表示。絵文字を含む入力に対して、書記素クラスタ数・総文字数・バイト数が異なる値で表示されている

👨‍👩‍👧‍👦 を貼り付けると:

  • 書記素クラスタ: 1(見た目通り)
  • 総文字数(String.length): 11(内部コード単位)
  • UTF-8バイト数: 25(実際のデータサイズ)

3つの指標を比べると、API のレート制限・DBのカラム長・SNS の文字数制限がどの指標に基づくかを把握できます。


まとめ

Intl.Segmenter は「人間が見て1文字」という直感的な単位でテキストを扱うための標準 API です。ポイントをまとめます。

  • なぜ必要か: String.length は絵文字・結合文字で人間の感覚と一致しない
  • 何ができるか: 書記素クラスタ単位のカウント・切り捨て・分割
  • いつ使うか: 文字数制限 UI・テキスト切り捨て・SNS 投稿補助ツール
  • 注意点: granularity: 'word' は日本語ロケール指定が重要、大量テキストでは useDeferredValue と組み合わせる

フォームや入力系コンポーネントで文字数カウントを実装するときは、最初から Intl.Segmenter を選ぶことをお勧めします。