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 には、これらのコードポイントをコンピュータが一貫して解釈できるバイトのシーケンスに変換する 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 文字列には一意のプロパティがあります。

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

UTF-16 には、サロゲートペアと呼ばれる概念があります。次のように考えてください。

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

ご想像のとおり、書籍を表す数字のみが含まれ、書籍内の実際のエントリがないと、問題が発生することがあります。UTF-16 では、これは「単独のサロゲート」と呼ばれます。

サロゲートが 1 つでも動作する API もあれば、失敗する API もあるため、これは JavaScript では特に困難です。

この例では、base64 から再度デコードするときに TextDecoder を使用します。特に、TextDecoder のデフォルトでは、以下を指定します。

デフォルトは false です。これは、デコーダが不正な形式のデータを置換文字に置き換えることを意味します。

先ほど確認した文字(16 進数の \uFFFD で表される)が置換文字です。UTF-16 では、サロゲートが孤立している文字列は「不正な形式」または「形式が正しくない」と見なされます。

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

正しい形式の文字列を確認する

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

対応ブラウザ

  • 111
  • 111
  • 119
  • 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 のエンコードとデコードの貴重な例であるだけでなく、特にテキストデータがユーザー生成ソースまたは外部ソースから来る場合に、慎重なテキスト処理が特に重要である理由の例を示します。