Die Nuancen der Base64-Codierung von Strings in JavaScript

Die Base64-Codierung und -Dekodierung ist eine gängige Methode, um Binärinhalte in websicheren Text umzuwandeln. Sie wird häufig für Daten-URLs verwendet, z. B. für Inline-Bilder.

Was passiert, wenn Sie Strings in JavaScript mit Base64 codieren und decodieren? In diesem Beitrag werden die Feinheiten und häufigen Fallstricke beschrieben, die Sie vermeiden sollten.

btoa() und atob()

Die Hauptfunktionen für die base64-Codierung und -Decodierung in JavaScript sind btoa() und atob(). btoa() geht von einem String in einen base64-codierten String und atob() wird wieder 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}]`);

Leider funktioniert das, wie in den MDN-Dokumenten erwähnt, nur mit Strings, die ASCII-Zeichen oder Zeichen enthalten, die durch ein einzelnes Byte dargestellt werden können. Mit anderen Worten: Das 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 führt Unicode zu diesem Problem?

Um das zu verstehen, gehen wir einen Schritt zurück und sehen uns Strings sowohl in der Informatik als auch in JavaScript an.

Strings in Unicode und JavaScript

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

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

  • h – 104
  • ñ – 241
  • ❤ – 2764
  • ❤️ – 2764 mit dem versteckten Modifikator 65039
  • ⛳ – 9971
  • 🧀 – 129472

Die Zahlen, die die einzelnen Zeichen repräsentieren, werden als „Codepunkte“ bezeichnet. Sie können sich „Codepoints“ als Adressen für jedes Zeichen vorstellen. Für das rote Herz-Emoji gibt es tatsächlich zwei Codepunkte: einen für ein Herz und einen, um die Farbe zu „variieren“ und sie immer rot zu machen.

Unicode bietet zwei gängige Möglichkeiten, diese Codepunkte in Bytefolgen umzuwandeln, die von Computern konsistent interpretiert werden können: UTF-8 und UTF-16.

Hier eine stark vereinfachte Darstellung:

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

Wichtig: In JavaScript werden Strings als UTF-16 verarbeitet. Dies führt zu Fehlern bei Funktionen wie btoa(), die davon ausgehen, dass jedem Zeichen im String ein einzelnes Byte zugeordnet ist. Das wird auf der MDN ausdrücklich erwähnt:

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

Sie wissen jetzt, dass für Zeichen in JavaScript oft mehr als ein Byte erforderlich ist. Im nächsten Abschnitt wird gezeigt, wie Sie mit diesem Fall bei der Base64-Codierung und ‑Dekodierung umgehen.

btoa() und atob() mit Unicode

Wie Sie jetzt wissen, wird der Fehler ausgegeben, weil unser String Zeichen enthält, die in UTF-16 nicht in einem einzelnen Byte enthalten sind.

Glücklicherweise enthält der MDN-Artikel zu base64 nützlichen Beispielcode 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 beschrieben, wie dieser Code den String codiert:

  1. Verwenden Sie die TextEncoder-Oberfläche, um den UTF-16-codierten JavaScript-String mithilfe von TextEncoder.encode() in einen Stream von UTF-8-codierten Byte umzuwandeln.
  2. Dadurch wird ein Uint8Array zurückgegeben. Dies ist ein weniger häufig verwendeter Datentyp in JavaScript und eine abgeleitete Klasse von TypedArray.
  3. Diese Uint8Array wird an die bytesToBase64()-Funktion übergeben, die mithilfe von String.fromCodePoint() jedes Byte in der Uint8Array als Codepunkt behandelt und daraus einen String erstellt. Dies führt zu einem String von Codepunkten, die alle als einzelnes Byte dargestellt werden können.
  4. Codieren Sie diesen String mit btoa() in Base64.

Der Dekodierungsprozess ist derselbe, aber in umgekehrter Reihenfolge.

Das funktioniert, weil der Schritt zwischen Uint8Array und einem String dafür sorgt, dass der String in JavaScript als UTF-16-Codierung mit zwei Byte dargestellt wird, wobei der Codepunkt, der jeweils zwei Byte darstellt, immer kleiner als 128 ist.

Dieser Code funktioniert in den meisten Fällen gut, scheitert aber in anderen Fällen geräuschlos.

Fall mit stummgeschaltetem Fehler

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 \uFFFD und nicht das ursprüngliche \uDE75-Zeichen ist. Sie schlägt nicht fehl und gibt keinen Fehler aus, aber die Eingabe- und Ausgabedaten haben sich automatisch geändert. Warum?

Strings variieren je nach JavaScript API

Wie bereits erwähnt, werden Strings in JavaScript als UTF-16 verarbeitet. UTF-16-Strings haben jedoch eine einzigartige Eigenschaft.

Nehmen wir das Käse-Emoji als Beispiel. Das Emoji (🧀) hat den Unicode-Codepunkt 129472. Leider beträgt der Maximalwert für eine 16-Bit-Zahl 65.535. Wie wird diese viel höhere Zahl in UTF-16 dargestellt?

UTF-16 verwendet Ersatzzeichenpaare. Sie können sich das so vorstellen:

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

Wie Sie sich vorstellen können, kann es manchmal problematisch sein, nur die Nummer für das Buch zu haben, aber nicht den tatsächlichen Eintrag in diesem Buch. In UTF-16 wird dies als einsamer Ersatzwert bezeichnet.

Das ist in JavaScript besonders schwierig, da einige APIs trotz einzelner Surrogate funktionieren, während andere fehlschlagen.

In diesem Fall verwenden Sie TextDecoder, wenn Sie von Base64 zurück decodieren. Insbesondere werden in den Standardeinstellungen für TextDecoder Folgendes festgelegt:

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

Das Zeichen „�“, das Sie zuvor gesehen haben und das im Hexadezimalformat als \uFFFD dargestellt wird, ist das Ersatzzeichen. In UTF-16 gelten Strings mit einzelnen Ersatzzeichen als „falsch formatiert“ oder „nicht korrekt formatiert“.

Es gibt verschiedene Webstandards (Beispiele: 1, 2, 3, 4), die genau angeben, wann ein fehlerhafter String das API-Verhalten beeinflusst. Eine dieser APIs ist TextDecoder. Es empfiehlt sich, vor der Textverarbeitung zu prüfen, ob Strings korrekt formatiert sind.

Nach korrekt formatierten Strings suchen

Neuere Browser haben jetzt eine Funktion für diesen Zweck: isWellFormed().

Unterstützte Browser

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

Quelle

Ein ähnliches Ergebnis lässt sich mit encodeURIComponent() erzielen. Dabei wird ein URIError-Fehler ausgegeben, wenn der String einen einzelnen Surrogate enthält.

In der folgenden Funktion wird isWellFormed() verwendet, wenn es verfügbar ist, und encodeURIComponent(), wenn es nicht verfügbar ist. Ähnlicher Code kann verwendet werden, um eine Polyfill für isWellFormed() zu erstellen.

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

Da Sie nun wissen, wie Sie sowohl Unicode als auch einzelne Surrogates verarbeiten, können Sie alles zusammenstellen, um Code zu erstellen, der alle Fälle ohne stummen Textersatz verarbeitet.

// 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 auszugebenden TextDecoder-Parameter, die ausgegeben werden sollen, anstatt einzelne Ersatzwerte ohne Ton zu ersetzen, und vieles mehr.

Mit diesem Wissen und diesem Code können Sie auch explizite Entscheidungen darüber treffen, wie mit fehlerhaften Strings umgegangen werden soll, z. B. indem Sie die Daten ablehnen, die Datenersetzung explizit aktivieren oder vielleicht einen Fehler für eine spätere Analyse ausgeben.

Dieser Beitrag ist nicht nur ein wertvolles Beispiel für die Base64-Codierung und ‑Dekodierung, sondern zeigt auch, warum eine sorgfältige Textverarbeitung besonders wichtig ist, insbesondere wenn die Textdaten aus von Nutzern erstellten oder externen Quellen stammen.