【JavaScript】サロゲートペアに対応したUnicodeコードポイント取得|codePointAt・charCodeAtの違いと実務対策

【JavaScript】サロゲートペアに対応したUnicodeコードポイント取得|codePointAt・charCodeAtの違いと実務対策 JavaScript

JavaScriptの文字列は内部的にUTF-16でエンコードされており、絵文字や一部の漢字はサロゲートペア(2つのコードユニット)で表現されます。従来の charCodeAt() ではこれらの文字を正しく扱えませんが、codePointAt() を使えば正確なUnicodeコードポイントを取得できます。

この記事では、codePointAt() の基本から charCodeAt() との違い、サロゲートペアの仕組み、絵文字・結合文字の実務的な処理パターンまで体系的に解説します。

スポンサーリンク

codePointAt() の基本構文

codePointAt() は文字列の指定位置にある文字のUnicodeコードポイントを返すメソッドです(ES2015で追加)。

// 構文
string.codePointAt(index)

// 基本的な使用例
const str = 'ABC';
console.log(str.codePointAt(0)); // 65("A" のコードポイント)
console.log(str.codePointAt(1)); // 66("B" のコードポイント)
console.log(str.codePointAt(2)); // 67("C" のコードポイント)

// サロゲートペア文字
const emoji = '?';
console.log(emoji.codePointAt(0)); // 127881(U+1F389 正しいコードポイント)

// 範囲外
console.log(str.codePointAt(99)); // undefined

? ポイント

codePointAt() はサロゲートペアを正しく解釈し、完全なUnicodeコードポイント(0〜1,114,111)を返します。通常のBMP文字では charCodeAt() と同じ値を返します。

サロゲートペアとは? UTF-16の仕組み

Unicodeには100万以上の文字が定義されていますが、JavaScriptの文字列はUTF-16(16ビット=65,536通り)でエンコードされています。65,536に収まらない文字は、2つのコードユニットを組み合わせたサロゲートペアで表現されます。

用語 説明 範囲
BMP(基本多言語面) 1つのコードユニットで表現できる文字 U+0000U+FFFF
追加面(SMP等) サロゲートペアが必要な文字 U+10000U+10FFFF
上位サロゲート ペアの前半部分 U+D800U+DBFF
下位サロゲート ペアの後半部分 U+DC00U+DFFF
// BMP文字の例(1コードユニット)
const a = 'あ';       // U+3042
console.log(a.length);  // 1

// サロゲートペア文字の例(2コードユニット)
const tsuchi = '?';   // U+20BB7
console.log(tsuchi.length);  // 2(見た目は1文字だが内部的には2つ)

// 絵文字もサロゲートペア
const fire = '?';
console.log(fire.length);    // 2

// 内部的なUTF-16の表現を確認
console.log(tsuchi.charCodeAt(0).toString(16)); // "d842"(上位サロゲート)
console.log(tsuchi.charCodeAt(1).toString(16)); // "dfb7"(下位サロゲート)

サロゲートペアのコードポイント計算

サロゲートペアからコードポイントへの変換は以下の式で計算されます。codePointAt() はこの計算を自動的に行います。

// サロゲートペア → コードポイントの計算式
// codePoint = (上位 - 0xD800) × 0x400 + (下位 - 0xDC00) + 0x10000

const high = 0xD842;  // 「?」の上位サロゲート
const low  = 0xDFB7;  // 「?」の下位サロゲート

const codePoint = (high - 0xD800) * 0x400 + (low - 0xDC00) + 0x10000;
console.log(codePoint);           // 134071
console.log(codePoint.toString(16)); // "20bb7"

// codePointAt() を使えば自動計算される
console.log('?'.codePointAt(0));           // 134071
console.log('?'.codePointAt(0).toString(16)); // "20bb7"

charCodeAt() と codePointAt() の違い

charCodeAt()codePointAt() はどちらも文字コードを返しますが、サロゲートペア文字での挙動が大きく異なります。

