JavaScriptと文字コード そして Unicodeとサロゲートペアの扱い

《 初回公開:2023/01/26 , 最終更新:未 》

この記事はUnicodeとサロゲートペアについての知識が必要で、これについては「文字コードについての予備知識 - ユニコード編 - 愚鈍人」をご一読下さい。

javascript string

【 目次 】

コードユニット - Code Unit(符号単位)

Unicodeでは1文字を表すのに使う最小限のビットの組み合わせをコードユニットという呼び方をする事がある。

コードポイントが2byteの32bit単位であるのに対して、
JavaScriptでは文字列の内部表現はUTF-16で、コードユニットは16bit単位という事になる。
16bit以下のコードポイントの値は基本的にはコードユニットの値と同じで(サロゲートペアに使われるD800~DFFFの範囲を除いて)、
16bit以上(10000~10FFFF)のコードポイントはサロゲートペアに変換されて2個のコードユニットで表現される事になる。

JavaScriptのメソッドでは、文字列をコードユニット単位で扱うStringクラスのメソッド多く存在していて、これがサロゲートペアの16bitx2の文字を扱う場合に予想外の結果を引き起こす事になってしまう

Stringクラスの文字コードに関するメソッド

StringクラスにはcharAtメソッドなどのatの付くメソッドがいくつか存在しているがそれぞれのatメソッドにはどんな違いがあるのだろうか?
また文字コードから文字列への変換をおこなうfromCodePointやfromCharCodeとは。

  • String.prototype.charAt
  • String.prototype.at
  • String.prototype.charCodeAt
  • String.prototype.codePointAt
  • String.fromCodePoint
  • String.fromCharCode

いろいろな文字列をブラケット記法を使って1文字ずつ取り出す

JavaScriptで文字列を1文字ずつ取り出すためによく使われるであろうブラケット記法のコード。
半角英数カタカナ漢字そしてこんな漢字あったのという見かけない漢字を最後に追加していろいろな文字で試してみる。

const str = "1a#アあア漢β𠀘";

console.log("------- 一文字づつ出力");
for (let i = 0; i < str.length; i++) {
    console.log(`${i}\t${str[i]}`);
}
console.log(`文字数は${str.length}`);

実行結果

------- 一文字づつ出力
0   1
1   a
2   #
3   ア
4   あ
5   ア
6   漢
7   β
8   �
9   �
文字数は10

最後の「𠀘」の1文字が文字化けして2文字で表示されている。
また、文字数が9文字であるのに関わらずstr.lengthの値は10文字となってしまう。

これは、最後の「𠀘」の1文字が16bit以上のコードポイントでサロゲートペアに変換されて2個のコードユニットで表現される事による。
従って、サロゲートペアの文字はブラケット記法を使って処理をすると不可思議な結果となってしまう。

String.prototype.charAtメソッド

String オブジェクトの charAt() メソッドは、文字列の中の指定された位置にある単一の UTF-16 コードユニットからなる新しい文字列を返します。

for (let i = 0; i < str.length; i++) {
    console.log(`${i}\t${str.charAt(i)}`);
}

charAtメソッドもブラケット記法と同じようにコードユニット単位で文字を一文字づつ取り出す。
上記コードの実行結果はブラケット記法の時と同じで最後の「𠀘」の文字が文字化けしてしまう。

String.prototype.atメソッド

at() メソッドは整数値を取り、指定されたオフセットにある単一の UTF-16 コード単位で構成される新しい文字列を返します。
この方法では、正と負の整数を使用できます。
負の整数は、最後の文字列の文字から逆に数えます。

for (let i = 0; i < str.length; i++) {
    console.log(`${i}\t${str.at(i)}`);
}

atメソッドもcharAtメソッドやブラケット記法と同じようにコードユニット単位で文字を一文字づつ取り出す。
ではatメソッドとcharAtメソッドの違いは何かというとatメソッドは引数に負の整数を使用できる事。
atメソッドは引数に-1を指定すると文字列の最後の文字を返す。

console.log(`最後の文字: ${"xyz".at(-1)}`);  // z

