Die Nuancen der Base64-Codierung von Strings in JavaScript

Die base64-Codierung und -Decodierung ist eine gängige Form der Umwandlung von Binärinhalten so, dass sie als websicherer Text dargestellt wird. Es wird häufig für Daten-URLs wie Inline-Bilder verwendet.

Was passiert, wenn Sie die base64-Codierung und -Decodierung auf Strings in JavaScript anwenden? In diesem Beitrag geht es um die Feinheiten und häufigsten Fallstricke, die Sie vermeiden sollten.

btoa() und atob()

Die Hauptfunktionen für die base64-Codierung und -Decodierung in JavaScript sind btoa() und atob(). btoa() wird von einem String in einen base64-codierten String umgewandelt und atob() decodiert.

Hier ein kurzes Beispiel:

// 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}]`);

Wie in den MDN-Dokumenten angegeben, funktioniert dies leider nur mit Strings, die ASCII-Zeichen enthalten, oder Zeichen, die durch ein einzelnes Byte dargestellt werden können. Das heißt, dies funktioniert nicht mit Unicode.

Probieren Sie den folgenden Code aus, um zu sehen, was passiert:

// 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);
}

Jedes Emoji im String führt zu einem Fehler. Warum verursacht Unicode dieses Problem?

Gehen wir einen Schritt zurück, um Zeichenfolgen sowohl in der Informatik als auch in JavaScript zu verstehen.

Zeichenfolgen in Unicode und JavaScript

Unicode ist der aktuelle globale Standard für die Zeichencodierung bzw. die Praxis, einem bestimmten Zeichen eine Zahl zuzuweisen, damit es in Computersystemen verwendet werden kann. Ausführlichere Informationen zu Unicode finden Sie in diesem W3C-Artikel.

Einige Beispiele für Unicode-Zeichen und die zugehörigen Zahlen:

  • Std. - 104
  • ñ – 241
  • ❤ – 2764
  • ❤️ 2764 mit ausgeblendetem Modifikator 65039
  • ⛳ – 9971
  • 🧀 – 129472

Die Zahlen, die die einzelnen Zeichen darstellen, werden als „Codepunkte“ bezeichnet. Sie können sich „Codepunkte“ als eine Adresse zu jedem Zeichen vorstellen. Im roten Herz-Emoji gibt es zwei Codepunkte: einen für ein Herz und einen zum „Variieren“ der Farbe, sodass das Herz immer rot ist.

Bei Unicode werden diese Codepunkte auf zwei gängige Arten in Byte-Sequenzen umgewandelt, die Computer konsistent interpretieren können: UTF-8 und UTF-16.

Eine stark vereinfachte Ansicht sieht so aus:

  • In UTF-8 kann ein Codepunkt zwischen einem und vier Byte (8 Bit pro Byte) verwenden.
  • In UTF-16 besteht ein Codepunkt immer aus zwei Byte (16 Bit).

Wichtig ist, dass JavaScript Strings als UTF-16 verarbeitet. Dadurch funktionieren Funktionen wie btoa(), die effektiv davon ausgehen, dass jedes Zeichen im String einem einzelnen Byte zugeordnet ist. Dies ist explizit in der MDN angegeben:

Mit der Methode btoa() wird ein Base64-codierter ASCII-String aus einem Binärstring erstellt. Das ist ein String, in dem jedes Zeichen im String als Byte mit Binärdaten behandelt wird.

Jetzt wissen Sie, dass Zeichen in JavaScript häufig mehr als ein Byte erfordern. Im nächsten Abschnitt wird gezeigt, wie dieser Fall bei der base64-Codierung und -Decodierung gehandhabt wird.

btoa() und atob() mit Unicode

Wie Sie wissen, ist der Fehler darauf zurückzuführen, dass der String Zeichen enthält, die in UTF-16 außerhalb eines einzelnen Bytes liegen.

Glücklicherweise enthält der MDN-Artikel auf base64 einige hilfreiche Beispielcodes zur Lösung dieses „Unicode-Problems“. Sie können diesen Code so ändern, dass er mit dem vorherigen Beispiel funktioniert:

// 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}]`);

In den folgenden Schritten wird erläutert, wie dieser Code zum Codieren des Strings dient:

  1. Verwenden Sie die TextEncoder-Oberfläche, um den UTF-16-codierten JavaScript-String mit TextEncoder.encode() in einen Stream von UTF-8-codierten Byte zu konvertieren.
  2. Dadurch wird Uint8Array zurückgegeben. Das ist ein weniger häufig verwendeter Datentyp in JavaScript und eine abgeleitete Klasse von TypedArray.
  3. Stellen Sie dieses Uint8Array in der bytesToBase64()-Funktion bereit, die mithilfe von String.fromCodePoint() jedes Byte im Uint8Array als Codepunkt behandelt und daraus einen String erstellt. Daraus ergibt sich ein String von Codepunkten, die alle als einzelnes Byte dargestellt werden können.
  4. Verwenden Sie diesen String und btoa(), um ihn in base64 zu codieren.

