JavaScript で文字列の文字数を正確にカウントする方法
String.length では絵文字やサロゲートペアを含む文字列の文字数を正しく取得できません。この記事では、コードユニット・コードポイント・書記素クラスタの違いと、Intl.Segmenter を使った正確な文字数カウント方法を解説します。
String.length の問題点
JavaScript で文字列の文字数を調べるとき、多くの方は String.length を使うでしょう。しかし、String.length は「見た目の文字数」とは異なる結果を返すことがあります。
この記事では、なぜそのような違いが生まれるのかを理解し、Intl.Segmenter を使ってどんな文字列でも正確に文字数をカウントする方法を学びます。
絵文字で試してみる
まず、いくつかの文字列で .length の結果を確認してみましょう。
// 普通の文字列 console.log("Hello".length); // 5 console.log("こんにちは".length); // 5 // 絵文字を含む文字列 console.log("🍎".length); // 2 ← 1 ではない! console.log("👨👩👧👦".length); // 11 ← 1 ではない! console.log("🇯🇵".length); // 4 ← 1 ではない!
アルファベットやひらがなでは直感どおりの結果が返りますが、絵文字では見た目は 1 文字なのに .length の値が 2 以上になっています。
なぜこのようなことが起きるのでしょうか。
なぜ正しくカウントできないのか
この問題を理解するためのポイントは次のとおりです。
- JavaScript の文字列は内部的に UTF-16 というエンコーディング方式(文字をコンピュータ上で表現するための仕組み)で保存されています
- UTF-16 では 1 つの文字を 16 ビットのコードユニット(データの最小単位)で表現します
String.lengthは「文字数」ではなく「コードユニットの数」を返します- 絵文字など一部の文字は 1 つのコードユニットに収まらず、2 つのコードユニット(サロゲートペア)で表現されます
- そのため、見た目は 1 文字でも
.lengthが 2 以上になります
MDN の公式ドキュメントでも、String.length は UTF-16 コードユニットの数を返すと定義されています。
The
lengthdata property of aStringvalue contains the length of the string in UTF-16 code units.
文字の「単位」を理解する
「文字」には実はいくつかの単位があります。ここでは、この記事で使う 3 つの単位を紹介します。
コードユニット(UTF-16)
コードユニットは、UTF-16 の最小単位(16 ビット)です。
String.lengthが返す値はこの単位の数です- 一般的なアルファベットやひらがな・カタカナ・漢字は 1 コードユニットで表現できます
- 絵文字や一部の稀な漢字は 2 コードユニット(サロゲートペア)で表現されます
コードポイント(Unicode)
コードポイントは、Unicode(世界中の文字を統一的に扱うための規格)が各文字に割り当てた固有の番号です。U+ のあとに 16 進数で表記します。
- 例:
AはU+0041、🍎はU+1F34E - コードポイントが
U+FFFFより大きい文字は、UTF-16 で 2 つのコードユニット(サロゲートペア)が必要になります - JavaScript ではスプレッド構文
[...str]やArray.from(str)を使うと、コードポイント単位で文字列を分割できます
書記素クラスタ(Grapheme Cluster)
書記素クラスタは、ユーザーが「1 文字」と認識する最小の単位です。
重要なのは、複数のコードポイントが組み合わさって 1 つの書記素クラスタを形成するケースがあることです。
- 国旗絵文字
🇯🇵= 2 コードポイント(地域指示子 J + P)で 1 書記素クラスタ - 家族絵文字
👨👩👧👦= 7 コードポイント(絵文字 4 つ + ZWJ 3 つ)で 1 書記素クラスタ - 肌色付き絵文字
👍🏽= 2 コードポイント(基本絵文字 + 肌色修飾子)で 1 書記素クラスタ
書記素クラスタ単位で正確にカウントするには、Intl.Segmenter を使います。
ZWJ(Zero Width Joiner、ゼロ幅接合子)は、複数の絵文字を 1 つに合成するための見えない文字(U+200D)です。たとえば 👨👩👧👦(家族)は、👨 + ZWJ + 👩 + ZWJ + 👧 + ZWJ + 👦 という構造になっています。
Intl.Segmenter で正確にカウントする
Intl.Segmenter は、文字列を書記素クラスタ単位で分割するための JavaScript の組み込み API です。この API を使えば、どんな文字列でも「見た目どおりの文字数」を取得できます。
基本的な使い方
// Segmenter インスタンスを作成 const segmenter = new Intl.Segmenter("ja", { granularity: "grapheme" }); // segment() でセグメントに分割し、スプレッド構文で配列に変換して長さを取得 const str = "Hello🍎"; const segments = segmenter.segment(str); console.log([...segments].length); // 6
使い方は次の 3 ステップです。
new Intl.Segmenter(locale, { granularity: "grapheme" })でインスタンスを作成しますlocale: 言語タグ("ja"など)を指定します。書記素クラスタのカウントではロケールによる結果の違いはほぼありませんが、指定を推奨しますgranularity: "grapheme": 書記素クラスタ単位で分割します。これはデフォルト値なので省略も可能です
segmenter.segment(text)でセグメントオブジェクト(イテラブル)を取得します- スプレッド構文
[...segments]やArray.from(segments)で配列に変換し、.lengthで文字数を取得します
さまざまな文字列で試してみる
Intl.Segmenter を使った文字数カウント関数を作成し、さまざまな文字列で試してみましょう。
const segmenter = new Intl.Segmenter("ja", { granularity: "grapheme" });
// 文字数をカウントする関数
function countCharacters(text) {
return [...segmenter.segment(text)].length;
}
// 通常の文字列
console.log(countCharacters("Hello")); // 5 ✅
console.log(countCharacters("こんにちは")); // 5 ✅
// 絵文字
console.log(countCharacters("🍎🍊🍋")); // 3 ✅
// 国旗絵文字
console.log(countCharacters("🇯🇵")); // 1 ✅
// 家族絵文字(ZWJ シーケンス)
console.log(countCharacters("👨👩👧👦")); // 1 ✅
// 肌色付き絵文字
console.log(countCharacters("👍🏽")); // 1 ✅
参考として、String.length と Intl.Segmenter の結果を比較した表を示します。
| 文字列 | 見た目 | String.length | Intl.Segmenter |
|---|---|---|---|
"Hello" | 5 文字 | 5 | 5 |
"こんにちは" | 5 文字 | 5 | 5 |
"🍎" | 1 文字 | 2 | 1 |
"🇯🇵" | 1 文字 | 4 | 1 |
"👨👩👧👦" | 1 文字 | 11 | 1 |
"👍🏽" | 1 文字 | 4 | 1 |
"Hello🍎世界" | 8 文字 | 9 | 8 |
Intl.Segmenter は、すべてのケースで見た目どおりの文字数を返していることが分かります。
Intl.Segmenter は 2024 年 4 月に全主要ブラウザでサポートされました(Baseline 2024)。Chrome 87+、Firefox 125+、Safari 14.1+、Node.js 16+ で利用できます。
まとめ
この記事で学んだ内容を振り返ります。
String.lengthは UTF-16 コードユニット数を返すため、絵文字などの文字数を正しくカウントできない- 文字列には「コードユニット」「コードポイント」「書記素クラスタ」の 3 つの単位がある
Intl.Segmenterを使えば、あらゆる文字列の文字数を正確にカウントできる