ブラケット記法と同様にcharAtメソッドやatメソッドも文字列から1文字づつ文字を取り出す事ができるが、サロゲートペアの文字を扱うには不向き。
サロゲートペアの文字を扱う方法については後述

String.prototype.charCodeAtメソッド

charCodeAt() メソッドは、指定された位置にある UTF-16 コードユニットを表す 0 から 65535 までの整数を返します。
...
単一の UTF-16 コードユニットで表現可能なコードポイントであれば、 UTF-16 コードユニットは Unicode コードポイントと一致します。
Unicode コードポイントが単一の UTF-16 コードユニットで表現できない場合 (値が 0xFFFF を超える場合)、返されるコードユニットはそのコードポイントのサロゲートペアの最初の部分になります。
コードポイント値全体を取得したい場合は、 codePointAt() を使用してください。

for (let i = 0; i < str.length; i++) {
    console.log(`${i}\t${str.charCodeAt(i).toString(16)}`);
}

実行結果

0   31
1   61
2   23
3   ff71
4   3042
5   30a2
6   6f22
7   3b2
8   d840
9   dc18

charCodeAtメソッドはUTF-16のコードユニットつまり0x0000~0xffffまでのコードポイントであればその文字位置のコードポイントを返す。
0x10000以上のコードポイントの場合は、コードユニットはそのコードポイントのサロゲートペアの値(16bit x 2)に変換されている。

ターゲットの文字列strは"1a#アあア漢β𠀘"の最後の文字「𠀘」以外は0x0000~0xffffまでのコードポイントにおさまっているのでコードユニットとしてコードポイントの値が出力される。
最初の3文字「1a#」はasciiコードに含まれる文字列なので0x00~0x7fまでの数値となる。
最後の文字「𠀘」のコードポイントは0x20018で16bitを超える値なのでサロゲートペアに変換されたd840, dc18のコードユニットの値が得られる事となる。

String.prototype.codePointAtメソッド

codePointAt() メソッドは、 Unicode コードポイント値である負ではない整数を返します。
...
指定された位置に要素が存在しない場合は undefined を返します。 pos の位置から UTF-16 サロゲートペアが始まらない場合は、 pos の位置のコードユニットを返します。

for (let i = 0; i < str.length; i++) {
    console.log(`${i}\t${str.codePointAt(i).toString(16)}`);
}

実行結果

0   31
1   61
2   23
3   ff71
4   3042
5   30a2
6   6f22
7   3b2
8   20018
9   dc18

ターゲットの文字列strは"1a#アあア漢β𠀘"の最後の文字「𠀘」以外は0x0000~0xffffまでのコードポイントにおさまっているので0~7までの8文字についてはcodePointAtメソッドと同じ値が出力される。
最後の文字「𠀘」についてはのコードポイントの値0x20018と、注目すべき点としてstr.lengthによって得られた1文字余った分はサロゲートペアの下位16bitの値がdc18の値が得られる事となる。

String.fromCodePoint()メソッド

String.fromCodePoint() 静的メソッドは指定されたコードポイントのシーケンスを使って生成された文字列を返します。

String.fromCodePointメソッドはString.prototype.codePointAtメソッドとペアとなるメソッドで、codePointAtメソッドとは逆にコードポイントから文字を生成する。

const codePointAry = [0x31, 0x61, 0x23, 0xff71, 0x3042, 0x30a2, 0x6f22, 0x3b2, 0x20018];
let str = "";
for (let i = 0; i < codePointAry.length; i++) {
    str += String.fromCodePoint(codePointAry[i])
}
console.log(str);   // 1a#アあア漢β𠀘

String.fromCharCode()メソッド

String.fromCharCode() 静的メソッドは、指定された UTF-16 コードユニットの並びから生成された文字列を返します。

String.fromCharCodeメソッドはString.prototype.charCodeAtメソッドとペアとなるメソッドで、codePointAtメソッドがコードポイントから文字を生成するのに対して、charCodeAtメソッドはコードユニットから文字を生成する。

const codeUnitAry = [0x31, 0x61, 0x23, 0xff71, 0x3042, 0x30a2, 0x6f22, 0x3b2, 0xd840, 0xdc18];
let str = "";
for (let i = 0; i < codeUnitAry.length; i++) {
    str += String.fromCharCode(codeUnitAry[i])
}
console.log(str);   // 1a#アあア漢β𠀘

