Szczegółowe informacje o ciągach kodowania base64 w JavaScripcie

Kodowanie i dekodowanie base64 to typowa forma przekształcania treści binarnych w taki sposób, aby były przedstawiane jako tekst do użytku w internecie. Jest on często używany w przypadku adresów URL danych, np. obrazów w treści.

Co się stanie, gdy zastosujesz kodowanie i dekodowanie base64 do ciągów tekstowych w JavaScript? W tym poście omawiamy niuanse i typowe błędy, których należy unikać.

btoa() i atob()

Podstawowe funkcje kodowania i dekodowania w formacie base64 w języku JavaScript to btoa() i atob(). btoa() przechodzi z ciągu znaków do ciągu zakodowanego w formacie base64 i odkodowuje z powrotem atob().

Oto krótki przykład:

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

Niestety, jak wskazano w dokumentacji MDN, działa to tylko w przypadku ciągów znaków zawierających znaki ASCII lub znaków, które mogą być reprezentowane przez pojedynczy bajt. Innymi słowy, nie będzie ona działać w przypadku Unicode.

Aby zobaczyć, co się stanie, użyj tego kodu:

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

Każdy z emotikonów w ciągu znaków spowoduje błąd. Dlaczego Unicode sprawia ten problem?

Aby to zrozumieć, cofnijmy się o krok i zrozumiemy ciągi tekstowe, zarówno w informatyce, jak i w JavaScript.

Ciągi znaków w Unicode i JavaScript

Unicode to obecny globalny standard kodowania znaków, czyli metody przypisywania numerów do określonych znaków, tak aby można było ich używać w systemach komputerowych. Aby dowiedzieć się więcej o Unicode, przeczytaj ten artykuł W3C.

Oto kilka przykładów znaków w Unicode i powiązanych z nimi cyfr:

  • h - 104
  • ñ – 241
  • ❤ – 2764
  • ❤️ – 2764 z ukrytym modyfikatorem o numerze 65039
  • ⛳ – 9971
  • 🧀 – 129472

Liczby reprezentujące każdy znak nazywamy „punktami kodowymi”. „Punkty kodowe” to coś więcej niż adres do każdego znaku. W czerwonym sercu są 2 punkty kodu: 1 oznaczając serce, a drugi, który „zmienia” jego kolor, aby był zawsze czerwony.

Unicode łączy te punkty kodu i umieszcza je w sekwencjach bajtów, które mogą być spójnie interpretowane przez komputery: UTF-8 i UTF-16.

Zbyt uproszczony widok wygląda tak:

  • W UTF-8 punkt kodu może mieć od 1 do 4 bajtów (8 bitów na bajt).
  • W UTF-16 punkt kodu ma zawsze 2 bajty (16 bitów).

Co ważne, JavaScript przetwarza ciągi tekstowe jako UTF-16. Powoduje to uszkodzenie funkcji takich jak btoa(), które efektywnie działają przy założeniu, że każdy znak w ciągu znaków jest mapowany na jeden bajt. Jest to wyraźnie zawarte w MDN:

Metoda btoa() tworzy ciąg znaków ASCII zakodowany w Base64 z ciągu binarnego (tzn. ciąg, w którym każdy znak w ciągu znaków jest traktowany jako bajt danych binarnych).

Znaki w JavaScripcie często wymagają więcej niż 1 bajtu. W następnej sekcji pokazujemy, jak postępować w przypadku kodowania i dekodowania base64.

btoa() i atob() z Unicode

Jak wiesz, błąd wynika z tego, że nasz ciąg znaków zawiera znaki spoza jednego bajtu w formacie UTF-16.

Na szczęście w artykule w MDN na temat Base64 znajdziesz przydatny przykładowy kod, który pomoże Ci rozwiązać problem związany z kodem Unicode. Możesz go zmodyfikować tak, aby pasował do poprzedniego przykładu:

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

W tych krokach wyjaśniamy, jak ten kod koduje ciąg:

  1. Użyj interfejsu TextEncoder, aby pobrać ciąg JavaScript zakodowany w formacie UTF-16 i przekształcić go w strumień bajtów zakodowanych w UTF-8 za pomocą TextEncoder.encode().
  2. Zwraca Uint8Array, czyli rzadziej używany typ danych w JavaScript i należący do podklasy TypedArray.
  3. Pobierz ten obiekt Uint8Array i podaj go do funkcji bytesToBase64(), która używa String.fromCodePoint() do traktowania każdego bajta w elemencie Uint8Array jako punktu kodu i utworzenia z niego ciągu znaków. W efekcie powstaje ciąg punktów kodu, które można przedstawić jako pojedynczy bajt.
  4. Weź ten ciąg znaków i użyj btoa() do zakodowania go w formacie base64.

Proces dekodowania jest taki sam, ale odwrócony.

