JavaScript: 文字数を正確にカウントするには？

この投稿ではJavaScriptで文字数をできるだけ正確にカウントする方法について取り上げます。

## 文字数とは？

要件で「文字数を表示してほしい」「○文字以上はバリデーションエラーにしたい」と文字数を考慮しないとならないことがあります。

そもそも文字数とは何でしょうか。

たとえば、アルファベットの「A」は1文字と数えられそうです。

次の絵文字は、何文字になるでしょうか？

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/889/c60692de-ddbc-0b2c-3779-5aba01545797.png" width=40>

この絵文字はiOSであれば14.5の環境では、UI上では上のように1文字のように表示されます。しかし、それ以前のバージョンでは、同じ文字列データでも😵💫のように2文字で表示されます。なお、この絵文字は3つのコードポイントU+1F635 U+200D U+1F4ABからなります。この絵文字の「文字数」はいったい何文字として扱ったらよいのでしょうか。

以上のように、ひとことで文字数と言ってもデータと見た目と環境の3つのややこしい事情がつきまといます。

本稿のタイトルは「文字数を正確にカウントするには？」としましたが、以上の事情により「正確に」の部分は要件定義によるところが出てくる部分です。なので、ここで紹介する方法はすべてのアプリケーションに適用できるものではない点はご留意ください。

## JavaScriptの文字列と`length`

JavaScriptの文字列の内部エンコードはUTF-16です。UTF-16にはサロゲートペアというものがあり、1文字(1コードポイント)を複数の符号(コードユニット)で表現する仕組みがあります。たとえば、「あ」は1コードユニットで1コードポイントですが、一方「🍎」はサロゲートペアの文字で、2つのコードユニットで1コードポイントを表現します。

JavaScriptの`String.prototype.length`はUTF-16のコード**ユニット**を数えるので、「あ」と「🍎」では結果が異なります。

```js
console.log("あ".length);
//=> 1
console.log("🍎".length);
//=> 2
```

サロゲートペアで表現される漢字もあり、𩸽(ほっけ)がその例です。これも`length`では2とカウントされます。

```js
console.log("𩸽".length);
//=> 2
```

## 文字列とスプレッド演算子

JavaScriptでは、文字列に対してスプレッド演算子`...`を使うと、文字列を文字ごとに区切った配列が得られます。

```js
console.log([..."abc"]);
//=> [ 'a', 'b', 'c' ]
```

この区切り方は、UTF-16のコードユニットごとではなく、ユニコードのコードポイント単位で区切ります。したがって、一旦文字列を配列化し、その要素数を数えれば、サロゲートペアかどうかを考慮する必要はなくなります。

```js
console.log([..."あ"].length);
//=> 1
console.log([..."🍎"].length);
//=> 1
console.log([..."𩸽"].length);
//=> 1
```

コードポイント単位で数えれば万全かというと、そうでない場合もあります。たとえば、イングランドの旗の絵文字を1文字と数えたい場合です。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/889/b8457bc4-5857-7341-1f2d-a451410cb7cb.png" width=40>

この絵文字は、一見1コードポイントに見えますが、実際は以下の7つのコードポイントが合わさって1文字として表示されています。

1. U+1F3F4󠁧 Black Flag 🏴
1. U+E0067 Tag Latin Small Letter G
1. U+E0062 Tag Latin Small Letter B
1. U+E0065 Tag Latin Small Letter E
1. U+E006E Tag Latin Small Letter N
1. U+E0067 Tag Latin Small Letter G
1. U+E007F Cancel Tag

そのため、スプレッド演算子でこの文字列を配列にすると、7つの要素に分解されるため、7文字とカウントされます。

```js
console.log([..."🏴󠁧󠁢󠁥󠁮󠁧󠁿"].length);
//=> 7
```

イングランドの旗は極端な例ですが、身近な日本国旗の絵文字であっても、実は2コードポイントなので、2文字とカウントされます。

```js
console.log([..."🇯🇵"]);
//=> [ '🇯', '🇵' ]
console.log([..."🇯🇵"].length);
//=> 2
```

旗以外にも絵文字には、肌のトーンや持ち物のコードポイントを組み合わせて、1つの絵文字を成すものもあります。

```js
console.log([..."👨🏻‍💻"]);
//=> [ '👨', '🏻', '‍', '💻' ]
console.log([..."👨🏻‍💻"].length);
//=> 4
```

https://qiita.com/suin/items/c4a3c551a76042c75c3d

## Intl.Segmenter

JavaScriptで文字数を数える方法として、Intl.Segmenterを使う方法があります。

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter

Intl.Segmenterはロケールを考慮して、文字列を書記素や単語、文に分解できるAPIです。Intl.SegmenterはNode.jsや主要な最新ブラウザにて実装されています。(古いブラウザでは実行できない可能性はあります)

文字列を書記素単位に分解するには、Intl.Segmenterクラスの`granularity`オプションに`grapheme`を指定します。たとえば、「あいうえお」を分解すると次のような配列が得られます。

```js
const segmenter = new Intl.Segmenter("ja", { granularity: "grapheme" });
console.log([...segmenter.segment("あいうえお")]);
//=>
// [
//   { segment: 'あ', index: 0, input: 'あいうえお' },
//   { segment: 'い', index: 1, input: 'あいうえお' },
//   { segment: 'う', index: 2, input: 'あいうえお' },
//   { segment: 'え', index: 3, input: 'あいうえお' },
//   { segment: 'お', index: 4, input: 'あいうえお' }
// ]
```

`String.prototype.length`や`[...string].length`と比べると記述量が多いですが、次のようなユーティリティ関数を定義しておくと良いです。

```js
function countGrapheme(string) {
  const segmenter = new Intl.Segmenter("ja", { granularity: "grapheme" });
  return [...segmenter.segment(string)].length;
}
```

Intl.Segmenterはサロゲートペアや絵文字の合成も考慮しているので、より正確な文字数がカウントできます。

```js
console.log(countGrapheme("あ"));
//=> 1
console.log(countGrapheme("🍎"));
//=> 1
console.log(countGrapheme("𩸽"));
//=> 1
console.log(countGrapheme("🏴󠁧󠁢󠁥󠁮󠁧󠁿"));
//=> 1
console.log(countGrapheme("🇯🇵"));
//=> 1
console.log(countGrapheme("👨🏻‍💻"));
//=> 1
```

## 比較

文字列 | `str.length` | `[...str].length` | `Intl.Segmenter`
---|:---:|:---:|:---:
`"あ"` | 1 | 1 | 1
`"🍎"` | **2** | 1 | 1
`"𩸽"` | **2** | 1 | 1
`"🏴󠁧󠁢󠁥󠁮󠁧󠁿"` | **14** | **7** | 1
`"🇯🇵"` | **4** | **2** | 1
`"👨🏻‍💻"` | **7** | **4** | 1

## おわり

最後までお読みくださりありがとうございました。この投稿が少しでも役に立ちそうだったシェアしていただけると嬉しいです！

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr"><a href="https://twitter.com/hashtag/JavaScript?src=hash&amp;ref_src=twsrc%5Etfw">#JavaScript</a> <br><br>Qiita書きました😌<br><br>文字数のカウントって、str.lengthでOKかと思いきや、実はややこしい問題だったりします。<a href="https://t.co/gwOm8JkQOt">https://t.co/gwOm8JkQOt</a></p>&mdash; suin・読者1万人『サバイバルTypeScript』公開中！ (@suin) <a href="https://twitter.com/suin/status/1513320950674386948?ref_src=twsrc%5Etfw">April 11, 2022</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>