比較項目 charCodeAt() codePointAt()
戻り値の範囲 0〜65,535 0〜1,114,111
処理単位 UTF-16コードユニット Unicodeコードポイント
サロゲートペア対応 片方のサロゲートのみ 正しいコードポイント
BMP文字での結果 同じ値 同じ値
範囲外の戻り値 NaN undefined
導入時期 ES1(1997年〜) ES2015(2015年〜)
逆変換メソッド String.fromCharCode() String.fromCodePoint()

具体的な数値の違い

BMP内の文字では両者は同じ値を返しますが、サロゲートペア文字では結果が大きく異なります。

文字 Unicode charCodeAt(0) codePointAt(0) length
A U+0041 65 65 1
U+3042 12354 12354 1
? U+20BB7 55362 134071 2
? U+1F389 55356 127881 2
? U+1F525 55357 128293 2
?
(ほっけ)
U+29E3D 55399 171581 2

コードで比較してみる

const str = '?野家';

// charCodeAt() は上位サロゲートのみを返す
console.log(str.charCodeAt(0));  // 55362(0xD842 上位サロゲート)
console.log(str.charCodeAt(1));  // 57271(0xDFB7 下位サロゲート)
console.log(str.charCodeAt(2));  // 37326("野" 通常の文字)

// codePointAt() は正しいコードポイントを返す
console.log(str.codePointAt(0)); // 134071(0x20BB7 正しいコードポイント)
console.log(str.codePointAt(1)); // 57271(下位サロゲートの値)
console.log(str.codePointAt(2)); // 37326("野" 通常の文字)

⚠️ 注意: codePointAt() のインデックスの罠

codePointAt(1) はサロゲートペアの「下位サロゲートの位置」を指すため、下位サロゲートの値が返ります。文字単位で正しくイテレートするには for...of を使いましょう。

// ❌ for文 + codePointAt: インデックスが正しく進まない
const str = '?野';
for (let i = 0; i < str.length; i++) {
  console.log(i, str.codePointAt(i));
}
// 0  134071  ← 正しい「?」のコードポイント
// 1  57271   ← 下位サロゲートの値(意味のない値)
// 2  37326   ← 「野」のコードポイント

// ✅ for文で使う場合はサロゲートペアを手動でスキップ
for (let i = 0; i < str.length; ) {
  const cp = str.codePointAt(i);
  console.log(i, cp, String.fromCodePoint(cp));
  i += cp > 0xFFFF ? 2 : 1; // サロゲートペアなら2つ進む
}
// 0  134071  "?"
// 2  37326   "野"

String.fromCodePoint() で文字に変換

codePointAt() の逆操作として、コードポイントから文字を生成する String.fromCodePoint() があります。従来の String.fromCharCode() と比較してみましょう。

// BMP文字: どちらも同じ結果
console.log(String.fromCharCode(65));     // "A"
console.log(String.fromCodePoint(65));    // "A"

// サロゲートペア文字: fromCharCode は正しく変換できない
console.log(String.fromCharCode(134071));  // "ꮷ"(間違った文字)
console.log(String.fromCodePoint(134071)); // "?"(正しい文字)

// fromCharCode でサロゲートペアを正しく変換するにはペアを渡す必要がある
console.log(String.fromCharCode(0xD842, 0xDFB7)); // "?"(ペアで渡せば正しい)

// fromCodePoint は複数のコードポイントをまとめて変換可能
console.log(String.fromCodePoint(134071, 37326, 23478));
// "?野家"

コードポイントの16進数表記

// コードポイント → 16進数表記(U+XXXX 形式)
function toUnicodeNotation(char) {
  const cp = char.codePointAt(0);
  return 'U+' + cp.toString(16).toUpperCase().padStart(4, '0');
}

console.log(toUnicodeNotation('A'));  // "U+0041"
console.log(toUnicodeNotation('あ')); // "U+3042"
console.log(toUnicodeNotation('?')); // "U+20BB7"
console.log(toUnicodeNotation('?')); // "U+1F389"

// Unicodeエスケープシーケンス(u{XXXX})
console.log('u{20BB7}');  // "?"(ES2015のUnicodeエスケープ)
console.log('u{1F389}');  // "?"
console.log('u{3042}');   // "あ"

サロゲートペア文字を正しく扱う方法