Działa to, ponieważ przejście między ciągiem Uint8Array a ciągiem gwarantuje, że chociaż ciąg w skrypcie JavaScript jest przedstawiony jako dwubajtowe kodowanie UTF-16, punkt kodu, który reprezentuje każdy 2 bajty, ma zawsze wartość mniejszą niż 128.

Ten kod sprawdza się w większości sytuacji, ale w innych po prostu nie zadziała.

Cichy przypadek niepowodzenia

Użyj tego samego kodu, ale z innym ciągiem znaków:

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

Jeśli po zdekodowaniu ( ) pobierzesz ten ostatni znak i sprawdzisz jego wartość szesnastkową, zobaczysz, że jest to \uFFFD, a nie pierwotna wartość \uDE75. Nie występuje żaden błąd ani błąd, ale dane wejściowe i wyjściowe zostały bez powiadomienia zmienione. Dlaczego?

Ciągi znaków różnią się w zależności od interfejsu JavaScript API

Jak opisano wcześniej, JavaScript przetwarza ciągi tekstowe jako UTF-16. Ciągi znaków w formacie UTF-16 mają jednak unikalną właściwość.

Posłużmy się przykładem emotikona sera. Emotikon (🧀) ma punkt kodu Unicode 129472. Niestety maksymalna wartość liczby 16-bitowej to 65 535. W jaki sposób kodowanie UTF-16 oznacza tak dużo większą liczbę?

W kodowaniu UTF-16 istnieje pojęcie pary zastępcze. Możesz to ująć w ten sposób:

  • Pierwsza liczba w parze określa, w której „książce” ma być przeszukiwane. Nazywamy to „zastępnikiem”.
  • Druga liczba w parze to pozycja w książce.

Jak łatwo sobie wyobrazić, czasem trudno jest znaleźć sam numer reprezentujący książkę, a nie jej rzeczywisty wpis. W formacie UTF-16 jest to nazywane samotnym zastępnikiem.

Jest to szczególnie trudne w przypadku JavaScriptu, ponieważ niektóre interfejsy API działają nawet wtedy, gdy mają samotne zastępcze, a inne zawiodają.

W tym przypadku używasz TextDecoder przy dekodowaniu z powrotem z base64. Wartości domyślne funkcji TextDecoder określają w szczególności te elementy:

Domyślnie ma wartość false, co oznacza, że dekoder zastępuje zniekształcone dane znakiem zastępczym.

Zaobserwowany wcześniej znak, który jest przedstawiony jako \uFFFD w systemie szesnastkowym, jest znakiem zastępczym. W standardzie UTF-16 ciągi tekstowe z samotnymi zastępnikami są uważane za „zniekształcone” lub „niedobrze sformułowane”.

Istnieją różne standardy internetowe (przykłady: 1, 2, 3, 4), które dokładnie określają, kiedy nieprawidłowy ciąg znaków wpływa na działanie interfejsu API. W szczególności chodzi o to, że jednym z tych interfejsów API jest TextDecoder. Przed rozpoczęciem przetwarzania tekstu warto sprawdzić, czy ciągi tekstowe mają odpowiednią formę.

Sprawdź, czy ciąg znaków nie zawiera błędów.

Ostatnie przeglądarki mają teraz funkcję pozwalającą na użycie tej funkcji: isWellFormed().

Obsługa przeglądarek

  • 111
  • 111
  • 119
  • 16.4

Źródło

Podobne wyniki możesz uzyskać, używając metody encodeURIComponent(), która zwraca błąd URIError, jeśli ciąg zawiera pojedynczy zastępnik.

Poniższa funkcja używa danych isWellFormed(), jeśli jest dostępna, lub encodeURIComponent(), jeśli nie. Podobny kod można użyć do utworzenia kodu polyfill dla 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;
    }
  }
}

Połącz wszystkie elementy

Gdy już wiesz, jak obsługiwać zarówno kod Unicode, jak i samotne modele zastępcze, możesz połączyć wszystkie elementy, aby utworzyć kod obsługujący wszystkie przypadki bez konieczności zastępowania tekstu dyskretnie.

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

Kod można zoptymalizować na wiele sposobów, np. uogólnić go do kodu polyfill lub zmienić parametry TextDecoder przesyłane zamiast dyskretnie zastępować samotne modele zastępcze itp.

Dzięki tej wiedzy i kodzie możesz też podejmować wyraźne decyzje dotyczące postępowania z nieprawidłowymi ciągami znaków, np. odrzuceniem danych, jawnym włączeniem funkcji zastępowania danych lub zwróceniem błędu do późniejszej analizy.

Ten post jest cennym przykładem kodowania i dekodowania base64, a także przykładem, dlaczego staranne przetwarzanie tekstu jest szczególnie ważne, zwłaszcza gdy dane tekstowe pochodzą ze źródeł użytkowników lub zewnętrznych.