JavaScript での Base64 エンコード文字列の微妙な違い

base64 のエンコードとデコードは、バイナリ コンテンツをウェブセーフ テキストとして表現するための一般的な変換形式です。通常は、インライン画像などのデータ URL に使用されます。

JavaScript で文字列に base64 のエンコードとデコードを適用するとどうなりますか?この投稿では、微妙な違いと回避すべき一般的な注意点について解説します。

btoa() と atob()

JavaScript で Base64 エンコードとデコードを行うコア関数は、btoa()atob() です。btoa() は文字列から base64 でエンコードされた文字列に、atob() はデコードします。

簡単な例を次に示します。

// A really plain string that is just code points below 128.
const asciiString = 'hello';

// This will work. It will print:
// Encoded string: [aGVsbG8=]
const asciiStringEncoded = btoa(asciiString);
console.log(`Encoded string: [${asciiStringEncoded}]`);

// This will work. It will print:
// Decoded string: [hello]
const asciiStringDecoded = atob(asciiStringEncoded);
console.log(`Decoded string: [${asciiStringDecoded}]`);

残念ながら、MDN のドキュメントに記載されているように、これは ASCII 文字を含む文字列、または 1 バイトで表すことができる文字列でのみ機能します。つまり、Unicode では機能しません。

結果を確認するには、次のコードを試してください。

// Sample string that represents a combination of small, medium, and large code points.
// This sample string is valid UTF-16.
// 'hello' has code points that are each below 128.
// '⛳' is a single 16-bit code units.
// '❤️' is a two 16-bit code units, U+2764 and U+FE0F (a heart and a variant).
// '🧀' is a 32-bit code point (U+1F9C0), which can also be represented as the surrogate pair of two 16-bit code units '\ud83e\uddc0'.
const validUTF16String = 'hello⛳❤️🧀';

// This will not work. It will print:
// DOMException: Failed to execute 'btoa' on 'Window': The string to be encoded contains characters outside of the Latin1 range.
try {
  const validUTF16StringEncoded = btoa(validUTF16String);
  console.log(`Encoded string: [${validUTF16StringEncoded}]`);
} catch (error) {
  console.log(error);
}

文字列内のいずれかの絵文字がエラーの原因になります。なぜ Unicode がこの問題の原因なのでしょうか?

これを理解するために、コンピュータ サイエンスと JavaScript の両方で文字列について理解しましょう。

Unicode と JavaScript の文字列

Unicode は、文字エンコードの現在のグローバル標準です。文字エンコードとは、特定の文字に番号を割り当ててコンピュータ システムで使用できるようにする手法です。Unicode の詳細については、こちらの W3C の記事をご覧ください。

Unicode の文字とそれに関連する数値の例を次に示します。

  • 時間 - 104
  • ñ - 241
  • ❤ - 2764
  • ❤️ - 2764(隠し修飾子番号 65039)
  • ⛳ - 9971
  • 🧀 - 129472

各文字を表す数値は「コードポイント」と呼ばれます。「コードポイント」は、各文字のアドレスと考えることができます。赤いハートの絵文字には、実際には 2 つのコードポイントがあります。1 つはハート用で、もう 1 つは色を「変化」させて常に赤色にするためのものです。

Unicode には、これらのコードポイントをコンピュータが一貫して解釈できるバイトのシーケンスにする方法として、UTF-8 と UTF-16 という 2 つの一般的な方法があります。

簡素化したビューは次のとおりです。

  • UTF-8 では、コードポイントに 1~4 バイト(1 バイトあたり 8 ビット)を使用できます。
  • UTF-16 では、コードポイントは常に 2 バイト(16 ビット)です。

重要な点として、JavaScript は文字列を UTF-16 として処理します。これにより、文字列内の各文字が 1 つのバイトにマッピングされることを前提として効率的に動作する btoa() などの関数が破損します。これは MDN に明記されています。

btoa() メソッドは、バイナリ文字列(文字列内の各文字がバイナリデータの 1 バイトとして扱われる文字列)から Base64 でエンコードされた ASCII 文字列を作成します。

JavaScript の文字には 1 バイト以上が必要になることが多いことを学びました。次のセクションでは、Base64 のエンコードとデコードでこのようなケースを処理する方法について説明します。

Unicode を使用した btoa() と atob()

ここまでで説明したように、スローされるエラーは、UTF-16 で 1 バイトを超える文字が文字列に含まれていることが原因です。

