JavaScriptで文字数を数えるのはそんなに簡単ではない

## はじめに
JavaScriptにて文字数をカウントする方法に関する記事をいくつか目にする機会があり、今回実際に記事を参考に調べてみました。
簡単そうに見えて意外と難しいです。

## String.length
Googleなどで`「JavaScript 文字数 カウント」`とかで検索すると真っ先に出る方法です。
MDN公式では`String.length`に関して以下のように説明されています。

> length プロパティは String オブジェクトの文字列長を UTF-16 コードユニットの数で表します。 
length は、 string インスタンスの読み取り専用データプロパティです。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/String/length


### UTF-16 コードユニット
ざっくりと説明するなら**Unicodeで割り当てられた番号をUTF-16 という文字コード方式で割り当てられた各文字に対応するID**を指します。
難しい単語がいくつか出てきているので1つずつかいつまんで説明します。

#### Unicode
世界にはたくさんの文字があり、全世界で文字を使うにあたり、共通ルールが必要になります。
そのルールを作るにあたり、**全世界で共通とする、文字とそれに対応する番号の組み合わせ表**が作成されました。
この組み合わせ表が**Unicode**と呼ばれます。
なので全世界で「Unicodeで`U+3042`！」というと全世界の人が「`あ`」であるということがわかります。

| 文字 | あ | い | う | え | お |
| --- | --- | --- | --- | --- | --- |
| Unicode | U+3042 | U+3044 | U+3046 | U+3048 | U+304A |

#### 文字コード
コンピュータは私たち人間が使用する言葉（`あいうえお`など）が理解できません。
それをコンピュータでも理解できるようにしようと、Unicodeを元にコンピュータが理解できるような形に変換するための翻訳ルールがいくつか作られました。
このルールが**文字コード**と呼ばれます。
今回上で記載している**UTF-16**はその翻訳ルールの1つだと思っていただければOKです。（他に馴染みのあるものだと、`UTF-8`とか`Shift-JIS`とかがあります。）
ちなみにUTF-16は **Unicode番号を16進数で表現する** という割り振り方式になっており、前2つの`0x`で16進数であるということを示しています。

| 文字 | あ | い | う | え | お |
| --- | --- | --- | --- | --- | --- |
| Unicode | U+3042 | U+3044 | U+3046 | U+3048 | U+304A |
| UTF-16 | 0x3042 | 0x3044 | 0x3046 | 0x3048 | 0x304A |

#### コードポイント
上の`文字コード（UTF-16）`によって割り当てられた **識別番号（0x3042とか）** がコードポイントと呼ばれます。
基本的に`0x`以下が**4桁**になります。

#### コードユニット
上の**識別番号（コードポイント）を再現して文字を表出させるための組み合わせ**のことをコードユニットと呼ばれます。
今はピンとこないと思いますが、一旦なんか組み合わせて文字が出るんだなと思っていただければOKです。
基本的にはコードポイントと同じになります。

| 文字 | あ | い | う | え | お |
| --- | --- | --- | --- | --- | --- |
| コードポイント | 0x3042 | 0x3044 | 0x3046 | 0x3048 | 0x304A |
| コードユニット | 0x3042 | 0x3044 | 0x3046 | 0x3048 | 0x304A |

### どのようにカウントされるのか 
例えば「あいうえお」のコードユニットは以下のようになります。

```js
const str = "あいうえお";

console.log(str.length);
// -> コードユニットの数を数える: ["0x3042", "0x3044", "0x3046", "0x3048", "0x304A"]
// -> 5
```
`String.length`はこのコードユニットの数を返却していると言うことになります。
なので上の例だと、`あいうえお`という文字に対応するコードユニットは5つということで、出力結果は5ということなります。
普段よく使うような文字に関してはコードユニットは1つとなっているため、基本的に使われる文字を数える場合はこちらで問題ありません。

## String.lengthの問題点
上の例を見るとこちらを使用すれば文字数のカウントに関しては問題ないように見えます。
- コードポイントとコードユニットが同じ
- 文字1文字に対してコードユニットが1つ（`1:1`）の関係

