第1回で length を使いました。あれは、いつでも正しいわけではありません。
見た目はどちらも3文字です。なのに、絵文字が入ると4になりました。
length が数えているもの
JavaScript の文字列はUTF-16という形式で持たれています。
length が返すのは、その符号単位(code unit)の個数です。
多くの文字は符号単位1つで表せます。ですが、表しきれない文字があります。
絵文字は符号単位2つの組で表されます。これをサロゲートペアと呼びます。
つまり length は嘘をついていません。
「文字の数」を数えているつもりが無いだけで、符号単位の数という約束を守っています。
数えたいものが違う、というだけの話です。
1つの文字としての番号を見る
codePointAt() は、符号位置(code point)を返します。こちらが「文字そのものの番号」です。
65535(0xFFFF)を超える番号の文字が、サロゲートペアになります。
符号位置ごとに分ける
スプレッド構文 [...] と Array.from() は、符号位置ごとに分けてくれます。
見た目と合いました。 多くの場面では、これで十分です。
それでも合わないものがある
家族の絵文字を数えてみます。
見た目は1文字なのに、符号位置でも5あります。 これは3人の絵文字を見えないつなぎ記号で連結したものだからです。
肌の色を指定した絵文字でも同じことが起きます。
「親指」と「肌の色」の2つが組み合わさっています。
見た目の1文字=書記素
人が「1文字」と感じる単位を書記素(grapheme cluster)と呼びます。
これを数えるのが Intl.Segmenter です。
const text = 'あ🙂い';
const reversed = text.split('').reverse().join('');
console.log(reversed.length);
console.log(reversed === 'い🙂あ');ようやく見た目と一致しました。
3つの数え方
| 数え方 | 書き方 | '👨👩👧' |
|---|---|---|
| 符号単位 | text.length |
8 |
| 符号位置 | [...text].length |
5 |
| 書記素 | Intl.Segmenter |
1 |
どれが正しいということはありません。 目的で選びます。
- 保存できる長さの上限を見る →
length(データの大きさに近い) - 1文字ずつ処理する →
[...text] - 入力欄の「あと何文字」を出す →
Intl.Segmenter
Intl.Segmenter は 2024 年に主要なブラウザで揃いました。古い環境も相手にするなら対応状況を確認してください。
単語で区切ることもできる
granularity を変えると、単語の単位で分けられます。
const text = 'あ🙂い';
console.log(text.slice(0, 2).length);
console.log(text.slice(0, 2) === 'あ🙂');日本語には単語の区切りに空白がありません。 それでも分けられます。
見た目が同じでも、等しくない
ここからは別の問題です。が は2通りの書き方があります。
画面には同じ が が出ているのに、等しくありません。
片方は「が」1文字、もう片方は「か」+「濁点」の2文字です。
macOS のファイル名やコピーしてきた文字列で、実際に起きます。
normalize() で揃える
比べる前に、どちらかの形へ正規化します。
利用者が入力した文字列を比べるときは、正規化してから比べてください。
NFKC を使うと、半角カタカナや全角英数も揃います。
検索の照合に向きますが、元の表記は失われます。保存する値には使わないでください。
並べ替えも辞書順ではない
sort() は符号位置の順に並べます。人が期待する順とは違います。
大文字が先にまとまってしまいました。 符号位置では大文字のほうが小さいからです。
localeCompare() を使うと、辞書の順に近づきます。
ひらがな同士のように、既定の並べ替えでも同じ結果になる場合はあります。違いが出るかどうかは中身によります。
よくある間違い
split('') で1文字ずつに分ける
split('') は符号単位で分けます。
サロゲートペアが2つに割れてから逆順になるので、絵文字が壊れます。
スプレッド構文なら符号位置ごとに分かれるので、壊れません。
ただし、これでも結合文字は壊れます。家族の絵文字を逆順にすれば分解されます。
確実に扱うなら Intl.Segmenter で分けてください。
途中で切ると壊れる
slice() も符号単位で切ります。絵文字の途中で切れました。
文字数で切り詰めるなら、先に [...text] か Intl.Segmenter で分けてから取ってください。
まとめ
lengthは符号単位の数。見た目の文字数ではない- 絵文字は符号単位2つ(サロゲートペア)
[...text]は符号位置ごと。多くの場面ではこれで足りる- 家族や肌の色の絵文字は、符号位置でも1にならない
- 見た目の1文字=書記素。数えるなら
Intl.Segmenter - 同じに見えて等しくない文字がある。比べる前に
normalize() sort()は符号位置の順。辞書順に近づけるならlocaleCompare()
この連載はここまで
文字列を作り、切り出し、探し、正しく数えるところまで来ました。 ここから先は、必要になったときに引ける形でまとめてあります。
- String.prototype.split() … 空文字で分けたときの挙動、連続した区切りの扱い
- String.prototype.replace() と replaceAll()
…
$が特別扱いされる落とし穴、関数で置き換える方法 - 文字列を数値に変換する
…
Number()とparseInt()の違い、失敗を見分ける方法 - 配列の基本
…
[...text]で得られる配列を、そのまま扱えるようになります