JavaScriptの文字列はUTF-16で内部表現されており、絵文字や一部の漢字などサロゲートペアを含む文字は2つのコードユニットで1文字を表します。
通常のforループやcharAtではサロゲートペアが正しく処理されず、文字化けの原因になります。この記事では、for-of・スプレッド構文・Array.from・正規表現uフラグなど、サロゲートペアを正しく扱う方法を網羅的に解説します。
この記事で学べること
- サロゲートペアとは何か(UTF-16の仕組み)
- 通常のforループで文字化けする原因
- for-ofループで1文字ずつ正しく取得する方法
- スプレッド構文(
[...str])で文字配列に変換する方法
- Array.fromでサロゲートペア対応の配列を作る方法
- 正規表現のuフラグでUnicode文字にマッチさせる方法
- codePointAt / String.fromCodePointでコードポイントを扱う方法
- サロゲートペアを考慮した文字数カウントの実装
サロゲートペアとは
JavaScriptの文字列はUTF-16で内部表現されています。UTF-16では、U+0000〜U+FFFFの範囲(BMP: 基本多言語面)の文字は1つの16ビットコードユニットで表現できますが、それ以外の文字は2つのコードユニット(サロゲートペア)で表現されます。
| 文字 |
コードポイント |
UTF-16表現 |
length |
| A |
U+0041 |
0x0041(1ユニット) |
1 |
| あ |
U+3042 |
0x3042(1ユニット) |
1 |
| 😀 |
U+1F600 |
0xD83D 0xDE00(2ユニット) |
2 |
| 𠮷 |
U+20BB7 |
0xD842 0xDFB7(2ユニット) |
2 |
| 𩸽 |
U+29E3D |
0xD867 0xDE3D(2ユニット) |
2 |
注意:.lengthはコードユニット数を返すため、サロゲートペア文字は2とカウントされます。見た目の文字数とは一致しません。
通常のforループで文字化けする例
インデックスベースのforループやcharAtはコードユニット単位で処理するため、サロゲートペア文字が2つに分割されてしまいます。
forループでの問題(NG例)
const str = "A😀B";
console.log(str.length); // 4(見た目は3文字なのに4)
// forループ → サロゲートペアが分割される
for (let i = 0; i < str.length; i++) {
console.log(str[i]);
}
実行結果
A
ufffd ← サロゲートペアの前半
ufffd ← サロゲートペアの後半
B
絵文字😀がサロゲートペアの前半と後半に分割され、文字化けしています。
for-ofループで正しく1文字ずつ取得する
for-ofループはイテレータプロトコルに基づいて動作し、文字列をコードポイント単位で処理します。そのためサロゲートペアも1文字として正しく扱われます。
for-ofループ(OK)
const str = "A😀B";
for (const char of str) {
console.log(char);
}
実用例:サロゲートペア文字を含む文字列からフィルタリング
絵文字だけを抽出
const text = "Hello😀World🎉!";
const emojis = [];
for (const char of text) {
// コードポイントがBMP外(U+10000以上)なら絵文字の可能性
if (char.codePointAt(0) > 0xFFFF) {
emojis.push(char);
}
}
console.log(emojis);
スプレッド構文([…str])で文字配列に変換
スプレッド構文もイテレータを使用するため、サロゲートペアを正しく1文字として展開します。
スプレッド構文で文字配列化
const str = "𠮷野家";
// split("") → サロゲートペアが壊れる
console.log(str.split(""));
// ["\uD842", "\uDFB7", "野", "家"]
// [...str] → 正しく分割される
console.log([...str]);
// ["𠮷", "野", "家"]
ポイント:str.split("")の代わりに[...str]を使えば、サロゲートペアを壊さずに文字配列を作成できます。
文字列の反転
サロゲートペアを壊さず反転
const str = "Hello😀";
// NG: split("").reverse() → 絵文字が壊れる
const bad = str.split("").reverse().join("");
console.log(bad); // ??olleH(文字化け)
// OK: スプレッド構文で分割してから反転
const good = [...str].reverse().join("");
console.log(good); // 😀olleH
Array.fromで文字配列に変換
Array.fromもイテレータを使用するため、スプレッド構文と同様にサロゲートペアを正しく扱えます。第2引数にマッピング関数を渡せるのが特徴です。
Array.fromでの変換
const str = "𠮷野家";
// 文字配列に変換
const chars = Array.from(str);
console.log(chars); // ["𠮷", "野", "家"]
// マッピング関数付き:各文字のコードポイントを取得
const codePoints = Array.from(str, c => c.codePointAt(0).toString(16));
console.log(codePoints); // ["20bb7", "91ce", "5bb6"]
サロゲートペアを考慮した文字数カウント
.lengthはコードユニット数を返すため、サロゲートペアを含む文字列では見た目の文字数と一致しません。正しい文字数を取得する方法を紹介します。
文字数カウントの比較
const str = "Hello😀🎉";
// NG: lengthはコードユニット数
console.log(str.length); // 9(5 + 2 + 2)
// OK: スプレッド構文
console.log([...str].length); // 7
// OK: Array.from
console.log(Array.from(str).length); // 7
Intl.Segmenterで書記素クラスタ単位のカウント
絵文字の中には複数のコードポイントで1つの見た目を構成するもの(結合絵文字)があります。Intl.Segmenterを使えば、書記素クラスタ(人間が認識する1文字)単位で正確にカウントできます。
Intl.Segmenterで書記素クラスタ単位のカウント
const str = "👨👩👧👦Hello"; // 家族絵文字 + Hello
console.log(str.length); // 16(コードユニット数)
console.log([...str].length); // 12(コードポイント数)
// Intl.Segmenter → 見た目通りの文字数
const segmenter = new Intl.Segmenter("ja", { granularity: "grapheme" });
const segments = [...segmenter.segment(str)];
console.log(segments.length); // 6(👨👩👧👦 + H + e + l + l + o)
ポイント:SNSの文字数カウントなど、ユーザー体感と一致させたい場合はIntl.Segmenterが最も正確です。Chrome 87+、Firefox 104+、Safari 15.4+で対応しています。
正規表現のuフラグでUnicode対応
正規表現にuフラグ(Unicode対応フラグ)を付けると、サロゲートペアを1文字として扱えます。
uフラグの有無による違い
const str = "A😀B🎉C";
// uフラグなし → .はサロゲートペアの片方にマッチ
console.log(str.match(/./g));
// ["A", "\uD83D", "\uDE00", "B", "\uD83C", "\uDF89", "C"]
// uフラグあり → .がサロゲートペアも1文字として扱う
console.log(str.match(/./gu));
// ["A", "😀", "B", "🎉", "C"]
Unicodeプロパティエスケープ(\p{})
uフラグと組み合わせてUnicodeプロパティエスケープを使うと、文字の種類で絞り込めます。
Unicodeプロパティエスケープ
const str = "Hello😀 こんにちは🎉 123";
// 絵文字を抽出
console.log(str.match(/\p{Emoji_Presentation}/gu));
// ["😀", "🎉"]
// 漢字・ひらがな・カタカナを抽出
console.log(str.match(/\p{Script=Hiragana}/gu));
// ["こ", "ん", "に", "ち", "は"]
注意:\p{}はuフラグが必須です。uフラグなしで使用するとSyntaxErrorが発生します。
codePointAt / String.fromCodePoint
従来のcharCodeAt/fromCharCodeはコードユニット単位で動作しますが、codePointAtとString.fromCodePointはコードポイント単位で正しく動作します。
codePointAt vs charCodeAt
const str = "😀";
// charCodeAt → サロゲートペアの前半だけ
console.log(str.charCodeAt(0).toString(16)); // d83d
// codePointAt → 正しいコードポイント
console.log(str.codePointAt(0).toString(16)); // 1f600
String.fromCodePoint
// fromCharCode → サロゲートペアを手動で計算する必要がある
console.log(String.fromCharCode(0xD83D, 0xDE00)); // 😀
// fromCodePoint → コードポイントを直接指定
console.log(String.fromCodePoint(0x1F600)); // 😀
console.log(String.fromCodePoint(0x20BB7)); // 𠮷
サロゲートペア対応メソッドの比較
| 操作 |
NG(コードユニット単位) |
OK(コードポイント単位) |
| 1文字ずつ処理 |
for (let i=0; ...) |
for (const c of str) |
| 文字配列に変換 |
str.split("") |
[...str] / Array.from(str) |
| 文字数カウント |
str.length |
[...str].length |
| コードポイント取得 |
charCodeAt() |
codePointAt() |
| コードポイントから文字 |
String.fromCharCode() |
String.fromCodePoint() |
| 正規表現 |
/./g |
/./gu |
よくある問題と対策
| 問題 |
原因 |
対策 |
| 絵文字が文字化けする |
forループ/charAt/split(“”)がコードユニット単位 |
for-of / […str] / Array.from を使う |
| 文字数が多くカウントされる |
.lengthがコードユニット数を返す |
[…str].length / Intl.Segmenter |
| 正規表現で絵文字にマッチしない |
uフラグがない |
正規表現にuフラグを追加 |
| 文字列の反転で絵文字が壊れる |
split(“”).reverse()がサロゲートペアを分割 |
[…str].reverse().join(“”) |
| substring/sliceで文字が途中で切れる |
インデックスがコードユニット単位 |
[…str].slice()で操作後にjoin |
まとめ
| 用途 |
推奨方法 |
| 1文字ずつ処理する |
for-of ループ |
| 文字配列に変換する |
[…str] / Array.from(str) |
| 正しい文字数を取得する |
[…str].length |
| 見た目通りの文字数 |
Intl.Segmenter |
| 正規表現でUnicode対応 |
uフラグ + \p{} |
| コードポイントの取得・生成 |
codePointAt / String.fromCodePoint |
サロゲートペアの問題は、絵文字や特殊漢字を扱う場面で必ず遭遇します。基本ルールは「インデックスベースの操作を避け、イテレータベースの操作を使う」こと。for-of・[...str]・Array.fromを覚えておけば、ほとんどのケースに対応できます。
関連記事