ですが、`String.length`では文字数のカウントができない文字が存在します。

### 背景
`UTF-16`が作成された当初は16進数での変換ルールで問題ありませんでした。
しかし時が経つにつれて様々な文字が現れるようになった結果、全世界共通ルールの`Unicode`がどんどん膨大になりました。
追加される文字をコンピュータに理解できるよう `UTF-16` を適用させてきたのですが、当初用意していた容量(16進数が4桁 = 16の4乗 = `65536`)に限界が来てしまいました。

### サロゲートペア
新しい文字を登録するための容量が足りなくなったためどうするか悩んだ結果、**一部文字を2つのコードユニットを組み合わせることで再現できるようにし、容量不足を補おう**という考えが生まれました。
この考えが**サロゲートペア**と呼ばれるものになり、`UTF-16`が作られた当初は全く考えられていなかった拡張機能になります。

### サロゲートペア文字はどうなるのか
例として「𠮷野家」という字で見てみます。
`𠮷`という字がサロゲートペア文字となります。

| 文字 | 𠮷 | 野 | 家 |
| --- | --- | --- | --- |
| Unicode | U+20BB7 | U+91CE | U+5BB6 |
| コードポイント | 0x20BB7 | 0x91CE | 0x5BB6 |
| コードユニット | 0xD842, 0xDFB7 | 0x91CE | 0x5BB6 |

`𠮷`という漢字で`Unicode, コードポイント`がそれぞれ`U+, 0x`の下が4桁から5桁になりました。
ただし文字を再現するための組み合わせであるコードユニットは4桁のままであり、2つ定義されています。
本来はコードポイントの5桁を表出したいのですが、残念ながら`UTF-16`では4桁までしか表示できないため、4桁のコードユニットを2つ組み合わせることで5桁のコードポイントを再現しています。
これがサロゲートペアの特徴で、足りない分を補うという考えからコードポイントとコードユニットが本来`1:1`想定のものが`1:2`になるという特徴があります。

### 結論
だいぶ話が長くなってしまいましたが、サロゲートペアの文字を数えようとすると想定外の挙動となります。
例えば「𠮷野家」のコードユニットは以下のようになります。

```js
const str = "𠮷野家";

console.log(str.length);
// -> コードユニットの数を数える: ["0xD842", "0xDFB7", "0x91CE", "0x5BB6"]
// -> 4
```
上記のようにコードユニットの数を返却している`String.length`ではサロゲートペアの文字を2文字とカウントしてしまい、
本来の想定している挙動とは異なる可能性があります。
そのため安直に`String.length`で万事解決！と思っていると思わぬエラーに出くわす可能性があります。

### 余談
余談になりますが、絵文字なども`String.length`で数えようとすると想定外の挙動になることがあります。
いくつかの絵文字で試してみます。

| 絵文字 | 😄 | 💢 | ✋ |
| --- | --- | --- | --- |
| Unicode | U+1F604 | U+1F4A2 | U+270B |
| コードポイント | 0x1F604 | 0x1F4A2 | 0x270B |
| コードユニット | 0xD83D, 0xDE04 | 0xD83D, 0xDCA2 | 0x270B |

```js
const str = "😄💢✋";

console.log(str.length);
// -> コードユニットの数を数える: ["0xD83D", "0xDE04", "0xD83D", "0xDCA2", "0x270B"]
// -> 5
```

## Array.length
`String.length`では**サロゲート文字**をカウントしようとすると正確にカウントすることができないということがわかりました。
ではそれらの文字も正確にカウントしたい場合はどうすれば良いのかということになるのですが、その解決方法に関してはMDNに書かれています。

> `length` は文字数ではなくコードユニットの数を数えるため、文字数を知りたい場合はこのようなことをする必要があります。
> 
> ``` js
> // 該当コードのみ引用
> function getCharacterLength (str) {
>   // The string iterator that is used here iterates over characters,
>   //  not mere code units
>   return [...str].length;
> }
> ```
> 

何をしているのかというと、文字列を1文字ずつ配列に格納するようにして配列の長さで文字数を数えるようにしようとしています。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/String/length#unicode