文字列を1文字ずつ処理する場合、通常の for ループや charAt() ではサロゲートペアが壊れます。以下の方法を使いましょう。

方法1: for…of ループ

for...of はUnicodeのコードポイント単位でイテレートするため、サロゲートペアを正しく処理できます。

const str = '?野家?';

// ❌ for ループ: サロゲートペアが壊れる
for (let i = 0; i < str.length; i++) {
  console.log(str[i]);
}
// "ud842" "udfb7" "野" "家" "ud83c" "udf5c"(6回、文字化け)

// ✅ for...of: コードポイント単位で正しくイテレート
for (const char of str) {
  console.log(char, char.codePointAt(0));
}
// "?" 134071
// "野" 37326
// "家" 23478
// "?" 127836

方法2: スプレッド構文 / Array.from()

const str = 'Hello?World';

// スプレッド構文でコードポイント単位の配列に変換
const chars = [...str];
console.log(chars);        // ["H","e","l","l","o","?","W","o","r","l","d"]
console.log(chars.length); // 11(正しい文字数)
console.log(str.length);  // 12(UTF-16コードユニット数)

// Array.from() でも同様
const chars2 = Array.from(str);
console.log(chars2.length); // 11

// 各文字のコードポイントを取得
const codePoints = [...str].map(c => c.codePointAt(0));
console.log(codePoints);
// [72, 101, 108, 108, 111, 127757, 87, 111, 114, 108, 100]

方法3: 正規表現の u フラグ

正規表現に u(Unicode)フラグを付けると、サロゲートペアを1文字として扱えます。

const str = 'ABC?DEF';

// u フラグなし: . はサロゲートペアの半分にマッチ
console.log(str.match(/./g).length);  // 8(絵文字が2文字扱い)

// u フラグあり: . はコードポイント単位にマッチ
console.log(str.match(/./gu).length); // 7(正しい文字数)

// Unicodeプロパティエスケープ(p{...})も使える
const emojis = 'Hello?World?!'.match(/p{Emoji_Presentation}/gu);
console.log(emojis); // ["?", "?"]

// 漢字(CJK統合漢字拡張Bなど)にマッチ
const kanji = '?野家'.match(/p{Script=Han}/gu);
console.log(kanji); // ["?", "野", "家"]

// ひらがな・カタカナにもマッチ可能
const text = 'Hello こんにちは カタカナ 漢字';
console.log(text.match(/p{Script=Hiragana}+/gu));  // ["こんにちは"]
console.log(text.match(/p{Script=Katakana}+/gu));  // ["カタカナ"]

Intl.Segmenter で書記素クラスタ単位の処理

スプレッド構文や for...of はサロゲートペアには対応しますが、ZWJシーケンス(Zero Width Joiner)で結合された複合絵文字や結合文字は正しく扱えません。「見た目通りの1文字」で処理するには Intl.Segmenter を使います。

// 複合絵文字(ZWJシーケンス)の例
const family = '?‍?‍?‍?';

console.log(family.length);     // 11(UTF-16コードユニット数)
console.log([...family].length); // 7(コードポイント数: ? + ZWJ + ? + ZWJ + ? + ZWJ + ?)

// Intl.Segmenter で書記素クラスタ単位に分割
const segmenter = new Intl.Segmenter('ja', { granularity: 'grapheme' });
const segments = [...segmenter.segment(family)];
console.log(segments.length);              // 1(正しく1文字)
console.log(segments[0].segment);           // "?‍?‍?‍?"
// さまざまな複合文字の例
const segmenter = new Intl.Segmenter('ja', { granularity: 'grapheme' });

// 国旗絵文字(Regional Indicator)
const flag = '??';
console.log([...flag].length);                             // 2(2つのRegional Indicator)
console.log([...segmenter.segment(flag)].length);         // 1(正しく1文字)

// 肌色修飾子付き絵文字
const wave = '??';
console.log([...wave].length);                             // 2(絵文字 + 修飾子)
console.log([...segmenter.segment(wave)].length);         // 1(正しく1文字)