Der Decodierungsprozess ist der gleiche, aber in umgekehrter Richtung.

Dies funktioniert, da durch den Schritt zwischen Uint8Array und einem String garantiert wird, dass der String in JavaScript zwar als UTF-16-Zwei-Byte-Codierung dargestellt wird, der Codepunkt, den die beiden Byte darstellen, aber immer kleiner als 128 ist.

Dieser Code funktioniert unter den meisten Umständen gut, schlägt in anderen jedoch ohne Meldung fehl.

Stummer Fehlerfall

Verwenden Sie denselben Code, aber mit einem anderen String:

// 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}]`);

Wenn Sie das letzte Zeichen nach der Decodierung ( ) nehmen und seinen Hexadezimalwert prüfen, werden Sie feststellen, dass es sich um \uFFFD und nicht um das ursprüngliche \uDE75 handelt. Er schlägt zwar fehl und gibt auch keinen Fehler aus, die Eingabe- und Ausgabedaten wurden jedoch im Hintergrund geändert. Warum?

Strings variieren je nach JavaScript API

Wie bereits beschrieben, verarbeitet JavaScript Strings als UTF-16. UTF-16-Strings haben jedoch ein eigenes Attribut.

Nehmen wir als Beispiel das Käse-Emoji. Das Emoji (🧀) hat den Unicode-Codepoint 129472. Leider ist der Maximalwert für eine 16-Bit-Zahl 65.535. Wie stellt UTF-16 diese viel höhere Zahl dar?

Bei UTF-16 wird das sogenannte Ersatzpaarpaar genannt. Sie können es sich so vorstellen:

  • Die erste Zahl im Paar gibt an, in welchem „Buch“ gesucht werden soll. Dies wird als Ersatz bezeichnet.
  • Die zweite Zahl im Paar ist der Eintrag im „Buch“.

Wie Sie sich vorstellen können, kann es manchmal problematisch sein, wenn nur die Nummer für das Buch angegeben ist, nicht aber der tatsächliche Eintrag im Buch. In UTF-16 wird dies als einsamer Ersatz bezeichnet.

Dies ist bei JavaScript besonders schwierig, da einige APIs trotz einsamer Ersatzwerte funktionieren, während andere fehlschlagen.

In diesem Fall verwenden Sie TextDecoder beim Decodieren aus Base64. Die Standardwerte für TextDecoder geben insbesondere Folgendes an:

Der Standardwert ist false, was bedeutet, dass der Decodierer fehlerhafte Daten durch ein Ersatzzeichen ersetzt.

Das Zeichen, das Sie zuvor beobachtet haben und das als \uFFFD in der Hexadezimalzahl dargestellt wird, ist dieses Ersatzzeichen. In UTF-16 werden Strings mit einsamen Ersatzzeichen als "fehlerhaft" oder "nicht wohlgeformt" betrachtet.

Es gibt verschiedene Webstandards (z. B. 1, 2, 3, 4), die genau angeben, wann ein falsch formatierter String das API-Verhalten beeinflusst. Dabei ist TextDecoder eine dieser APIs. Es empfiehlt sich, Zeichenfolgen vor der Textverarbeitung gut zu formatieren.

Nach korrekt formatierten Strings suchen

Aktuelle Browser haben jetzt eine entsprechende Funktion: isWellFormed().

Unterstützte Browser

  • 111
  • 111
  • 119
  • 16.4

Quelle

Ein ähnliches Ergebnis können Sie mit encodeURIComponent() erreichen, das einen URIError-Fehler auslöst, wenn der String einen einzigen Ersatz enthält.

Die folgende Funktion verwendet isWellFormed(), wenn sie verfügbar ist, und encodeURIComponent(), wenn nicht. Mit ähnlichem Code kann ein Polyfill für isWellFormed() erstellt werden.

// 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;
    }
  }
}

Zusammenfassung

Sie wissen nun, wie Sie sowohl Unicode- als auch Einzelersetzungen verarbeiten. Nun können Sie alles zusammensetzen, um Code zu erstellen, der alle Fälle verarbeitet, ohne dass stille Textersetzungen nötig sind.

// 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}]`);
}

Es gibt viele Optimierungen, die an diesem Code vorgenommen werden können, z. B. die Verallgemeinerung in einen Polyfill, das Ändern der TextDecoder-Parameter so, dass sie ausgelöst werden, anstatt einzelne Ersatzwerte im Hintergrund zu ersetzen.

Mit diesem Wissen und diesem Code können Sie auch explizite Entscheidungen zum Umgang mit fehlerhaften Strings treffen. So können Sie beispielsweise die Daten ablehnen, die Datenersetzung explizit aktivieren oder möglicherweise einen Fehler zur späteren Analyse ausgeben.

Dieser Beitrag ist nicht nur ein wertvolles Beispiel für die base64-Codierung und -Decodierung, sondern auch ein Beispiel dafür, warum eine sorgfältige Textverarbeitung besonders wichtig ist, insbesondere wenn die Textdaten aus nutzergenerierten oder externen Quellen stammen.