0
0
お題は不問!Qiita Engineer Festa 2024で記事投稿!
Qiita Engineer Festa20242024年7月17日まで開催中!

JavaScript で �(置換文字)が表示される理由と回避方法

Last updated at Posted at 2024-07-16

はじめに

JavaScript で文字列を操作していると、黒いひし形に?のマークの「�」(U+FFFD)が表示された経験が一度はあると思います。(ブラウザによっては 黒で縁取られた四角形が表示されるかもしれません。)

これは「置換文字」(Replacement Character)と呼ばれ、不正な文字を置き換えるために使用されます。

この記事では、JavaScript で「�」が表示される原因と、「�」を表示させないための方法について詳しく解説します。前提知識として UnicodeUTF-16 についても説明します。

Unicode と UTF-16

JavaScript は文字コードとして Unicode を採用し、符号化方式として UTF-16 を採用しています。

文字コード

文字コードは、コンピュータ上で文字を扱うために、文字に番号を割り振って定義したものです。ASCIIUnicodeShift_JIS などの種類があります。近年では Unicode が主流となりつつあり、様々なシステムで広く利用されています。

Unicode は、世界中のあらゆる言語で使用されるすべての文字を、固有の識別番号で管理する国際規格です。この識別番号のことをコードポイントと呼びます。
例えば、aコードポイントは 16 進数表記で 61 です(10 進数表記: 97)。

JavaScript で文字列を記述する際、通常の文字表現だけでなく、コードポイントを用いて記述する方法があります。\uXXXX のように Unicode のコードポイントを直接記述する方法です。XXXX には、表現したい文字のコードポイントを 16 進数で記述します。
例えば、'a' と記述する代わりに '\u0061' と記述することが可能です。

符号化方式

符号化方式は、コードポイントをビット列に変換する方法です。コンピュータ上では、あらゆるデータが 2 種類の電気信号 ( 01 に対応 )で表現されています。そのため、コンピュータ上で文字を扱う際にはコードポイントをビット列に変換する必要があります。符号化方式には UTF-8UTF-16 などの種類があります。

UTF-16 は文字を 16 ビット単位で表現する符号化方式です。

a を UTF-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 乗)種類の文字を追加で表現することができます。

上位サロゲートと下位サロゲートは、単独で有効な文字として定義されていません。つまり、D842DFB7 などの値を単独で使うことはできません。

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(🇺)は D83CDDFA1F1F8(🇸)は D83CDDF8 のサロゲートペアを用いて表現します。つまり、🇺🇸 は 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('')) // 👩‍👩‍👧‍👦

不適切なエンコーディングやデコーディング

TextEncoderTextDecoder を使用できます。以下は、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 文字列を含む文字列操作でのバグや表示の問題を回避できます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0