文字数カウンターを作ると必ずぶつかるのが「絵文字を入れたら2文字と数えられた」問題。これはバグではなく、JavaScriptの String.prototype.length が文字の見た目ではなくUTF-16のコード単位を数えているから。ぱんだツールズの文字数カウンターは、文字数・行数・UTF-8バイト数をリアルタイムで表示するツール。この記事では、その裏側にあるコード単位・コードポイント・書記素の違いを実装ベースで書く。
.length はUTF-16のコード単位を数えている
カウンターの中身は驚くほど短い。入力のたびにこれを計算しているだけ。
function countText(text: string): CountResult {
const total = text.length // 文字数(スペース含む)
const noSpace = text.replace(/\s/g, '').length // 文字数(スペース除く)
const lines = text === '' ? 0 : text.split('\n').length // 行数
const bytes = new TextEncoder().encode(text).byteLength // UTF-8バイト数
return { total, noSpace, lines, bytes }
}
問題は text.length。これはJavaScriptの文字列の内部表現であるUTF-16のコード単位(16ビット=2バイトの箱)の個数を返す。ふだん使うほとんどの文字(英数字・ひらがな・漢字の大半)は1コード単位に収まるので、"あ".length は 1 で直感通り。
ところが、Unicodeの広い範囲(BMPの外=コードポイントが U+FFFF を超える文字)は、UTF-16ではサロゲートペアという2つのコード単位の組で表現される。絵文字や一部の漢字がこれに当たる。
"👍".length // 2(サロゲートペア)
"𩸽".length // 2(「ほっけ」。JIS第4水準の漢字)
"🇯🇵".length // 4(国旗は2つの記号文字の組み合わせ)
だから「👍」1個が .length では2と数えられる。見た目の1文字と、内部のコード単位の数が一致しないのが原因。
コード単位・コードポイント・書記素
「1文字」の数え方は、実は3層ある。
-
コード単位(code unit) … UTF-16の箱の数。
.lengthが返すもの。絵文字=2 -
コードポイント(code point) … Unicodeの文字番号の数。
[...text].lengthやArray.from(text).lengthはこちらで、サロゲートペアを1つにまとめて数える。絵文字=1 -
書記素クラスタ(grapheme cluster) … 人が「1文字」と感じる単位。
Intl.Segmenterで数える。ゼロ幅接合子(ZWJ)で繋いだ絵文字(👨👩👧 など)も1と数える
const s = "👨👩👧" // 家族の絵文字(ZWJで3人を結合)
s.length // 8(コード単位)
[...s].length // 5(コードポイント。人3つ+ZWJ2つ)
// Intl.Segmenter なら 1(書記素)
このツールは一番素朴な .length(コード単位)を採用している。「見た目の文字数」を厳密に出すなら Intl.Segmenter が正しいが、あえてコード単位で数えるのには理由がある。
コード単位カウントは意外と実務に合う
「正確なのは書記素なのに、なぜコード単位?」と思うが、世の中の多くの文字数制限が実はコード単位(またはそれに近い)で動いている。
- HTMLの
maxlength属性はUTF-16コード単位で数える。フォームで「あと何文字入力できるか」はコード単位ベース - 一部のDBの文字数制限や、古いAPIの上限もUTF-16やバイト単位のことが多い
- 「◯◯字以内」の応募要項・SEOのtitle文字数なども、絵文字を厳密に1と数えたいケースは稀
つまり .length の値は、「厳密な見た目の文字数」ではないが、「システム上の文字数制限に引っかかるかどうか」の目安としてはむしろ実用的。だから素朴なコード単位カウントを一次情報として出しつつ、絵文字を多用するなら数がズレる、という前提を知っておけばいい。
スペース除去と行数の小さな仕様
残り3つの数値にも地味な仕様がある。
スペース除去は text.replace(/\s/g, '') で空白を消してから数える。JavaScriptの \s は半角スペース・タブ・改行だけでなく、全角スペース(U+3000)もマッチする。日本語文書で全角スペースを使っていても、ちゃんと空白として除外される。
行数は text.split('\n').length。改行(LF)で分割した断片の数を数える。空文字だけは特別扱いで 0 にしている。ここで一つ癖があって、末尾に改行があるテキスト("あ\n")は ["あ", ""] に割れて2行と数えられる。最後の改行の後ろに空の行があるとみなす、というよくある挙動。
バイト数は new TextEncoder().encode(text).byteLength。TextEncoderは常にUTF-8で符号化するので、日本語1文字はふつう3バイトになる。API上限やファイルサイズの見積もりに使える。同じ日本語でもUTF-8とShift_JISではバイト数が変わる(SJISなら2バイト)——このあたりの話はCSV文字コード変換の記事で詳しく書いた。
まとめ
-
String.prototype.lengthはUTF-16のコード単位を数える。サロゲートペアで表現される絵文字や一部の漢字は2(以上)と数えられる - 「1文字」には3層ある。コード単位(
.length)・コードポイント([...text].length)・書記素(Intl.Segmenter)。厳密な見た目の文字数は書記素 - あえてコード単位で数えるのは、
maxlengthやDB・API制限の多くがコード単位ベースで、実務の「文字数制限」の目安として使いやすいから - UTF-8バイト数は
TextEncoder().encode().byteLength。日本語は1文字3バイト。文字コードが変わればバイト数も変わる
X(旧Twitter)の文字数確認や、字数制限のある文章を書くときにどうぞ。入力はブラウザの外に出ない。
ぱんだツールズ では他にも 文字コード変換・全角半角変換・大文字小文字変換・テキスト整形など、開発者向けのブラウザ完結ツールを多数公開中。全部無料・登録不要・入力はサーバーに送られない。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。