【JavaScript】サロゲートペア文字列を正しく扱う方法|for-of・スプレッド構文・Array.from・正規表現uフラグ

【JavaScript】サロゲートペア文字列を正しく扱う方法|for-of・スプレッド構文・Array.from・正規表現uフラグ JavaScript

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);
}

実行結果

A
😀
B

実用例:サロゲートペア文字を含む文字列からフィルタリング

絵文字だけを抽出
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はコードユニット単位で動作しますが、codePointAtString.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を覚えておけば、ほとんどのケースに対応できます。

関連記事