189
121

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-09-27

はじめに

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.lengthArray.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はすでにサポートが終了しているのでここでは除外しています。)

スクリーンショット 2022-09-11 15.13.31.png

スクリーンショット 2022-09-11 15.12.45.png

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パッケージで回避することも可能
    • 他パッケージでも可能かもしれないです。

最後に

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

参考

189
121
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
189
121

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?