JavaScriptで絵文字の文字数がずれる原因|lengthと見た目の数え方
入力フォームに「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).length | UTF-8 でのサイズ上限 |
「文字数」という言葉は少なくとも4通りの意味を持ちます。まず「どの単位の上限か」を確認することが、ずれを直す最短ルートです。
なぜ絵文字の length は2や11になるのか
JavaScript の文字列は UTF-16 のコード単位の並びです。U+FFFF を超える文字(多くの絵文字、「𠮷」など)は2つのコード単位(サロゲートペア)で表されるため、length が2になります。
さらに、見た目は1文字でも複数のコードポイントを結合して作られる絵文字があります。肌色付きの絵文字、国旗、家族の絵文字(ZWJ で人物をつないだもの)などです。
次の結果は Node.js v20.20.1 で実測したものです。
| 文字 | length | コードポイント | 書記素 | UTF-8 バイト |
|---|---|---|---|---|
| あ | 1 | 1 | 1 | 3 |
| 𠮷(U+20BB7) | 2 | 1 | 1 | 4 |
| 😀 | 2 | 1 | 1 | 4 |
| 👍🏽(肌色付き) | 4 | 2 | 1 | 8 |
| 🇯🇵(国旗) | 4 | 2 | 1 | 8 |
| 👨👩👧👦(家族) | 11 | 7 | 1 | 25 |
| ❤️(U+2764 + U+FE0F) | 2 | 2 | 1 | 6 |
| 1️⃣(1 + U+FE0F + U+20E3) | 3 | 3 | 1 | 7 |
家族の絵文字は、見た目は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].length | 9万 | 約1ms |
数百文字のフォームなら問題になりにくいと考えられますが、長文エディタで1キー入力ごとに全文を数える作りは避けたほうが無難です。入力が止まってから数える(デバウンス)、上限を超えそうな場合だけ精密に数える、といった工夫が考えられます。この計測は1回きりで、ブラウザでの速度は未検証です。
文字数が「合わない」ときの切り分け
フォームでは通るのにサーバーで弾かれる、あるいはその逆のときは、次の順で確認します。
- フロントが数えている単位を調べる:
lengthか、スプレッドか、Segmenterか - サーバー側が数える単位を調べる:言語のドキュメントで「文字列長」が何を返すか確認する
- DBの列定義を確認する:文字数制限か、バイト制限か
- 絵文字で再現する:
👨👩👧👦を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 フラグの有無で結果が変わります。詳しくは 正規表現でひらがな・カタカナ・漢字を判定する方法 を参照してください。レポートの文字数の数え方については レポートの文字数の数え方 にまとめています。
まとめ
lengthは UTF-16 のコード単位数で、絵文字は2、家族の絵文字は11になる[...str].lengthはコードポイント単位で、結合絵文字までは1に揃わない- 見た目の文字数は
Intl.Segmenterのgraphemeで数えられるが、長文では遅くなる可能性がある - 文字数制限が合わないときは、フロント・サーバー・DBが数える単位を絵文字1つで試して切り分ける
sliceや反転は絵文字を壊すため、切る位置は書記素の境界に合わせる
なお、本記事の数値は Node.js v20.20.1 での実測で、ブラウザや他の言語での挙動は未検証です。データベースの仕様に関する記述は一般的な整理であり、個別の環境での結果を保証するものではありません。