サロゲートペアの文字を考慮すると

これまで観てきたようにコードポイント0x10000以上の文字はサロゲートペアに変換されてstr.lengtプロパティでは正しい文字長が得られず予想外の結果になってしまう。
サロゲートペアを考慮してこれまでのfor文のコードを無理やりに整合性がとれるように修正してみたのが以下のコード。

let strLength = 0;
for (let i = 0; i < str.length; i++) {
    let codePoint = str.codePointAt(i);
    if (codePoint < 0x10000)
        console.log(`${i}\t${str.charAt(i)}\tcodePoint: ${codePoint.toString(16)}`);
    else {
        console.log(`${i}\t${String.fromCodePoint(codePoint)}\tcodePoint: ${codePoint.toString(16)}(サロゲートペア: ${str.charCodeAt(i).toString(16)}, ${str.charCodeAt(i + 1).toString(16)})`);
        i++;
    }
    strLength++;
}
console.log(`文字数: ${strLength}`);

実行結果

0   1   codePoint: 31
1   a   codePoint: 61
2   #   codePoint: 23
3   ア   codePoint: ff71
4   あ   codePoint: 3042
5   ア   codePoint: 30a2
6   漢   codePoint: 6f22
7   β   codePoint: 3b2
8   𠀘   codePoint: 20018(サロゲートペア: d840, dc18)
文字数: 9

配列,反復子 (iterator) プロトコルを使って1文字づつ文字を取り出す

これまで観てきたように、サロゲートペアを含む文字列では文字列のlengthプロパティは実際の文字列の長さより大きくなってしまう。
そして、文字列のブラケット記法やString.prototype.charAtメソッド,String.prototype.atメソッドでは1文字づつ文字を取り出す事ができない。

しかし、配列,反復子 (iterator) プロトコルを使うとサロゲートペアを含む文字列でも容易に文字列を1文字づつ取り出す事ができる。

for...of文を利用する

for...of文を使うと簡単にサロゲートペアを含む文字列であっても簡単に文字列から1文字づつ文字を取り出す事ができる。

let i = 0;
for (const char of str) {
    console.log(`${i}\t${char}\tcodePoint: ${char.codePointAt(0).toString(16)}`);
    i++;
}
console.log(`文字数: ${i}`);

実行結果

0   1   codePoint: 31
1   a   codePoint: 61
2   #   codePoint: 23
3   ア   codePoint: ff71
4   あ   codePoint: 3042
5   ア   codePoint: 30a2
6   漢   codePoint: 6f22
7   β   codePoint: 3b2
8   𠀘   codePoint: 20018
文字数: 9

Array.fromメソッド

Array.fromメソッドを使う事でサロゲートペアを含む文字列を1文字づつの配列に変換できる。

Array.from(str, char => {
    console.log(`${char}\tcodePoint: ${char.codePointAt(0).toString(16)}`);
});
Array.from(str).forEach(char => {
    console.log(`${char}\tcodePoint: ${char.codePointAt(0).toString(16)}`);
});
Array.from(str).map(char => {
    console.log(`${char}\tcodePoint: ${char.codePointAt(0).toString(16)}`);
});

上記のコードはいずれも同じ結果を返す。

実行結果

1   codePoint: 31
a   codePoint: 61
#   codePoint: 23
ア   codePoint: ff71
あ   codePoint: 3042
ア   codePoint: 30a2
漢   codePoint: 6f22
β   codePoint: 3b2
𠀘   codePoint: 20018

配列に変換する事でArrayクラスの様々なメリットが利用可能になって、

下記のコードはascii文字のみを取り出す。

console.log(Array.from(str).filter(
    char => char.codePointAt(0) <= 0x7f).join("")); // 1a#

スプレッド構文

Array.fromメソッドのかわりにスプレッド構文を使って配列に変換する事も可能で、
以下のコードは前項のforEachメソッドのコードのArray.fromの部分をスプレッド構文に置き換えた例である。

