JavaScriptの `length` は、なぜ「文字数」を数えられないのか? ―― サロゲートペアの闇と文字列アーキテクチャの真実
こんにちは。日夜V8エンジンのメモリ効率やDOMの再描画コストに頭を悩ませているフロントエンド・アーキテクトだ。
今回は、JavaScriptの文字列操作において最も基本的でありながら、多くのシニアエンジニアすら油断した瞬間に足元をすくわれる魔物――`String.prototype.length` プロパティについて、その内部挙動と実務レベルでの回避策を徹底的に解剖しよう。
「文字列の長さなんて `str.length` を見れば一発だろ?」
そう思ったそこのあなた。その油断が、グローバル展開するWebアプリケーションのデータベースを破壊し、UIのレイアウトシフトを引き起こし、セキュリティ上の脆弱性を生む温床になっているとしたらどうだろうか?
さあ、ブラウザの内部挙動の深淵へと潜るぞ。
—
1. `length` プロパティの正体:文字数ではなく「コードユニット数」
まず大前提として刻み込んでおかなければならないのは、JavaScriptにおける文字列(String)とは、UTF-16エンコードされた16ビット符号なし整数(UTF-16 Code Unit)のシーケンスであるという事実だ。
V8をはじめとするモダンなJavaScriptエンジンは、メモリ上で文字列をUTF-16(またはASCIIに最適化されたLatin-1)のバイト列として保持している。`length` プロパティが返しているのは、私たちが目視する「文字数(Grapheme)」ではなく、メモリ上で占有されている16bitコードユニットの数に他ならない。
これが何を意味するか? 2バイト(16ビット)の枠に収まらない文字、すなわちサロゲートペア(Surrogate Pairs)や結合文字(Combining Characters)、そして絵文字(Emoji)が絡んだ瞬間、`length` は完全にバグの発生源と化す。
サロゲートペアの悪夢
基本多言語面(BMP: Basic Multilingual Plane)を超える文字(U+10000 〜 U+10FFFF)、例えば絵文字の 🚀 (U+1F680) や、一部の珍しい漢字などは、UTF-16では2つのコードユニット(上位サロゲートと下位サロゲート)で表現される。
ここで、実務でよくある絶望的なコードを見てみよう。
/
- ユーザー名の最大文字数をバリデーションする危険な関数
- @param {string} username
- @returns {boolean}
/
function validateUsername(username) {
// 業務要件:ユーザー名は最大 5 文字までとする
// ここで length を信用すると……
return username.length <= 5;
}
// 通常のASCII文字なら問題ない
console.log(validateUsername("Alice")); // true (長さ 5)
// 絵文字を含めた瞬間に悲劇が起きる
const maliciousName = "🚀🚀🚀";
console.log(maliciousName.length); // 出力: 6 (文字数は 3 なのに!)
console.log(validateUsername(maliciousName)); // true が返ってしまう!
文字数としては「3文字」しか入力されていないにもかかわらず、`length` は `6` を返す。もしこれがデータベースのカラム長制約(例: `VARCHAR(5)`)にそのまま流し込まれたらどうなるか? データベース側で文字化けや切り詰めエラー(Truncation Error)が発生し、最悪の場合、トランザクション全体がロールバックされる。
---
2. アーキテクチャの観点:なぜエンジンはこれを「直さない」のか?
「いやいや、ECMAScriptの仕様側で `length` を本当の文字数に修正してくれよ」という声が聞こえてきそうだが、それはJavaScriptの歴史的背景とパフォーマンスのトレードオフを無視した暴論というものだ。
もし `str.length` が「人間が認識する文字数(正確にはGrapheme Cluster)」を返す仕様だったとしたら、どうなるか?
文字列の長さを取得するたびに、エンジンは文字列の先頭から末尾までスキャンし、サロゲートペアの判定や、ゼロ幅接合子(ZWJ)を含む結合文字のクラスタリング解析を毎回O(N)の計算量で実行しなければならなくなる。
現在の `length` プロパティが $O(1)$(メモリ上のバッファサイズを即座に参照するだけ)で動作するという圧倒的なパフォーマンス的優位性を、言語仕様として捨てることなど到底できないのだ。
したがって、我々アプリケーション層のエンジニアが、このハードウェア寄りの低レイヤー仕様と人間の認知のギャップを的確にハンドリングしなければならない。
—
3. 実務で使える堅牢な文字列長取得のアーキテクチャ
では、モダンなWebアプリケーションにおいて、本当の意味での「文字数」や「文字列の安全性」を担保するにはどうすればよいのか。具体的なアプローチを見ていこう。
アプローチ A: スプレッド構文 (`[…str]`) によるイテレーション
最も手軽で、ES2015以降の環境であれば追加のポリフィルなしで使えるのが、スプレッド構文や `Array.from()` を使って配列に展開する方法だ。文字列のイテレータはサロゲートペアを正しく考慮してコードポイント単位(またはサロゲートペアを結合した単位)でバラしてくれる。
/
- サロゲートペアを考慮して真の文字数を取得する堅牢な関数
- @param {string} str
- @returns {number}
/
function getTrueLength(str) {
// スプレッド構文は文字列のイテレータブルを利用するため、サロゲートペアを分断しない
return […str].length;
}
const rocketStr = “🚀🚀🚀”;
console.log(rocketStr.length); // 6 (従来のバグを生むプロパティ)
console.log(getTrueLength(rocketStr)); // 3 (正しい文字数)
ただし、ここでパフォーマンスに敏感なシニアエンジニアならこう懸念するはずだ。
「おい、毎回 `[…]` でメモリ上に一時配列をアロケートしてたら、数万文字ある巨大なテキストを扱うときにGC(ガベージコレクション)の負荷が跳ね上がるじゃないか」と。
その通り。巨大なテキストのバリデーションやストリーム処理において、単に長さを測るためだけに配列を生成するのはメモリ効率の観点から避けるべき悪手だ。
アプローチ B: 高パフォーマンスな `Intl.Segmenter` の活用
現代のJavaScript(ES2021以降のモダンブラウザおよびNode.js 16+)には、言語仕様として最強の武器が備わっている。それが `Intl.Segmenter` だ。
これを使えば、Grapheme(書記素クラスタ)単位で正確に文字をカウントでき、かつ余計な配列アロケーションを抑えた効率的な処理が可能になる。
// 書記素(Grapheme)単位でのセグメンターを生成(言語は環境に合わせて適切に指定)
const segmenter = new Intl.Segmenter(‘ja’, { granularity: ‘grapheme’ });
/
- Intl.Segmenter を用いた高速かつ正確な文字数カウント
- @param {string} str
- @returns {number}
/
function getGraphemeLength(str) {
let count = 0;
// イテレータブルを回すことで、メモリを無駄に消費せずカウント可能
for (const _ of segmenter.segment(str)) {
count++;
}
return count;
}
const complexEmoji = “👨👩👧👦”; // 家族の絵文字(複数のコードポイントとZWJで構成されている)
console.log(complexEmoji.length); // 11 (絶望的な数字)
console.log([…complexEmoji].length); // 7 (サロゲートペアで割ってもまだずれる)
console.log(getGraphemeLength(complexEmoji)); // 1 (人間にとっては「1文字」!)
家族の絵文字 `👨👩👧👦` は、実は裏側で複数のUnicodeコードポイントがゼロ幅接合子(ZWJ)で連結されている。`length` を見ると「11」になり、スプレッド構文でも「7」になってしまうが、`Intl.Segmenter` ならば人間が認識する通り「1文字」として正確に捉えることができる。これぞプロフェッショナルな文字列処理だ。
—
4. セキュリティとバックエンド連携の罠
この `length` の仕様を理解していないと、セキュリティ上の脆弱性や、非同期通信におけるデータ競合・予期せぬ切り詰めに直結する。
1. SQLインジェクション対策や入力バリデーションのすり抜け
フロントエンド側で `input.length <= 10` というバリデーションを信頼し、サロゲートペアや結合文字を含む攻撃的ペイロード(または極端に長い絵文字の連打)を送り込まれた場合、バックエンドのデータベースの型制限(`VARCHAR` など)を超過し、例外(500 Internal Server Error)を引き起こしたり、最悪の場合はDBのパニックを誘発してDoS攻撃の踏み台にされる。
2. UIレンダリングのレイアウト破壊(CSSとの乖離)
CSSの `text-overflow: ellipsis` や文字数制限に基づく切り出し処理を自前で実装する際、`String.prototype.slice(0, 5)` などと安易にインデックスを指定すると、サロゲートペアの上位サロゲートだけを真っ二つにブチ抜いてしまうことがある。結果として、ブラウザが不正な文字コードをレンダリングし、画面上に謎の豆腐(□)や文字化けが発生、最悪の場合はDOMツリーの構築に異常をきたす。
安全に文字列を切り詰めるユーティリティの例を見ておこう。
/
- サロゲートペアを破壊せずに安全に文字列を切り詰める
- @param {string} str
- @param {number} maxGraphemes
- @returns {string}
/
function safeSlice(str, maxGraphemes) {
const segmenter = new Intl.Segmenter(‘ja’, { granularity: ‘grapheme’ });
let result = ”;
let currentCount = 0;
for (const { segment } of segmenter.segment(str)) {
if (currentCount >= maxGraphemes) break;
result += segment;
currentCount++;
}
return result;
}
const target = “A🚀B👨👩👧👦C”;
console.log(safeSlice(target, 3));
// 出力: “A🚀B” (サロゲートペアを壊さず、正確に3文字分で安全にカットされる)
—
結びにかえて
たかが `length`、されど `length`。
JavaScriptという言語の歴史と、V8エンジンのメモリ効率、そしてUnicodeという複雑怪奇な文字コードの仕様が交差するこのポイントにおいて、無知は最大のコストである。
「動けばいいや」で書かれたコードは、多言語対応(i18n)が必須となった現代のWebアプリケーションにおいて、必ずユーザーの期待を裏切る。表面的なAPIの使い方に囚われず、その背後にあるメモリモデルやエンジン挙動にまでと思いを馳せることこそが、真に堅牢なアーキテクチャを築くフロントエンド・スペシャリストの条件だ。
今日のコードレビューから、あなたのチームの `length` の使い方は本当に安全か、見直してみてはどうだろうか。

コメント