JavaScriptの文字数が合わない?length・Array.from・Intl.Segmenterの使い分け

氏名欄に「𠮷」を入力すると2文字扱いになったり、絵文字を1つ入れただけで文字数制限に近づいたりすることがあります。JavaScriptのlengthは、画面に見える文字の数とは違う単位を数えるためです。

入力フォームの文字数判定や文字列の切り詰めを作るときは、何を1文字とするかを先に決めると実装が安定します。ここでは、length・Array.from・Intl.Segmenterの結果を具体例で比べ、氏名と絵文字を途中で壊さず扱う方法を紹介します。

同じ文字列を3通りで数える

JavaScriptの文字列はUTF-16のコードユニットで扱われます。「𠮷」は2つのコードユニットで1つのコードポイントを表すため、"𠮷".lengthは2です。一方、「髙」はBMP内の文字なのでlengthも1になります。

コードポイントはUnicodeで文字などに割り当てられた値です。見た目のまとまりに当たる書記素クラスタは、複数のコードポイントを含むことがあります。たとえば、家族の絵文字は複数の絵文字と結合用の文字を組み合わせたものです。

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

for (const text of ["𠮷", "髙", "😊", "か\u3099", "👨‍👩‍👧‍👦"]) {
  console.log({
    text,
    codeUnits: text.length,
    codePoints: Array.from(text).length,
    graphemes: Array.from(segmenter.segment(text)).length
  });
}

"か\u3099"は「か」と結合濁点を組み合わせた文字列です。1つのコードポイントである「が」とは内部の並びが異なります。

文字列 length Array.from 書記素クラスタ
𠮷 2 1 1
髙 1 1 1
😊 2 1 1
か+結合濁点 2 2 1
👨‍👩‍👧‍👦 11 7 1

この例では、Array.from(text)でサロゲートペアの分割は避けられますが、家族の絵文字を1つとは数えられません。氏名に使われる文字の仕組みは、サロゲートペア文字の基礎と対処でも説明しています。

見た目に近い文字数を数える

入力欄の横に表示するカウンターを、絵文字や結合文字のまとまりに合わせたい場合は、Intl.Segmenterのgranularity: "grapheme"が使えます。セグメンターは関数の外で作っておけば、呼び出すたびに作り直す必要がありません。

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

function countCharacters(text) {
  let count = 0;
  for (const part of segmenter.segment(text)) {
    count += 1;
  }
  return count;
}

console.log(countCharacters("𠮷野家")); // 3
console.log(countCharacters("😊👨‍👩‍👧‍👦")); // 2

これは書記素クラスタの数を返す関数です。表示される字形やフォントの幅を測るものではないため、画面の横幅に合わせて文章を収めたい場合は、文字数制限とは別にレイアウトを考える必要があります。

文字のまとまりを保って切り詰める

"𠮷野家".slice(0, 1)では、最初のサロゲートペアの片側だけが残ります。表示用の短縮名を作る際は、コードユニットの位置で切るより、書記素クラスタを順に取り出すほうが扱いやすくなります。

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

function takeCharacters(text, limit) {
  if (!Number.isInteger(limit) || limit < 0) {
    throw new RangeError("limitには0以上の整数を指定してください");
  }

  let result = "";
  let count = 0;
  for (const { segment } of segmenter.segment(text)) {
    if (count >= limit) break;
    result += segment;
    count += 1;
  }
  return result;
}

console.log(takeCharacters("𠮷野家", 1)); // 𠮷
console.log(takeCharacters("👨‍👩‍👧‍👦と家族", 1)); // 👨‍👩‍👧‍👦

氏名の保存では、入力内容を黙って切り捨てるより、上限と修正が必要な内容を案内するほうが適しています。この切り詰め関数は、一覧やプレビューの表示用として使い、保存する元データは保持するとよいでしょう。

フォームと保存先の上限を合わせる

HTMLのmaxlengthもUTF-16コードユニット単位の制限です。書記素クラスタで数えるカウンターだけを追加しても、ブラウザー側のmaxlengthの単位は変わりません。たとえば、見た目で20文字まで受け付ける設計なら、HTMLの制限と独自の判定が食い違わないか、氏名や絵文字を含む入力で確認してください。

サーバー側でも、採用する文字数の単位をそろえる必要があります。DBカラムの上限やAPIのバイト数制限は別の条件なので、「画面では20文字だから必ず保存できる」とは限りません。DBで補助面の文字を保存できる文字セットを使っているかも、あわせて確認すると原因を見つけやすくなります。

古いブラウザーやWebViewも利用するシステムでは、typeof Intl.Segmenter === "function"で利用可否を確認できます。未対応の場合にArray.fromへ切り替えると数える単位が変わるため、書記素クラスタに対応したライブラリなどで同じ仕様を保つ方法を検討してください。

仕様を確認するための資料