// 結合文字(アクセント記号)
const cafe = 'café';  // "e" + 結合アクセント(U+0301)
console.log([...cafe].length);                             // 5(e と ́ が別々にカウント)
console.log([...segmenter.segment(cafe)].length);         // 4(正しく4文字)

文字の粒度まとめ

入力文字列 .length […str].length Segmenter 見た目
"Hello" 5 5 5 5文字
"?野家" 4 3 3 3文字
"?‍?‍?‍?" 11 7 1 1文字
"??" 4 2 1 1文字
"café"(結合文字) 5 5 4 4文字

? 使い分けの指針

  • ASCII文字のみ.length で十分
  • 絵文字・特殊漢字を含む[...str].length または for...of
  • 複合絵文字・結合文字を含むIntl.Segmenter を使用

Unicode正規化(NFC / NFD)

同じ見た目の文字でも、Unicodeでは複数の表現方法がある場合があります。たとえば「が」は「合成済み文字(1コードポイント)」と「か + 結合濁点(2コードポイント)」の2通りで表現できます。String.prototype.normalize() で正規化しましょう。

// NFC: 合成済み形式(推奨)
const nfc = 'u304C';            // "が"(合成済み、1コードポイント)
// NFD: 分解形式
const nfd = 'u304Bu3099';       // "が"(か + 結合濁点、2コードポイント)

// 見た目は同じだが、内部表現が違う
console.log(nfc === nfd);            // false !
console.log(nfc.length);             // 1
console.log(nfd.length);             // 2

// normalize() で正規化すれば一致する
console.log(nfc.normalize('NFC') === nfd.normalize('NFC')); // true

// 実務: 文字列比較の前に正規化する
function safeCompare(a, b) {
  return a.normalize('NFC') === b.normalize('NFC');
}

console.log(safeCompare(nfc, nfd)); // true

? 正規化形式の使い分け

  • NFC(推奨): 合成済み形式に正規化。Webで最も一般的
  • NFD: 分解形式に正規化。文字の構成要素を分析する場合に有用
  • NFKC / NFKD: 互換正規化。全角英数→半角英数のような変換も含む

実務で使えるユーティリティ関数

サロゲートペア対応の文字数カウント

// コードポイント単位の文字数を取得
function countCodePoints(str) {
  return [...str].length;
}

// 書記素クラスタ単位の文字数を取得(最も正確)
function countGraphemes(str) {
  const segmenter = new Intl.Segmenter('ja', { granularity: 'grapheme' });
  return [...segmenter.segment(str)].length;
}

const text = 'Hello?‍?‍?‍???';
console.log(text.length);             // 20(UTF-16)
console.log(countCodePoints(text));   // 14(コードポイント)
console.log(countGraphemes(text));    // 7(見た目の文字数)

サロゲートペア判定関数

// 文字がサロゲートペアかどうかを判定
function isSurrogatePair(char) {
  return char.length === 2 && char.codePointAt(0) > 0xFFFF;
}

// 文字列にサロゲートペアが含まれるかを判定
function containsSurrogatePair(str) {
  return str.length !== [...str].length;
}

console.log(isSurrogatePair('?'));  // true
console.log(isSurrogatePair('あ'));  // false
console.log(containsSurrogatePair('Hello?'));  // true
console.log(containsSurrogatePair('Hello'));   // false

孤立サロゲートの検出・除去

外部データやAPIレスポンスに、ペアが揃っていない孤立サロゲート(lone surrogate)が含まれることがあります。これを検出・除去する関数です。

// 孤立サロゲートが含まれるか判定
function hasLoneSurrogate(str) {
  for (let i = 0; i < str.length; i++) {
    const code = str.charCodeAt(i);
    if (code >= 0xD800 && code <= 0xDBFF) {
      // 上位サロゲート: 次が下位サロゲートでなければ孤立
      const next = str.charCodeAt(i + 1);
      if (!(next >= 0xDC00 && next <= 0xDFFF)) return true;
      i++; // ペアをスキップ
    } else if (code >= 0xDC00 && code <= 0xDFFF) {
      return true; // 下位サロゲートが単独で出現
    }
  }
  return false;
}

