おい、調子はどうだ?
今日も元気にフロントエンドの荒波を泳ぎ回っていることと思う。
さて、今回はJavaScriptの文字列操作、その中でも一見「誰でも知っている」ほど基本的な`length`プロパティについて、あえて深く掘り下げてみようと思う。
「いやいや、文字列の長さを取得するだけなんだから `str.length` で終わりだろ?」と思ったそこの君。もしそう考えているなら、実務で痛い目を見るか、あるいは既にユーザーからのバグ報告に頭を抱えた経験があるはずだ。
今回は、中級からもう一歩抜け出して「真のフロントエンド・プロフェッショナル」になるために、`length`の裏側の仕様と、実務で絶対に避けて通れない「サロゲートペア問題」について、俺の知見をすべて叩き込んでやろう。
—
なぜ `length` プロパティの仕様を知る必要があるのか?
フロントエンドを書いていて、ユーザーの入力文字数制限(例えば、プロフィールの自己紹介文やSNSの投稿など)をバリデーションするシーンは枚挙に暇がない。
「最大200文字まで」という要件に対して、素朴に`input.value.length`を使って実装していなかい?
もし、その入力欄に絵文字や一部の特殊な漢字(いわゆる環境依存文字など)が入力された場合、何が起きるか知っているか?
JavaScriptが認識する「文字数」と、人間の目で見た「文字数」が一致しなくなるんだ。
この「ズレ」を放置したままバックエンドにデータを送ると、DBの文字数制限に引っかかって500エラーを吐いたり、UI上で表示が不気味に崩れたりする。実務ではこういう細部の不整合が、プロダクトの信頼性を一気に落とす原因になる。だからこそ、俺たちは仕様の裏側まで理解しておく必要があるのさ。
—
JavaScriptの裏側:`length` は「文字数」ではなく「UTF-16のコードユニット数」を返している
まず大前提として、JavaScriptの文字列(String)は、内部的には UTF-16 というエンコーディング方式で保持されている。
そして、`length` プロパティが返しているのは、「文字の数」ではなく、「UTF-16のコードユニット(16ビット単位)の数」だ。ここが最大の罠になる。
ASCII文字(アルファベットや一般的な数字、記号など)や、ほとんどの日本語のひらがな・カタカナ・漢字は、1つの文字が1つのコードユニット(16ビット)で表現できる。だから、`”こんにちは”.length` は `5` になるし、期待通りに動く。
しかし、Unicodeの普及に伴い、16ビット(2バイト)の枠に収まりきらない文字が登場した。それがサロゲートペア(上位サロゲートと下位サロゲートの2つの16ビット値の組み合わせ)で表現される文字たちだ。
代表的な例が「絵文字(🚀や✨など)」や、歴史的・人名用の一部罕用漢字(いわゆるJIS補助漢字など)だな。
これらは、人間から見れば「1文字」なのに、JavaScriptの内部では「2つのコードユニット」として扱われる。
結果として、何が起きるか?
// ASCII文字や通常の日本語は1文字 = 1 length
console.log(“JS”.length); // 2
console.log(“猫”.length); // 1
// しかし、絵文字を含むと…?
const rocket = “🚀”;
console.log(rocket.length); // 2 (!? 人間には1文字に見えるのに!)
const family = “👨👩👧👦”; // 家族の絵文字(実はゼロ幅接合子などで結合されている)
console.log(family.length); // 11 (?!マジかよ…)
恐ろしいだろ? `🚀` は2、複雑な絵文字になると11もの長さを叩き出す。
これをそのまま「文字数制限200文字以内」のバリデーションに通したら、ユーザーが絵文字をいくつか打っただけで「文字数オーバーです」と怒られてしまうわけだ。UX最悪だな。
—
実務で使える!正しい「文字数」の数え方とベストプラクティス
じゃあ、俺たちはどうやって実務でこの問題を解決すればいいのか?
答えはシンプルで、スプレッド構文 (`…`) や `Intl.Segmenter` を使うことだ。現代のJavaScript(ES2015以降、およびモダンブラウザ)は、ちゃんと私たち救いの手を差し伸べてくれている。
1. スプレッド構文(Spread Syntax)で配列に展開する
もっとも手軽で、一般的なサロゲートペア文字(絵文字など)に対応できる方法がこれだ。文字列をスプレッド構文で配列に展開すると、JavaScriptはサロゲートペアを考慮して「1文字ずつ」正しく分割してくれる。
/
- サロゲートペアを考慮して正確な文字数を取得する関数
- @param {string} str
- @returns {number}
/
function getActualLength(str) {
// スプレッド構文で配列化することで、サロゲートペアを1文字として分割する
const characters = […str];
return characters.length;
}
const textWithEmoji = “こんにちは🚀世界”;
console.log(textWithEmoji.length); // 9 (誤った長さ:サロゲートペアのせいでズレる)
console.log(getActualLength(textWithEmoji)); // 8 (正しい長さ:🚀を1文字としてカウント)
これで大半のケースはクリアできる。もしチームのターゲットブラウザがモダンブラウザ(IE11のサポートなんかとうの昔に終わっているはずだよね?)なら、まずはこのアプローチを標準装備してほしい。
2. さらに一歩進んだ究極の解決策:`Intl.Segmenter`
もし君が、先ほどチラッと見せた `👨👩👧👦`(複数の絵文字やZWJ(ゼロ幅接合子)が組み合わさった家族の絵文字など)のような、さらに複雑なGrapheme Cluster(書記素クラスタ)を正確に数えたいなら、`Intl.Segmenter` APIを使うべきだ。
これは国際化(i18n)APIの一つで、人間の言語的直感に極めて近い単位で文字列を分割してくれる優れものだ。
/
- Intl.Segmenter を使って、人間の視覚的な「文字数」を正確に取得する
- @param {string} str
- @returns {number}
/
function getGraphemeLength(str) {
// ユーザーのロケールに合わせて文字単位(grapheme)でセグメント化するインスタンスを生成
const segmenter = new Intl.Segmenter(‘ja’, { granularity: ‘grapheme’ });
const segments = segmenter.segment(str);
let count = 0;
for (const segment of segments) {
count++;
}
return count;
}
const complexEmoji = “👨👩👧👦”;
console.log(complexEmoji.length); // 11 (絶望的な数字)
console.log(getGraphemeLength(complexEmoji)); // 1 (人間が見る通り「1文字」としてカウントされる)
実務でSNSクライアントや、高度なテキストエディタ、厳密な文字数カウンターを作る必要があるなら、`Intl.Segmenter` は絶対に知っておくべき武器だ。
—
チーフアーキテクトからの現場の助言
最後に、実務でコードレビューをする立場から、後輩たちにいくつかアドバイスを送っておこう。
1. 「ただの文字数確認」と侮るな
バックエンドのスキーマ定義(VARCHARのバイト数や文字数制限)と、フロントエンドのバリデーションロジックがどう噛み合っているかを必ず確認しろ。「フロントでは通ったのにDBで弾かれた」というバグは、大抵この `length` の仕様の違いをスルーしたことが原因だ。
2. パフォーマンスへの配慮を忘れるな
`Intl.Segmenter` は非常に強力だが、数万文字もある巨大なテキストをリアルタイムの入力イベント(`input` イベントなど)のたびに毎回セグメント化すると、メインスレッドをブロックしてタイピングが重くなる原因になる。通常の入力フォームであればスプレッド構文や正規表現での対策留めにするか、デバウンス(Debounce)を挟むなどのアーキテクチャ上の工夫を忘れないように。
技術の基本中の基本である `length` プロパティ一つをとっても、これだけの深い背景とハックが存在する。
こういう細部へのこだわりこそが、君を「ただ動くコードを書くエンジニア」から「信頼できるプロフェッショナル」へと引き上げてくれるはずだ。
それじゃあ、今日もイケてるコードを書こうぜ!何か質問があれば、いつでも俺のところに聞きに来い。

コメント