Web便利ツール集

JavaScriptで絵文字の文字数がずれる原因|lengthと見た目の数え方

JavaScript絵文字文字数UnicodeIntl.Segmenterサロゲートペア

入力フォームに「100文字まで」と書いたのに、絵文字を入れると画面の見た目より早く上限に達する。あるいは、フォームでは通ったのにサーバー側で「文字数オーバー」と弾かれる。こうした食い違いは、文字数を数える単位が場所ごとに違うことで起きます。この記事では、JavaScript の length が絵文字でずれる理由と、目的別の数え方を実測つきでまとめます。

結論:目的に合う単位で数える

"😀".length は 1 ではなく 2 です。見た目の文字数に合わせたいなら Intl.Segmenter を使い、サーバーやDBの上限に合わせたいなら「そちらが何を数えているか」に合わせます。

数えたいもの方法向いている場面
コード単位(UTF-16)str.length内部処理、配列の添字
コードポイント[...str].length / Array.from(str).length多くの絵文字・𠮷 を1と数えたい
書記素(見た目の1文字)Intl.Segmenter の grapheme入力フォームの「◯文字まで」表示
バイト数new TextEncoder().encode(str).lengthUTF-8 でのサイズ上限

「文字数」という言葉は少なくとも4通りの意味を持ちます。まず「どの単位の上限か」を確認することが、ずれを直す最短ルートです。

なぜ絵文字の length は2や11になるのか

JavaScript の文字列は UTF-16 のコード単位の並びです。U+FFFF を超える文字(多くの絵文字、「𠮷」など)は2つのコード単位(サロゲートペア)で表されるため、length が2になります。

さらに、見た目は1文字でも複数のコードポイントを結合して作られる絵文字があります。肌色付きの絵文字、国旗、家族の絵文字(ZWJ で人物をつないだもの)などです。

次の結果は Node.js v20.20.1 で実測したものです。

文字lengthコードポイント書記素UTF-8 バイト
あ1113
𠮷(U+20BB7)2114
😀2114
👍🏽(肌色付き)4218
🇯🇵(国旗)4218
👨‍👩‍👧‍👦(家族)117125
❤️(U+2764 + U+FE0F)2216
1️⃣(1 + U+FE0F + U+20E3)3317

家族の絵文字は、見た目は1文字なのに length は11、コードポイントでも7です。[...str].length に替えても、結合絵文字では解決しません。 「Hello 👋🏽」で試すと、length が10、スプレッドが8、書記素が7でした。

実際のコード:書記素単位で数える

見た目の文字数を数えるには Intl.Segmenter を使います。

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

function countGraphemes(text) {
  let n = 0;
  for (const _ of seg.segment(text)) n++;
  return n;
}

countGraphemes("Hello 👋🏽"); // 7
countGraphemes("👨‍👩‍👧‍👦");      // 1

Intl.Segmenter は新しめの標準APIで、web.dev の記事では主要ブラウザで利用できる状態(Baseline)になったと紹介されています。古い環境を相手にする場合は、利用前に対応状況を確認してください。本記事ではブラウザでの実行は検証しておらず、Node.js での結果のみを根拠にしています。

濁点の分離文字も1文字になる

「が」を「か」+結合濁点(U+3099)で表した形(NFD)は、length が2でも書記素では1でした。Mac のファイル名で起きる濁点分離は Macのファイル名で濁点が分離する問題 に、半角カナの ガ が2文字になる件は 半角カナを全角に変換するJavaScript にまとめています。どちらも「見た目1文字が複数に分かれる」という同じ構造です。

実務の落とし穴:Segmenter は遅い場合がある

あまり書かれていない点として、入力のたびに書記素を数える処理は、長文では重くなる可能性があります。

手元の Node.js v20.20.1 で、「あ😀👨‍👩‍👧‍👦」を1万回繰り返した文字列(length 14万)を計測したところ、次の結果でした(1回ずつの計測で、環境依存です)。