### スプレッド構文
そもそも`[...str]`としているけど、これが何かわかりません。という方に対しての簡単な説明です。
知っている方はスルーでOKです。
MDNでは下記のように説明されています。
> スプレッド構文 (...) を使うと、配列式や文字列などの反復可能オブジェクトを、0 個以上の引数 (関数呼び出しの場合) や要素 (配列リテラルの場合) を期待された場所で展開したり、オブジェクト式を、0 個以上のキーと値の組 (オブジェクトリテラルの場合) を期待された場所で展開したりすることができます。

言葉だけだと難しいので実際にコードを見ながら確認してみます。

```js
const str = "あいうえお";

console.log(...str);
// -> "あ" "い" "う" "え" "お"

console.log([...str]);
// -> ["あ", "い", "う", "え", "お"]
```

文字列に対してスプレッド構文を使用すると**1文字ずつ区切る**ことができます。
これを空配列（`[]`）の中で行うと1文字ずつ区切られた状態の配列を作成することができます。
なので`String.split('')`で1文字ずつ区切ることと全く同じことをしています。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Spread_syntax

### どのようにカウントされるのか
文字列をスプレッド構文を使用して配列に変化させると、カウント対象が**コードユニット**から**コードポイント**に変わります。
そのため`String.length`でうまくカウントできない文字も正しくカウントできるようになることができます。

```js
const str1 = "𠮷野家";
const str2 = "😄💢✋";

console.log([...str1].length);
// -> コードポイントの数を数える: ["0x20BB7", "0x91CE", "0x5BB6"]
// -> 3

console.log([...str2].length);
// -> コードポイントの数を数える: ["0x1F604", "0x1F4A2", "0x270B"]
// -> 3
```

## Array.lengthの問題点
`Array.length`を用いることで、`String.length`で数えることができなかった文字や絵文字なども正しく数えることができるようになりました。
MDNでも紹介されているので、これで問題なさそうに見えます。
が、残念ながらこれでも完璧に対応するということはできない文字が存在します。

### 対応できない文字
例として「👨‍👩‍👧‍👦」という絵文字で見てみます。

| 絵文字 | 👨‍👩‍👧‍👦 |
| --- | --- |
| Unicode | U+1F468, U+200D, U+1F469, U+200D, U+1F467, U+200D, U+1F466 |
| コードポイント | 0x1F468, 0x200D, 0x1F469, 0x200D, 0x1F467, 0x200D, 0x1F466 |
| コードユニット | 0xD83D, 0xDC68, 0x200D, 0xD83D, 0xDC69, 0x200D, 0xD83D, 0xDC67, 0x200D, 0xD83D, 0xDC66 |

すごいことになっていますね。
これまで見てきた文字は`コードユニット`が複数指定されている文字でしたが、この絵文字では`Unicode, コードポイント`も複数指定されています。
このような複数のコードポイントから生成されている絵文字を**絵文字シーケンス**と呼びます。 
この絵文字を数えようとすると、コードユニットの数を数える`String.length`, コードポイントの数を数える`Array.length`ではどうなるでしょうか。

```js
const str = "👨‍👩‍👧‍👦";

console.log(str.length);
// -> コードユニットの数を数える: ["0xD83D", "0xDC68", "0x200D", "0xD83D", "0xDC69", "0x200D", "0xD83D", "0xDC67", "0x200D", "0xD83D", "0xDC66"]
// -> 11

console.log([...str].length);
// -> コードポイントの数を数える: ["0x1F468", "0x200D", "0x1F469", "0x200D", "0x1F467", "0x200D", "0x1F466"]
// -> 7
```

このように`String.length`でも`Array.length`でも正しく数えることはできません。
ここまではもう考慮しないと割り切るのであれば、`Array.length`で対応をするとしても良いかもしれません。
ですが、このような文字も1文字ときちんとカウントしたいという場合は、また別の方法を考える必要があります。

