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

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

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

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 の文字とそれに関連する数値の例を次に示します。

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

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

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

次のように、単純化しすぎたビューを示します。

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

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

btoa() メソッドは、バイナリ文字列(文字列内の各文字がバイナリデータのバイトとして扱われる文字列)から 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 では特に困難です。サロゲートが 1 つでも機能する 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 と単独のサロゲートの両方を処理する方法がわかったので、すべてを 1 つにまとめて、すべてのケースを処理する(サイレント テキスト置換なしで処理する)コードを作成できます。

// 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 のエンコードとデコードの有用な例であるだけでなく、特にテキストデータがユーザー生成ソースまたは外部ソースから取り込まれる場合に、慎重なテキスト処理が特に重要である理由についても例示します。