// 孤立サロゲートを除去してクリーンな文字列を返す
function removeLoneSurrogates(str) {
  return str.replace(/[uD800-uDBFF](?![uDC00-uDFFF])|(?<![uD800-uDBFF])[uDC00-uDFFF]/g, '');
}

文字列の安全なスライス

// サロゲートペア対応の安全なスライス
function safeSlice(str, start, end) {
  const chars = [...str];
  return chars.slice(start, end).join('');
}

const str = '?野家?定食';

// ❌ 通常の slice: サロゲートペアが壊れる
console.log(str.slice(0, 3));       // "ud842udfb7野"(文字化け)

// ✅ safeSlice: 正しくスライス
console.log(safeSlice(str, 0, 3));  // "?野家"
console.log(safeSlice(str, 3));     // "?定食"

文字列の安全な切り詰め(truncate)

// サロゲートペア対応の文字列切り詰め
function truncate(str, maxLength, suffix = '...') {
  const chars = [...str];
  if (chars.length <= maxLength) return str;
  return chars.slice(0, maxLength).join('') + suffix;
}

console.log(truncate('こんにちは?世界!', 5));
// "こんにちは..."

console.log(truncate('Hello?World?!', 7));
// "Hello?W..."

全コードポイントの取得とUnicode情報表示

// 文字列の各文字のUnicode情報を表示
function analyzeString(str) {
  const result = [];
  for (const char of str) {
    const cp = char.codePointAt(0);
    result.push({
      char,
      codePoint: cp,
      hex: 'U+' + cp.toString(16).toUpperCase().padStart(4, '0'),
      isSurrogate: cp > 0xFFFF,
      utf16Units: char.length,
    });
  }
  return result;
}

console.table(analyzeString('?野家?'));
// ┌─────────┬──────┬───────────┬─────────┬─────────────┬────────────┐
// │ (index) │ char │ codePoint │ hex     │ isSurrogate │ utf16Units │
// ├─────────┼──────┼───────────┼─────────┼─────────────┼────────────┤
// │    0    │ '?'│  134071   │'U+20BB7'│    true     │     2      │
// │    1    │ '野'│   37326   │'U+91CE'│    false    │     1      │
// │    2    │ '家'│   23478   │'U+5BB6'│    false    │     1      │
// │    3    │ '?'│  127836   │'U+1F35C'│   true     │     2      │
// └─────────┴──────┴───────────┴─────────┴─────────────┴────────────┘

よくあるエラーと注意点

問題 原因 解決策
絵文字の文字数が多くカウントされる .length はUTF-16コードユニット数を返す [...str].length または Intl.Segmenter
charCodeAt() が意味不明な数値を返す サロゲートペアの上位/下位のみを返している codePointAt() に置き換える
codePointAt(1) が想定と違う値 インデックスがUTF-16コードユニット単位 for...of で文字ごとにイテレートする
String.fromCharCode() で文字化け BMP外のコードポイントを渡している String.fromCodePoint() に置き換える
slice() で絵文字が壊れる slice() はUTF-16コードユニット単位 [...str].slice().join('') で安全にスライス
正規表現が絵文字に正しくマッチしない u フラグがない 正規表現に u フラグを追加する
国旗絵文字や家族絵文字がバラバラになる ZWJシーケンスはコードポイント単位でも分解される Intl.Segmenter で書記素クラスタ単位に処理

まとめ

やりたいこと おすすめの方法
文字のコードポイントを取得 codePointAt()(サロゲートペア対応)
コードポイントから文字を生成 String.fromCodePoint()
文字列を1文字ずつ処理 for...of または [...str]
正しい文字数をカウント [...str].length(コードポイント単位)
絵文字を正規表現で検出 /p{Emoji_Presentation}/gu
複合絵文字を見た目通りに処理 Intl.Segmenter(書記素クラスタ単位)

サロゲートペア文字を正しく扱うには、codePointAt()String.fromCodePoint() のペアが基本です。文字列の走査には for...of、正規表現には u フラグ、複合絵文字には Intl.Segmenter を使いましょう。charCodeAt() / String.fromCharCode() はBMP内の文字にのみ安全に使えます。

関連記事