## Intl.Segmenter
1つ目の方法としては**Intl.segmenter**を用いる方法です。
MDNでは下記のように説明されています。
> このIntl.Segmenterオブジェクトにより、ロケールに依存したテキストのセグメンテーションが可能になり、文字列から意味のある項目 (書記素、単語、または文) を取得できるようになります。

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

何をしているのかというと、`コンピュータにとっては意味合いのないただの文字列でしかないものを人間がわかるような意味合いのある文章に分割する`ことをしようとしています。
この中でオプションとして設定できる値があり、そちらを利用することで正確に文字を数えようとしています。

### どのようにカウントされるのか
```js
// Segmenterオブジェクトを作成する
const segmenter = new Intl.Segmenter("ja", {granularity: "grapheme"});

// 文字列をセグメントに分割する
const segments = segmenter.segment('あいうえお');

// 分割された文字情報を1つずつ出力
console.log([...segments]);
// -> [
//   {segment: 'あ', index: 0, input: 'あいうえお'}
//   {segment: 'い', index: 1, input: 'あいうえお'}
//   {segment: 'う', index: 2, input: 'あいうえお'}
//   {segment: 'え', index: 3, input: 'あいうえお'}
//   {segment: 'お', index: 4, input: 'あいうえお'}
// ]

// 分割された文字情報の総数を数える
console.log([...segments].length);
// -> 5
```

最初に`segmenter`オブジェクトを作成し、対象言語と分割単位を設定します。
ここで分割単位として`{granularity: "grapheme"}`を指定します。
こちらのオプションは規定値として`grapheme(文字)`が設定されているので省略しても問題ありません。
また他の設定値として`'word'(単語), 'sentence'(文)`も設定することが可能です。

作成した`segmenter`オブジェクトに`.segment(対象文字列)`を実行することで文字情報分割を行うことができます。
分割された文字情報の中身としては下記になります。
- `segment`: 分割された文字
- `index`: 何番目か
- `input`: 分割元として設定されている文字

あとは文字数を数えるときに`Array.length`と同じようにスプレッド構文を使用して数えればOKです。

### 試してみる
`String.length`や`Array.length`で数えられない文字を正確に数えることができるか試してみます。
```js
const segmenter = new Intl.Segmenter("ja", {granularity: "grapheme"});

const str1 = "𠮷野家";
const str2 = "😄💢✋";
const str3 = "👨‍👩‍👧‍👦";

const segments1 = segmenter.segment(str1);
const segments2 = segmenter.segment(str2);
const segments3 = segmenter.segment(str3);

console.log([...segments1]);
// -> [
//   {segment: '𠮷', index: 0, input: '𠮷野家'}
//   {segment: '野', index: 2, input: '𠮷野家'}
//   {segment: '家', index: 3, input: '𠮷野家'}
// ]

console.log([...segments2]);
// -> [
//   {segment: '😄', index: 0, input: '😄💢✋'}
//   {segment: '💢', index: 2, input: '😄💢✋'}
//   {segment: '✋', index: 4, input: '😄💢✋'}
// ]

console.log([...segments3]);
// -> [
//   {segment: '👨‍👩‍👧‍👦', index: 0, input: '👨‍👩‍👧‍👦'}
// ]

console.log([...segments1].length);
// -> 3

console.log([...segments2].length);
// -> 3

console.log([...segments3].length);
// -> 1
```

一番最後に実施している`[...segments(1|2|3)].length`で文字列をカウントしていますが、正確に数えることができています。
こちらであれば`コードポイント`や`コードユニット`などを考慮せず正確に文字を数えることができそうです。
なお`segmenter.segment(str(1|2|3))`で出力される`index`は`コードユニット`が対象となっていることがわかります。

## Intl.Segmenterの問題点
`String.length`でも`Array.length`でも正確に数えることができなかった文字を`Intl.Segmenter`を使うことで解決できました。
じゃあこれでもう完璧なのかというと1点だけ気をつけないといけない点があります。
一部環境では`Intl.Segmenter`を使うことができないという点です。

### 対象環境
MDNやCan I useで対象環境を確認することができるのですが、2022年9月12日時点ではPC, SP共に`Firefox`ではまだサポートされていないため使用することができません。
そのため`Firefox`をサポート対象としている場合は別の方法を検討する必要があります。（IEはすでにサポートが終了しているのでここでは除外しています。）

