はじめに
JavaScript で文字列を操作していると、黒いひし形に?のマークの「�」(U+FFFD
)が表示された経験が一度はあると思います。(ブラウザによっては 黒で縁取られた四角形が表示されるかもしれません。)
これは「置換文字」(Replacement Character)と呼ばれ、不正な文字を置き換えるために使用されます。
この記事では、JavaScript で「�」が表示される原因と、「�」を表示させないための方法について詳しく解説します。前提知識として Unicode と UTF-16 についても説明します。
Unicode と UTF-16
JavaScript は文字コードとして Unicode を採用し、符号化方式として UTF-16 を採用しています。
文字コード
文字コードは、コンピュータ上で文字を扱うために、文字に番号を割り振って定義したものです。ASCII
や Unicode
、Shift_JIS
などの種類があります。近年では Unicode が主流となりつつあり、様々なシステムで広く利用されています。
Unicode は、世界中のあらゆる言語で使用されるすべての文字を、固有の識別番号で管理する国際規格です。この識別番号のことをコードポイントと呼びます。
例えば、a
のコードポイントは 16 進数表記で 61
です(10 進数表記: 97
)。
JavaScript で文字列を記述する際、通常の文字表現だけでなく、コードポイントを用いて記述する方法があります。\uXXXX
のように Unicode のコードポイントを直接記述する方法です。XXXX
には、表現したい文字のコードポイントを 16 進数で記述します。
例えば、'a'
と記述する代わりに '\u0061'
と記述することが可能です。
符号化方式
符号化方式は、コードポイントをビット列に変換する方法です。コンピュータ上では、あらゆるデータが 2 種類の電気信号 ( 0
と 1
に対応 )で表現されています。そのため、コンピュータ上で文字を扱う際にはコードポイントをビット列に変換する必要があります。符号化方式には UTF-8
やUTF-16
などの種類があります。
UTF-16 は文字を 16 ビット単位で表現する符号化方式です。
`a` のコードポイントは 10 進数表記で `61` で 2 進数表記で `110 0001` です。
UTF-16 は文字を 16 ビット単位で表現するため、以下のようにメモリに格納されます。
0000 0000 0110 0001
16 ビット(= 2 バイト)では、理論的には 65,536(2 の 16 乗)種類の文字を表すことができます。コードポイントが 0
から FFFF
(10 進数表記で 65,535) の範囲は基本多言語面(BMP)と呼ばれます。BMP には、英数字、記号、大部分のヨーロッパ言語、アジア言語の文字などが含まれています。
UTF-16 では BMP 内の文字は 16 ビットで表現しますが、BMP 外の文字はサロゲートペアと呼ばれる 2 つの 16 ビット値の組み合わせ(32 ビット)を用いて表現します。
サロゲートペア
サロゲートペア(surrogate pair)は、BMP 外の文字を表現するために使用されます。これらの文字は 2 つの 16 ビット値(サロゲートペア)を組み合わせて表現されます。
サロゲートペアは、以下の 2 つの値で構成されます。
-
上位サロゲート:
D800
~DBFF
の範囲のコードポイント -
下位サロゲート:
DC00
~DFFF
の範囲のコードポイント
上位サロゲートと下位サロゲートを組み合わせることで、1,048,576(2 の 20 乗)種類の文字を追加で表現することができます。
上位サロゲートと下位サロゲートは、単独で有効な文字として定義されていません。つまり、D842
や DFB7
などの値を単独で使うことはできません。
const surrogatePair = '𠮷'; // BMP 外の文字
console.log(surrogatePair.length); // 2
console.log(surrogatePair.charCodeAt(0)); // 55362 (16進数表記: D842)
console.log(surrogatePair.charCodeAt(1)); // 57271 (16進数表記: DFB7)
置換文字「�」が表示される原因
「�」が表示される主な原因は以下の通りです。
- サロゲートペアの不適切な処理
- Unicode の不適切なエンコーディングやデコーディング
不適切な処理
JavaScript で文字列を処理する際に、charAt
メソッドやインデックスを使う場合がありますが、これらの方法はユニコードのサロゲートペアや結合文字を正しく扱うことができないため、注意が必要です。
let text = 'foo';
console.log(text[0]); // 'f'
console.log(text.charAt(1)); // 'o'
このコードは単純な BMP 内の文字列を扱っている場合には問題ありません。
しかし、Unicode の絵文字など、サロゲートペアを含む文字列を処理する場合には問題が発生します。
'😊'[0]; // '\uD83D'
'😊'.charAt(1); // '\uDE0A'
// \uD83D も \uDE0A も console.log で表示する場合、� と表示されます。
const str = '😊';
for (let i = 0; i < str.length; i++) {
console.log(str[i]);
}
// �
// �
// Web ページ上で表示する場合も、� と表示されます。
// (Safari や Firefox では 黒で縁取り四角形が表示されるかもしれません。)
document.getElementById('foo').innerText = "😊".charAt(0);
上記のコードでは、絵文字「😊」はサロゲートペアで表現されています。しかし、charAt
メソッドやインデックスを使用して文字を取得しようとすると、サロゲートペアの一部のみを取得してしまい、結果的に不完全な Unicode 文字となり、「�」として表示されます。
サロゲートペアの正しい処理
サロゲートペアは、UTF-16 エンコーディングで BMP 外の文字を表現するための方法です。
サロゲートペアを正しく扱うためには、for...of
ループやスプレッド構文([... ]
)、Array.from
メソッドを使用します。
const str = 'A😊';
// for...of ループ
for (const char of str) {
console.log(char); // 正しく 'A' と '😊' が表示される
}
// スプレッド構文
const chars = [...str];
console.log(chars); // 正しく ['A', '😊'] と表示される
// Array.from
const charsFromArray = Array.from(str);
console.log(charsFromArray); // 正しく ['A', '😊'] と表示される
リージョナルインディケータシンボル文字の正しい扱い
リージョナルインディケータシンボル文字(Regional Indicator Symbol Letters)も同様に、正しい方法で処理する必要があります。
リージョナルインディケータシンボル文字は、ユニコードで国旗の絵文字を表現するために使用される特殊な文字です。これらの文字は、1F1E6
から 1F1FF
までの範囲に定義されており、A から Z までの英字に対応しています。
以下のように 2 つのリージョナルインディケータシンボル文字を組み合わせて旗を表現します。
- 🇯🇵(日本):
1F1EF
(🇯) +1F1F5
(🇵) - 🇺🇸(アメリカ):
1F1FA
(🇺) +1F1F8
(🇸) - 🇬🇧(イギリス):
1F1EC
(🇬) +1F1E7
(🇧)
1F1E6
から 1F1FF
までの範囲は BMP 外の文字であり、サロゲートペアを使用して表現します。各サロゲートペアは 2 つの 16 ビット値で構成されており、1 つのリージョナルインディケータシンボル文字は 32 ビット(4バイト)で表現されます。
例えば、🇺🇸 は 1F1FA
(🇺) と 1F1F8
(🇸)で構成されますが、1F1FA
(🇺)は D83C
と DDFA
、1F1F8
(🇸)は D83C
と DDF8
のサロゲートペアを用いて表現します。つまり、🇺🇸 は D83C DDFA D83C DDF8
の 32 バイトで表現されます。
const str = '🇺🇸'; // '\uD83C\uDDFA\uD83C\uDDF8'
// 不適切な処理
console.log(str.charAt(0)); // '\uD83C' (不完全な文字)
// �
console.log(str.charAt(1)); // '\uDDFA' (不完全な文字)
// �
// 正しい文字列処理
const chars = [...str];
console.log(chars);
// ['🇺', '🇸']
// join メソッドで元に戻すことができます
console.log(chars.join(''))
// 🇺🇸
この例では、スプレッド構文を使用して文字列を分割することで、サロゲートペアを正しく処理し、国旗絵文字を 2 つのリージョナルインディケータシンボル文字として正しく扱うことができます。
ゼロ幅結合子(ZWJ)の処理
ゼロ幅接合子(\u200D
)を含む文字列も正しく処理する必要があります。
ゼロ幅結合子は、複数の絵文字を結合して 1 つの絵文字として表示するために使用されます。これにより、例えば家族の絵文字や複合絵文字が作成されます。
// 🐻 と ❄ を \u200D で結合すると 🐻❄ になります
console.log('🐻\u200D❄') // 🐻❄
const str = '👩👩👧👦';
// ZWJ を含む文字列の処理
const chars = [...str];
console.log(chars); // ['👩', '', '👩', '', '👧', '', '👦']
// join メソッドで元に戻すことができます
console.log(chars.join('')) // 👩👩👧👦
不適切なエンコーディングやデコーディング
TextEncoder
と TextDecoder
を使用できます。以下は、UTF-8 でエンコードされた文字列を ISO-8859-1(latin1)でデコードする例です。このような誤ったエンコーディングによって、文字列が正しく表示されず、置換文字が現れることがあります。
const text = 'こんにちは';
const encoder = new TextEncoder();
// UTF-8 でエンコードされた文字の配列
const utf8Array = encoder.encode(text);
// ISO-8859-1(latin1)でデコードする(不適切な処理)
const decoder = new TextDecoder('iso-8859-1');
const incorrectText = decoder.decode(utf8Array);
// 正しい UTF-8 でのデコード
const correctDecoder = new TextDecoder('utf-8');
const correctText = correctDecoder.decode(utf8Array);
// 結果の表示
console.log('UTF-8エンコード: ', utf8Array);
// UTF-8エンコード: [227, 129, 147, 227, 130, 147, 227, 129, 171, 227, 129, 161, 227, 129, 175]
console.log('不適切なデコード: ', incorrectText);
// 不適切なデコード: ã�“ã‚“ã�«ã�¡ã�¯
// (ブラウザによっては、`不適切なデコード: ã“ã‚“ã«ã¡ã¯` と表示される場合もあります)
console.log('正しいデコード: ', correctText);
// 正しいデコード: こんにちは
まとめ
JavaScriptで「�」が表示される問題を回避するためには、Unicode 文字列を正しく処理することが重要です。
for...of
ループ、スプレッド構文、Array.from
メソッドを使用することで、サロゲートペア、結合文字、ゼロ幅結合子などの特殊な文字を正しく扱うことができます。これにより、Unicode 文字列を含む文字列操作でのバグや表示の問題を回避できます。