方法結果所要時間
Intl.Segmenter で全件走査書記素3万約2,244ms
[...str].length9万約1ms

数百文字のフォームなら問題になりにくいと考えられますが、長文エディタで1キー入力ごとに全文を数える作りは避けたほうが無難です。入力が止まってから数える(デバウンス)、上限を超えそうな場合だけ精密に数える、といった工夫が考えられます。この計測は1回きりで、ブラウザでの速度は未検証です。

文字数が「合わない」ときの切り分け

フォームでは通るのにサーバーで弾かれる、あるいはその逆のときは、次の順で確認します。

  1. フロントが数えている単位を調べる:length か、スプレッドか、Segmenter か
  2. サーバー側が数える単位を調べる:言語のドキュメントで「文字列長」が何を返すか確認する
  3. DBの列定義を確認する:文字数制限か、バイト制限か
  4. 絵文字で再現する:👨‍👩‍👧‍👦 を1つ入れて、各層でいくつと数えられるか見る

DBについては、MySQL の utf8(3バイトまで)では絵文字などの4バイト文字を保存できないとされており、utf8mb4 が必要になることが知られています。ただし、これは一般的な説明であり、お使いのバージョンや設定での挙動は未検証です。必ず自分の環境で確認してください。

slice で絵文字が壊れる

文字数の問題とは別に、途中で切ると文字が壊れる問題もあります。

const s = "😀";
s.slice(0, 1);                        // 片方のサロゲートだけ(孤立サロゲート)
encodeURIComponent(s.slice(0, 1));    // URIError: URI malformed

"👨‍👩‍👧‍👦".slice(0, 2);               // "👨"(家族が1人に)

実測では、孤立サロゲートを encodeURIComponent に渡すと URIError になりました。toWellFormed()(String.prototype、Node.js v20 で利用可能でした)を通すと、孤立サロゲートは U+FFFD(�)に置き換わります。

「先頭◯文字だけ表示して…を付ける」処理や、文字列の反転も同じ理由で壊れます。"a😀b".split("").reverse().join("") は b��a になりました。切る位置は、Segmenter で得た境界に合わせるのが安全です。

function truncate(text, max) {
  const parts = [...seg.segment(text)].map((s) => s.segment);
  return parts.length <= max ? text : parts.slice(0, max).join("") + "…";
}

URL の扱いで困ったときは URLエンコードで文字化けする原因 も参考になります。

自作ツールで確認するときの注意

文字数カウントツールでは、文字数を Array.from で数えています(ソースを確認しました)。つまりコードポイント単位なので、「😀」は1ですが、家族の絵文字は7と表示されます。見た目の文字数とは一致しない場合があります。

また、このツールの「Twitter/X 文字数」は簡易的な重み付けの計算で、U+4DFF 以下の文字を1、それ以外を2と数える実装です。この基準がXの現行仕様と一致するかは確認できていません。投稿できるかどうかの最終判断は、X の公式ドキュメントや実際の入力画面で行ってください。

正規表現で絵文字や漢字を判定する場合は、u フラグの有無で結果が変わります。詳しくは 正規表現でひらがな・カタカナ・漢字を判定する方法 を参照してください。レポートの文字数の数え方については レポートの文字数の数え方 にまとめています。

まとめ

  1. length は UTF-16 のコード単位数で、絵文字は2、家族の絵文字は11になる
  2. [...str].length はコードポイント単位で、結合絵文字までは1に揃わない
  3. 見た目の文字数は Intl.Segmenter の grapheme で数えられるが、長文では遅くなる可能性がある
  4. 文字数制限が合わないときは、フロント・サーバー・DBが数える単位を絵文字1つで試して切り分ける
  5. slice や反転は絵文字を壊すため、切る位置は書記素の境界に合わせる

なお、本記事の数値は Node.js v20.20.1 での実測で、ブラウザや他の言語での挙動は未検証です。データベースの仕様に関する記述は一般的な整理であり、個別の環境での結果を保証するものではありません。

この記事で使うツール

関連記事