幸い、base64 に関する MDN の記事には、この「Unicode の問題」を解決するための便利なサンプルコードが含まれています。このコードを変更して、上記の例と連携させることができます。

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function base64ToBytes(base64) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function bytesToBase64(bytes) {
  const binString = String.fromCodePoint(...bytes);
  return btoa(binString);
}

// Sample string that represents a combination of small, medium, and large code points.
// This sample string is valid UTF-16.
// 'hello' has code points that are each below 128.
// '⛳' is a single 16-bit code units.
// '❤️' is a two 16-bit code units, U+2764 and U+FE0F (a heart and a variant).
// '🧀' is a 32-bit code point (U+1F9C0), which can also be represented as the surrogate pair of two 16-bit code units '\ud83e\uddc0'.
const validUTF16String = 'hello⛳❤️🧀';

// This will work. It will print:
// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA]
const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
console.log(`Encoded string: [${validUTF16StringEncoded}]`);

// This will work. It will print:
// Decoded string: [hello⛳❤️🧀]
const validUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(validUTF16StringEncoded));
console.log(`Decoded string: [${validUTF16StringDecoded}]`);

次の手順では、このコードが文字列をエンコードする仕組みについて説明します。

  1. TextEncoder インターフェースを使用して、UTF-16 でエンコードされた JavaScript 文字列を取得し、TextEncoder.encode() を使用して UTF-8 でエンコードされたバイトのストリームに変換します。
  2. これにより、Uint8Array が返されます。これは JavaScript ではあまり使用されないデータ型で、TypedArray のサブクラスです。
  3. この Uint8ArraybytesToBase64() 関数に渡します。この関数は String.fromCodePoint() を使用して、Uint8Array 内の各バイトをコードポイントとして扱い、そこから文字列を作成します。その結果、すべて 1 つのバイトとして表すことができるコードポイントの文字列が生成されます。
  4. その文字列を取得し、btoa() を使用して Base64 でエンコードします。

デコード プロセスは同じですが、逆になります。

これは、Uint8Array と文字列の間のステップにより、JavaScript の文字列が UTF-16 の 2 バイトのエンコードとして表される一方で、各 2 バイトが表すコードポイントは常に 128 未満であることが保証されるためです。

このコードはほとんどの状況で正常に機能しますが、他の状況では通知なく失敗します。

サイレント エラーのケース

同じコードを使用しますが、文字列を変更します。

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function base64ToBytes(base64) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function bytesToBase64(bytes) {
  const binString = String.fromCodePoint(...bytes);
  return btoa(binString);
}

// Sample string that represents a combination of small, medium, and large code points.
// This sample string is invalid UTF-16.
// 'hello' has code points that are each below 128.
// '⛳' is a single 16-bit code units.
// '❤️' is a two 16-bit code units, U+2764 and U+FE0F (a heart and a variant).
// '🧀' is a 32-bit code point (U+1F9C0), which can also be represented as the surrogate pair of two 16-bit code units '\ud83e\uddc0'.
// '\uDE75' is code unit that is one half of a surrogate pair.
const partiallyInvalidUTF16String = 'hello⛳❤️🧀\uDE75';

// This will work. It will print:
// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA77+9]
const partiallyInvalidUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(partiallyInvalidUTF16String));
console.log(`Encoded string: [${partiallyInvalidUTF16StringEncoded}]`);

// This will work. It will print:
// Decoded string: [hello⛳❤️🧀�]
const partiallyInvalidUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(partiallyInvalidUTF16StringEncoded));
console.log(`Decoded string: [${partiallyInvalidUTF16StringDecoded}]`);

( )をデコードした後に最後の文字を取得し、その 16 進値を確認すると、元の \uDE75 ではなく \uFFFD であることがわかります。エラーは発生せず、エラーもスローされませんが、入力データと出力データがサイレントで変更されています。理由

文字列は JavaScript API によって異なります

前述のように、JavaScript は文字列を UTF-16 として処理します。ただし、UTF-16 文字列には固有のプロパティがあります。

チーズの絵文字を例にとります。絵文字(🧀)の Unicode コードポイントは 129472 です。残念ながら、16 ビット数値の最大値は 65,535 です。UTF-16 では、この非常に大きな数値をどのように表すのでしょうか。

UTF-16 には、サロゲート ペアと呼ばれるコンセプトがあります。次のように考えることができます。

  • ペアの最初の数値は、検索する「書籍」を指定します。これは「サロゲート」と呼ばれます。
  • ペアの 2 番目の数字は「書籍」内のエントリです。

