はじめに
JavaScriptにて文字数をカウントする方法に関する記事をいくつか目にする機会があり、今回実際に記事を参考に調べてみました。
簡単そうに見えて意外と難しいです。
String.length
Googleなどで「JavaScript 文字数 カウント」
とかで検索すると真っ先に出る方法です。
MDN公式ではString.length
に関して以下のように説明されています。
length プロパティは String オブジェクトの文字列長を UTF-16 コードユニットの数で表します。
length は、 string インスタンスの読み取り専用データプロパティです。
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 |
どのようにカウントされるのか
例えば「あいうえお」のコードユニットは以下のようになります。
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
になるという特徴があります。
結論
だいぶ話が長くなってしまいましたが、サロゲートペアの文字を数えようとすると想定外の挙動となります。
例えば「𠮷野家」のコードユニットは以下のようになります。
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 |
const str = "😄💢✋";
console.log(str.length);
// -> コードユニットの数を数える: ["0xD83D", "0xDE04", "0xD83D", "0xDCA2", "0x270B"]
// -> 5
Array.length
String.length
ではサロゲート文字をカウントしようとすると正確にカウントすることができないということがわかりました。
ではそれらの文字も正確にカウントしたい場合はどうすれば良いのかということになるのですが、その解決方法に関してはMDNに書かれています。
length
は文字数ではなくコードユニットの数を数えるため、文字数を知りたい場合はこのようなことをする必要があります。// 該当コードのみ引用 function getCharacterLength (str) { // The string iterator that is used here iterates over characters, // not mere code units return [...str].length; }
何をしているのかというと、文字列を1文字ずつ配列に格納するようにして配列の長さで文字数を数えるようにしようとしています。
スプレッド構文
そもそも[...str]
としているけど、これが何かわかりません。という方に対しての簡単な説明です。
知っている方はスルーでOKです。
MDNでは下記のように説明されています。
スプレッド構文 (...) を使うと、配列式や文字列などの反復可能オブジェクトを、0 個以上の引数 (関数呼び出しの場合) や要素 (配列リテラルの場合) を期待された場所で展開したり、オブジェクト式を、0 個以上のキーと値の組 (オブジェクトリテラルの場合) を期待された場所で展開したりすることができます。
言葉だけだと難しいので実際にコードを見ながら確認してみます。
const str = "あいうえお";
console.log(...str);
// -> "あ" "い" "う" "え" "お"
console.log([...str]);
// -> ["あ", "い", "う", "え", "お"]
文字列に対してスプレッド構文を使用すると1文字ずつ区切ることができます。
これを空配列([]
)の中で行うと1文字ずつ区切られた状態の配列を作成することができます。
なのでString.split('')
で1文字ずつ区切ることと全く同じことをしています。
どのようにカウントされるのか
文字列をスプレッド構文を使用して配列に変化させると、カウント対象がコードユニットからコードポイントに変わります。
そのためString.length
でうまくカウントできない文字も正しくカウントできるようになることができます。
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
ではどうなるでしょうか。
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オブジェクトにより、ロケールに依存したテキストのセグメンテーションが可能になり、文字列から意味のある項目 (書記素、単語、または文) を取得できるようになります。
何をしているのかというと、コンピュータにとっては意味合いのないただの文字列でしかないものを人間がわかるような意味合いのある文章に分割する
ことをしようとしています。
この中でオプションとして設定できる値があり、そちらを利用することで正確に文字を数えようとしています。
どのようにカウントされるのか
// 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
で数えられない文字を正確に数えることができるか試してみます。
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はすでにサポートが終了しているのでここでは除外しています。)
npmパッケージ
2つ目の方法としてnpmパッケージ
を使う方法もあります。
自力実装も可能ですが相当大変なので、今回は下記パッケージを使用してみることにします。
こちらは「カーソルが1つ移動する分」という定義に基づいてカウントできるライブラリになります。
試してみる
手順としてはまずnpm install graphemesplit
でパッケージをインストールします。
インストール完了後はjsファイル内でパッケージを呼び出してカーソル移動分を取得してみます。
なお確認はブラウザ上ではrequire
が存在しないためエラーとなるので、今回はnode
コマンドから挙動を確認してみます。
こちらの方法でブラウザ上でも使えるようにはなるのですが、今回は割愛します。
// パッケージを使用する
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パッケージ
で回避することも可能- 他パッケージでも可能かもしれないです。
最後に
自分で色々調べて今回の記事を執筆してみたのですが、すでに有識者の方が同様の記事を書いてくださっていました。
パクリのようになってしまい申し訳ないです・・・。