[...str].forEach(char => {
    console.log(`${char}\tcodePoint: ${char.codePointAt(0).toString(16)}`);
});

サロゲートペアを考慮した文字数の取得

これまで観てきたように、Stringクラスのlengthプロパティではサロゲートペアを含む文字列の正しい文字数を得る事ができない。
サロゲートペアを含む文字列の正しい文字数を求めるにはいろいろ方法があるが、
文字列を配列に変換するのが簡単なようだ。

console.log(`文字数: ${Array.from(str).length}`);

あるいは

console.log(`文字数: ${[...str].length}`);

もし、頻繁に文字列の長さを求める必要があるでであれば、以下のようにStringクラスを拡張してしまうのもありかな。

// 文字列の長さを求める関数を定義
function getCharacterLength(str) {
    return [...str].length;
}

// StringクラスにcharLengthプロパティを追加
Object.defineProperty(String.prototype, 'charLength', {
    get() {
        return getCharacterLength(this);
    }
});

// String.charLengthを利用
console.log(str.charLength); // 9               

ユニコードのエスケープ表記

エスケープ表記を使って、文字列に16 進数のコードポイント,コードユニットを埋め込む事ができる。

1コード 出力
\uXXXX (XXXX = 4 桁の 16 進数、
0x0000~0xFFFF の範囲)
UTF-16 のコード単位 / U+0000 から U+FFFF の間の Unicode コードポイント
\u{X} ... \u{XXXXXX} (X…XXXXXX = 1 ~ 6 桁の 16 進数、
0x0~0x10FFFF の範囲))
UTF-32 のコード単位 / U+0000 から U+10FFFF の間の Unicode コードポイント
\xXX (XX = 2 桁の 16 進数、
0x00~0xFF の範囲)
ISO-8859-1 の文字 / U+0000 から U+00FF の間の Unicode コードポイント
// コードユニット => UTF-16 のコード単位 / U+0000 から U+FFFF の間の Unicode コードポイント
console.log("\u0031\u0061\u0023\uff71\u3042\u30a2\u6f22\u03b2\ud840\udc18");    // 1a#アあア漢β𠀘
// コードポイント => UTF-32 のコード単位 / U+0000 から U+10FFFF の間の Unicode コードポイント
console.log("\u{31}\u{61}\u{23}\u{ff71}\u{3042}\u{30a2}\u{6f22}\u{3b2}\u{20018}");  //1a#アあア漢β𠀘
// ISO-8859-1 の文字 / U+0000 から U+00FF の間の Unicode コードポイント
console.log("\x31\x61\x23");    // 1a#
// 混在して記述
console.log("\x31\x61\x23\uff71\u3042\u30a2\u6f22\u03b2\u{20018}"); // 1a#アあア漢β𠀘

Encoding API

これまでみてきたメソッドはUTF-16に関するメソッドであったが、UTF-8の文字コードを処理するためにはそれとは別の「Encoding API」が便利である。

// 文字列をutf-8に変換
const encoder = new TextEncoder();
const view = encoder.encode('€');
console.log(view); // Uint8Array(3) [226, 130, 172];
for(let i=0;i<view.length;i++){
    console.log(`${i}\t${view[i].toString(16)}`);
}

// utf-8のUint8Arrayを文字列に変換
let utf8decoder = new TextDecoder(); // default 'utf-8' or 'utf8'
console.log(utf8decoder.decode(view));

実行結果

0   e2
1   82
2   ac
€

まとめ

atメソッドとcharAtメソッドの違いは何かというとatメソッドは引数に負の整数を使用できる事。
atメソッドは引数に-1を指定すると文字列の最後の文字を返す。

ブラケット記法と同様にcharAtメソッドやatメソッドも文字列から1文字づつ文字を取り出す事ができるが、サロゲートペアの文字を扱うには不向き。

charCodeAtメソッドは文字列からコードユニットを得る。
codePointAtメソッドは文字列からコードポイントを得る。

fromCodePointメソッドはコードポイントから文字を得る。
fromCharCodeメソッドは指定された UTF-16 コードユニットの並びから生成された文字列を得る。

UTF-8の文字コードを処理するには「Encoding API」

ページのトップへ戻る