文字をコード値に変換する charCodeAt()codePointAt() の違いがよく分かってませんでした。「codePointAt() 使ったほうがいいらしい」程度の認識だったので、ちゃんと調べてみました。


例として『ABあい🍅🐤!』という文字列があるとします。文字数は見ての通り7文字です。

この文字列を length ぶんループ処理しながら、ひとつずつ charCodeAtcodePointAt に通してみます。

var str = 'ABあい🍅🐤!';
var codes = [];
var points = [];
for (var i = 0; i < str.length; ++i) {
	codes.push(str.charCodeAt(i));
	points.push(str.codePointAt(i));
}
console.log('charCodeAt => ', codes);   // -> [65, 66, 12354, 12356,  55356, 57157,  55357, 56356, 33]
console.log('codePointAt => ', points); // -> [65, 66, 12354, 12356, 127813, 57157, 128036, 56356, 33]

charCodeAtcodePointAt で結果が異なってます。しかし結果の要素数から、実際の str.length は9であったことが分かります。

なぜこうなるのか、無い頭しぼって図を描いてみました。上のコードの結果と見比べてみてください。

『コード値』と言ってるものの内訳
『コード値』と言ってるものの内訳

そもそも……。

  • charCodeAt が返却するのはUTF-16コードでした。
  • UTF-16コードは、ある文字列をUTF-16で表現した場合のコード値です。UTF-16は、16ビットのコードを使ってUnicode文字を表現するエンコード方式です。
  • UTF-16コードは 0 ~ 65,535 (0x0 ~ 0xFFFF) の範囲をとります。

一方で……。

  • codePointAt が返却するのはUnicodeコードポイントでした。
  • Unicodeコードポイントは、純粋にUnicodeで表現できる文字ひとつひとつに振られている番号です。
  • Unicodeコードポイントは 0 ~ ‭1,114,111‬ (0x0 ~ 0x10FFFF‭) の範囲をとります。
  • Unicodeコードポイントの 0 ~ 65,535 は、UTF-16コードと同じ文字を指してます。

すると、UTF-16コードが65,536個しかないのに対して、Unicodeで表現したい文字は1,114,112個もあることになります。なので実際には、ひとつのUTF-16コードで表現できない文字については、UTF-16コードを2つ使うことで表現してるそうです。いわゆるサロゲートペアです。

先ほどの図でサロゲートペアを説明したのが次の図です。サロゲートペア的にはコード値が16進数のほうがキリがいいので、こちらは16進表記です。

サロゲートペア
サロゲートペア

このあたり全般の理解については、下記のサイト様が大変参考になりました。

で、JavaScriptの内部的には、文字列はUTF-16で扱われてるらしいです。これは、元を辿っていったらECMAScriptの仕様書に書いてありました。

そのために、文字列の length = UTF-16コードの数となるようです。上述の str.length が9だったのは、🍅と🐤がサロゲートペアを使わないと表現できない = UTF-16コードが2つ必要な文字だったせいです。(※ここでは絵文字しか使ってませんが、絵文字以外にも漢字とか色々あります。)

そして最初のコードの実行結果からわかる通り、charCodeAtcodePointAt の引数に指定する『インデックス』は、文字列をUTF-16コードで表現した場合のインデックス番号のことを指してます。i = 5i = 7 のときは、サロゲートペア文字の下位16ビットだけを指してるせいで、そこから下だけの中途半端な結果が出てました。

なんかわかってきた気がします。試しに String.fromCodePoint(65536) で生成した文字の length を見たら、そこから急に 2 になってました。この文字を charCodeAt で扱うには、2桁ぶん見ないと不正確になるんですね。

なお、上記の文字列を本当に7文字として処理するにはどうすればいいのか? というのについては、ネットで調べると詳しい記事がいっぱい出てくるので、ここではやめときます。

var chars = 'ABあい🍅🐤!'.match(/[\uD800-\uDBFF][\uDC00-\uDFFF]|[\s\S]/g) || [];
var points = [];
for (var i = 0; i < chars.length; ++i) {
	points.push(chars[i].codePointAt(0));
}
console.log('codePointAt => ', points); // -> [65, 66, 12354, 12356, 127813, 128036, 33]