![スクリーンショット 2022-09-11 15.13.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2748844/b3b230df-7e73-a22f-1a67-2bb69d5d6005.png)

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


![スクリーンショット 2022-09-11 15.12.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2748844/de76bbbd-2bcc-7260-099e-b124adf2326f.png)

https://caniuse.com/?search=segmenter


## npmパッケージ
2つ目の方法として`npmパッケージ`を使う方法もあります。
自力実装も可能ですが相当大変なので、今回は下記パッケージを使用してみることにします。
こちらは「カーソルが1つ移動する分」という定義に基づいてカウントできるライブラリになります。

https://www.npmjs.com/package/graphemesplit

### 試してみる
手順としてはまず`npm install graphemesplit`でパッケージをインストールします。
インストール完了後はjsファイル内でパッケージを呼び出してカーソル移動分を取得してみます。
なお確認はブラウザ上では`require`が存在しないためエラーとなるので、今回は`node`コマンドから挙動を確認してみます。
[こちら](https://qiita.com/gumieProf/items/e5ed3a919926af985e90)の方法でブラウザ上でも使えるようにはなるのですが、今回は割愛します。

```js
// パッケージを使用する
const split = require('graphemesplit');

// 対象文字列を指定
const str0 = "あいうえお";
const str1 = "𠮷野家";
const str2 = "😄💢✋";
const str3 = "👨‍👩‍👧‍👦";

// 対象文字列のカーソル移動分をカウント
console.log(split(str0));
// -> [ 'あ', 'い', 'う', 'え', 'お' ]

console.log(split(str1));
// -> [ '𠮷', '野', '家' ]

console.log(split(str2));
// -> [ '😄', '💢', '✋' ]

console.log(split(str3));
// -> [ '👨‍👩‍👧‍👦' ]
// -> コンソール上だと['👨👩👧👦']と表現される場合があります。

// 文字数カウント
console.log(split(str0).length);
// -> 5

console.log(split(str1).length);
// -> 3

console.log(split(str2).length);
// -> 3

console.log(split(str3).length);
// -> 1

```
コンソールにて`node 上記jsファイルのパス`を打ち込むことで出力結果を確認することができます。
こちらでも`Intl.Segmenter`と同じように文字数を正確に数えることが可能になります。

## まとめ
- `String.length`の場合、**サロゲートペア**に該当する文字のカウントが正確に行うことができない
- `Array.length`の場合、**コードポイント**が複数存在するような文字のカウントが正確に行うことができない
- `Intl.segmenter`の場合、上の方法で数えることができない文字も正確にカウントすることができる
    - `Firefox`が2022年9月12日時点で対応していないため対象ブラウザになる場合は使うことができない
- `npmパッケージ`で回避することも可能
    - 他パッケージでも可能かもしれないです。

## 最後に
自分で色々調べて今回の記事を執筆してみたのですが、すでに有識者の方が同様の記事を書いてくださっていました。
パクリのようになってしまい申し訳ないです・・・。

https://qiita.com/suin/items/3da4fb016728c024eaca


## 参考
- [Unicode文字一覧表](https://tools.m-bsys.com/ex/unicode_table.php)
- [JSPrimer](https://jsprimer.net/basic/string/)
- [JavaScript における文字コードと「文字数」の数え方](https://blog.jxck.io/entries/2017-03-02/unicode-in-javascript.html)
- [絵文字一覧](https://lets-emoji.com/emojilist/emojilist-28/)
- [Unicode 絵文字にまつわるあれこれ (絵文字の標準とプログラム上でのハンドリング)](https://qiita.com/_sobataro/items/47989ee4b573e0c2adfc)
- [JavaScriptのIntl.Segmenterで文章の意味分割を行う](https://sbfl.net/blog/2020/11/21/javascript-intl-segmenter/)
- [Intl.Segmenter](https://cercopes-z.com/JavaScript/Object-Intl-Segmenter-js.html)