テキストエンコーディングの内部(UTF-16のサロゲートとコードポイント)
絵文字の文字数がずれる謎が原理から解ける。JS文字列がUTF-16コード単位列である帰結、サロゲートペア、lengthとIntl.Segmenterの違いまで一気に整理し、文字数バグを根絶します。
- 1.JavaScriptの文字列はUTF-16のコード単位(16bit)の並びで、添字もlengthもコード単位を数える。文字(コードポイント)や見た目の1文字(書記素)とは一致しない。
- 2.U+10000以上の文字(多くの絵文字や一部漢字)は2つのコード単位=サロゲートペアで表される。そのためlengthが2と数え、添字で半分だけ切ると壊れる。
- 3.コードポイント単位で正しく扱うにはfor...ofやArray.from、見た目の1文字単位で数えるにはIntl.Segmenterを使う。lengthは「文字数」ではない。
なぜ「文字列の長さ」がずれるのか
絵文字を1個入れただけで length が 2 を返す、slice で文字が文字化けする、入力欄の文字数カウントが見た目とずれる——これらは個別のバグではなく、JavaScriptの文字列がUTF-16のコード単位列であるという設計から導かれる必然です。原理を押さえれば、どの操作が安全でどこに Intl.Segmenter を入れるべきかを推測でなく仕組みから判断できます。前提として基礎は JavaScript を押さえておくと、ここでの内部表現がつながります。
Unicodeの三層:コードポイント・コード単位・書記素
まず用語を分けます。これらを混同することが、文字数バグのほぼすべての原因です。
| 概念 | 意味 | JSでの数え方 |
|---|---|---|
| コードポイント | Unicodeが文字に割り当てた番号(U+0000〜U+10FFFF) | for...of / Array.from / codePointAt |
| コード単位 (UTF-16) | 符号化後の16bit値1個。文字列の物理的な構成要素 | length / 添字 / charCodeAt |
| 書記素クラスタ | 人が見て1文字と感じる単位(結合文字や絵文字連結を1つに) | Intl.Segmenter(granularity: grapheme) |
Unicodeは文字にコードポイントという番号を振りますが、メモリ上にどう並べるかは別問題で、その符号化方式の一つがUTF-16です。UTF-16はコードポイントを16bitのコード単位1個または2個で表します。さらに人が「1文字」と感じる単位は書記素クラスタで、これはコードポイント1個とも限りません。JavaScriptの length が数えるのは、この三層のうちコード単位です。
JS文字列はUTF-16コード単位の並び
ECMAScript仕様では、文字列は16bitの符号なし整数(コード単位)の列と定義されます。添字 str[i] も str.length も str.charCodeAt(i) も、すべてこのコード単位を基準に動きます。U+FFFF以下の文字(ASCII、日本語の大半、基本多言語面=BMPの文字)はコード単位1個で表されるため、この範囲だけ使う限り「length = 文字数」が偶然一致します。
"abc".length; // 3
"あいう".length; // 3(各文字がBMP内、1コード単位ずつ)
"あ".charCodeAt(0); // 12354 (= 0x3042)
ところがこの一致はBMP内に限った偶然であり、U+10000以上の文字が混ざった瞬間に崩れます。仕様上 length は「コード単位数」であって「文字数」ではないからです。
サロゲートペア:U+10000以上を2個で表す
UTF-16は16bitなので、単独では 0x0000〜0xFFFF(BMP)しか表せません。U+10000以上の文字(補助面)は、2つのコード単位の組=サロゲートペアで表現します。具体的には、0xD800〜0xDBFFの上位サロゲートと、0xDC00〜0xDFFFの下位サロゲートを連続させます。この 0xD800〜0xDFFF の範囲はサロゲート専用に予約され、単独の文字としては使われません。
コードポイント C(U+10000以上)からペアへの変換は次の通りです。
v = C - 0x10000 # 0x00000〜0xFFFFF(20bit)に収まる
hi = 0xD800 + (v >> 10) # 上位10bit → 上位サロゲート
lo = 0xDC00 + (v & 0x3FF) # 下位10bit → 下位サロゲート
20bitを上位・下位10bitずつに分け、それぞれサロゲート領域へ写像します。逆向きの復号は C = (hi - 0xD800) * 0x400 + (lo - 0xDC00) + 0x10000 です。絵文字 😀(U+1F600)を例に挙げます。
"😀".length; // 2(サロゲートペアなので2コード単位)
"😀".charCodeAt(0); // 55357 (0xD83D, 上位サロゲート)
"😀".charCodeAt(1); // 56832 (0xDE00, 下位サロゲート)
"😀".codePointAt(0); // 128512 (0x1F600, 正しいコードポイント)
[..."😀"].length; // 1(コードポイント単位で数える)
charCodeAt がコード単位を返すのに対し、codePointAt は上位サロゲートを見つけると下位とペアにして正しいコードポイントを返します。この一語の違いが正否を分けます。
str.slice(0, 1) や str[0] はコード単位境界で切るため、サロゲートペアの片割れ(上位だけ)を取り出してしまいます。残された半分は対応する相手のいない**孤立サロゲート(lone surrogate)**となり、表示すると置換文字 U+FFFD(◇)に化けます。「先頭N文字だけ表示」を添字で実装すると絵文字や一部漢字(𠮷 など)で壊れるのは、ほぼ必ずこれが原因です。
length は文字数ではない:正しい数え方
「length = 文字数」という思い込みを捨てるのが核心です。目的に応じて数える単位を選びます。
| 数えたいもの | 正しい手段 | 誤用しがちな手段 |
|---|---|---|
| コード単位(記憶域の長さ) | str.length | —(これは正しくコード単位) |
| コードポイント数 | [...str].length / Array.from(str).length | str.length |
| 見た目の1文字(書記素)数 | Intl.Segmenter(grapheme) | str.length / [...str].length |
ES2015以降、文字列の反復子(iterator)はコードポイント単位で動きます。for...of・スプレッド [...str]・Array.from(str) はサロゲートペアを1要素として扱うため、コードポイント数を正しく数えられます。一方、添字ループ(for (let i = 0; i < str.length; i++))はコード単位単位なので、サロゲートを割ります。
const s = "a😀b";
s.length; // 4(a=1, 😀=2, b=1)
[...s].length; // 3(コードポイント単位)
[...s]; // ["a", "😀", "b"]
正規表現は既定でコード単位ベースですが、u(Unicode)フラグを付けるとコードポイント単位でマッチします。/./u は絵文字1個に一致しますが、/./(uなし)は半分の上位サロゲートだけに一致します。\u{1F600} のようなコードポイント表記も u フラグ下でのみ有効です。文字単位で扱う正規表現には原則 u を付けます。
書記素クラスタ:コードポイントでも足りない
コードポイント単位でも、まだ「見た目の1文字」とは一致しません。複数のコードポイントが結合して1つの書記素クラスタを作るからです。代表例が次です。
- 結合文字:
e(U+0065)+ 結合アクセント(U+0301)でéに見えるが、コードポイントは2個。 - 絵文字のZWJシーケンス:👨👩👧 のような家族絵文字は、複数の絵文字をゼロ幅接合子(ZWJ, U+200D)で連結したもので、コードポイントは多数(この例では👨/ZWJ/👩/ZWJ/👧)。
- 肌色修飾子や国旗:絵文字に修飾コードポイントが続く構成。
これらは [...str].length でも「1」になりません。人が数える「文字数」に合わせるには、書記素境界を解決する Intl.Segmenter を使います。
const seg = new Intl.Segmenter("ja", { granularity: "grapheme" });
const s = "👨👩👧é";
[...s].length; // 6(コードポイント数:絵文字3+ZWJ2+é=1…ではなく構成依存)
[...seg.segment(s)].length; // 2(書記素:家族絵文字1+é1)
Intl.Segmenter は単語境界(word)や文境界(sentence)の分割も担い、書記素分割では国・言語ごとのUnicode規則に従って結合列を1単位にまとめます。「ユーザーに見せる文字数」「カーソル移動」「先頭N文字の切り出し」など、人の知覚に合わせる処理はこれが基準です。
"😀".length は 2(コード単位)、[..."😀"].length は 1(コードポイント)、é(結合文字版)の書記素数は 1(Intl.Segmenter)。資格・面接では「lengthは文字数ではなくUTF-16コード単位数」という一点と、コードポイント単位(反復子・uフラグ)と書記素単位(Intl.Segmenter)の住み分けが問われやすい点を押さえてください。
実務での落とし穴と対策
帰結を実務に落とすと、危険なのはコード単位境界で文字列を分割・切断する操作です。slice/substring/添字アクセスで「先頭N文字」を作ると、N文字目がサロゲートペアや書記素の途中だった場合に壊れます。対策は次の通りです。
- 表示用の切り詰めは書記素単位:
Intl.Segmenterで分割してからN個取り、結合し直す。コード単位のsliceを直接使わない。 - 文字数バリデーションは目的に合わせる:DBのバイト長制限なら符号化後のバイト数、ユーザー向け「N文字まで」なら書記素数で数える。
maxlength属性はコード単位基準である点に注意。 - 入力のサニタイズで孤立サロゲートを除く:外部入力に孤立サロゲートが混ざると、保存・転送時に U+FFFD へ化けたり比較が狂ったりする。
String.prototype.toWellFormed()(ES2024)で整形できる。 - 正規化を忘れない:
éは単一コードポイント(U+00E9)でも結合列でも表せる。比較・重複判定の前にstr.normalize("NFC")で正規化しないと、見た目が同じ文字列が不一致になる。
UTF-16の生バイト列を直接扱う場面や、サニタイズで境界を割って危険なマークアップが通る経路は、バイナリ表現の理解とも地続きです。バイト列としての扱いは TypedArray・ArrayBuffer・DataViewのメモリレイアウト、入力の浄化が破綻する経路は DOM-based XSSのsink/source分類 も合わせて見ると理解が深まります。
まとめ
JavaScriptの文字列はUTF-16のコード単位(16bit)の並びで、length・添字・charCodeAt はすべてコード単位を数えます。U+10000以上の文字は2つのコード単位=サロゲートペアで表されるため、length は文字数とずれ、添字スライスはペアを割って孤立サロゲートを生みます。コードポイント単位で正しく扱うには反復子(for...of/スプレッド/Array.from)や正規表現の u フラグを、人が見る「1文字」で数えるには結合列を1単位にまとめる Intl.Segmenter を使います。「lengthは文字数ではなくコード単位数」を起点に、コードポイントと書記素の住み分けを意識すれば、絵文字の文字数バグは推測でなく仕組みから防げます。数値の内部表現に興味があれば JavaScriptの数値表現とIEEE754 も同じ「内部表現の帰結」という観点でつながります。
Web/フロントエンド Article
テキストエンコーディングの内部(UTF-16のサロゲートとコードポイント)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
JavaScript
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
U+10000以上の文字(多くの絵文字や一部漢字)は2つのコード単位=サロゲートペアで表される。そのためlengthが2と数え、添字で半分だけ切ると壊れる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「JavaScript / Unicode」に近いか確認する。
- 強みである「JavaScriptの文字列はUTF-16のコード単位(16bit)の並びで、添字もlengthもコード単位を数える。文字(コードポイント)や見た目の1文字(書記素)とは一致しない。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。