ご想像のとおり、書籍を表す番号のみが記録され、その書籍の実際のエントリが記録されていない場合があります。UTF-16 では、これは単独のサロゲートと呼ばれます。

これは JavaScript で特に難しい問題です。一部の API は単一のサロゲートがあるにもかかわらず動作しますが、他の API は動作しません。

この場合、base64 からデコードする際に TextDecoder を使用します。特に、TextDecoder のデフォルトでは、次のように指定します。

デフォルトは false です。つまり、デコーダが不正な形式のデータを置換文字に置き換えます。

先ほど見た � 文字(16 進数では \uFFFD)が、その置換文字です。UTF-16 では、単独のサロゲートを含む文字列は「不正な形式」または「適切な形式ではない」と見なされます。

さまざまなウェブ標準(例: 1234)があり、不正な形式の文字列が API の動作に影響するタイミングを正確に指定していますが、特に TextDecoder はそのような API の 1 つです。テキスト処理を行う前に、文字列の形式が正しいことを確認することをおすすめします。

適切な形式の文字列を確認する

最新のブラウザには、この目的のための関数 isWellFormed() が用意されています。

対応ブラウザ

  • Chrome: 111.
  • Edge: 111.
  • Firefox: 119.
  • Safari: 16.4。

ソース

同様の結果を得るには、encodeURIComponent() を使用します。この関数は、文字列に単一のサロゲート文字が含まれている場合に URIError エラーをスローします。

次の関数は、利用可能な場合は isWellFormed() を使用し、利用できない場合は encodeURIComponent() を使用します。同様のコードを isWellFormed() のポリフィルの作成に使用できます。

// Quick polyfill since older browsers do not support isWellFormed().
// encodeURIComponent() throws an error for lone surrogates, which is essentially the same.
function isWellFormed(str) {
  if (typeof(str.isWellFormed)!="undefined") {
    // Use the newer isWellFormed() feature.
    return str.isWellFormed();
  } else {
    // Use the older encodeURIComponent().
    try {
      encodeURIComponent(str);
      return true;
    } catch (error) {
      return false;
    }
  }
}

すべてをまとめる

Unicode と単独のサロゲートの両方を処理する方法がわかったので、すべてを組み合わせて、すべてのケースを処理し、テキストをサイレント置換せずに処理するコードを作成できます。

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function base64ToBytes(base64) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function bytesToBase64(bytes) {
  const binString = String.fromCodePoint(...bytes);
  return btoa(binString);
}

// Quick polyfill since Firefox and Opera do not yet support isWellFormed().
// encodeURIComponent() throws an error for lone surrogates, which is essentially the same.
function isWellFormed(str) {
  if (typeof(str.isWellFormed)!="undefined") {
    // Use the newer isWellFormed() feature.
    return str.isWellFormed();
  } else {
    // Use the older encodeURIComponent().
    try {
      encodeURIComponent(str);
      return true;
    } catch (error) {
      return false;
    }
  }
}

const validUTF16String = 'hello⛳❤️🧀';
const partiallyInvalidUTF16String = 'hello⛳❤️🧀\uDE75';

if (isWellFormed(validUTF16String)) {
  // This will work. It will print:
  // Encoded string: [aGVsbG/im7PinaTvuI/wn6eA]
  const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
  console.log(`Encoded string: [${validUTF16StringEncoded}]`);

  // This will work. It will print:
  // Decoded string: [hello⛳❤️🧀]
  const validUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(validUTF16StringEncoded));
  console.log(`Decoded string: [${validUTF16StringDecoded}]`);
} else {
  // Not reached in this example.
}

if (isWellFormed(partiallyInvalidUTF16String)) {
  // Not reached in this example.
} else {
  // This is not a well-formed string, so we handle that case.
  console.log(`Cannot process a string with lone surrogates: [${partiallyInvalidUTF16String}]`);
}

このコードには、ポリフィルへの一般化、単一のサロゲートをサイレント リプレイスするのではなく、スローするように TextDecoder パラメータを変更するなど、多くの最適化を行うことができます。

この知識とコードを使用すると、データを拒否する、データ置換を明示的に有効にする、後で分析するためにエラーをスローするなど、不正な形式の文字列の処理方法を明示的に決定することもできます。

この投稿は、Base64 エンコードとデコードの貴重な例であるだけでなく、特にテキストデータがユーザー作成または外部ソースから取得される場合に、慎重なテキスト処理が特に重要である理由の例を示しています。