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

Kodowanie i dekodowanie base64 to powszechna forma przekształcania danych binarnych w tekst obsługiwany w internecie. Jest on zwykle używany w przypadku adresów URL danych, np. obrazów w treści.

Co się dzieje, gdy zastosujesz kodowanie i dekodowanie base64 do ciągów znaków w JavaScript? W tym poście omawiamy subtelności i typowe pułapki, których należy unikać.

btoa() i atob()

Podstawowe funkcje do kodowania i dekoodowania w formacie Base64 w JavaScriptzie to btoa()atob(). btoa() przekształca ciąg tekstowy w ciąg zakodowany w formacie Base64, a atob() odwrotnie.

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, dokumentacja MDN działa tylko w przypadku ciągów zawierających znaki ASCII lub znaki, które mogą być reprezentowane przez jeden bajt. Inaczej mówiąc, nie zadziała to w przypadku Unicode.

Aby sprawdzić, co się stanie, spróbuj użyć 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);
}

Jeden z emotikonów w ciągu znaków spowoduje błąd. Dlaczego problem występuje z Unicode?

Aby to zrozumieć, cofniemy się i zrozumiemy ciągi tekstowe, zarówno w zakresie informatyki, jak i JavaScriptu.

Ciągi znaków w Unicode i JavaScript

Unicode to obecny globalny standard kodowania znaków, czyli przypisywania liczby do konkretnego znaku, aby można go było używać w systemach komputerowych. Aby dowiedzieć się więcej o Unicode, przeczytaj ten artykuł W3C.

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

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

Liczby reprezentujące poszczególne znaki nazywamy „punktami kodu”. „Punkty kodu” możesz traktować jako adresy poszczególnych znaków. W przypadku emotikonu czerwonego serca są w rzeczywistości 2 punkty kodu: jeden dla serca i jeden do „zmieniania” koloru, aby był zawsze czerwony.

Unicode wykorzystuje 2 typowe sposoby analizowania tych punktów kodu i tworzenia z nich ciągów bajtów, które komputery mogą zinterpretować w spójny sposób: UTF-8 i UTF-16.

Uproszczony widok:

  • W kodowaniu UTF-8 punkt kodowy może używać od 1 do 4 bajtów (8 bitów na bajt).
  • W UTF-16 punkt kodowy to zawsze 2 bajty (16 bitów).

Ważne: JavaScript przetwarza ciągi znaków jako UTF-16. To powoduje nieprawidłowe działanie funkcji takich jak btoa(), które działają z założenia, że każdy znak w ciągu odpowiada pojedynczemu bajtowi. Jest to wyraźnie podane w MDN:

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

Teraz już wiesz, że znaki w JavaScript często wymagają więcej niż 1 bajta. W następującej sekcji pokażemy, jak rozwiązać ten problem w przypadku kodowania i dekodowania base64.

btoa() i atob() z Unicode

Jak wiesz, błąd ten jest spowodowany tym, że nasz ciąg znaków zawiera znaki znajdujące się poza jednym bajtem w kodowaniu UTF-16.

Na szczęście ten artykuł w MDN na temat base64 zawiera przykładowy kod, który pomoże rozwiązać ten „problem z Unicode”. Możesz zmodyfikować ten kod, aby działał w poprzednim przykładzie:

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

Poniżej znajdziesz opis tego, jak kod koduje ciąg:

  1. Użyj interfejsu TextEncoder, aby wziąć ciąg znaków JavaScript zakodowany w UTF-16 i konwertować go na strumień bajtów zakodowanych w UTF-8 za pomocą funkcji TextEncoder.encode().
  2. Zwraca to Uint8Array, który jest rzadziej używanym typem danych w JavaScript i podklasą TypedArray.
  3. Weź ten Uint8Array i przekaż go do funkcji bytesToBase64(), która używa String.fromCodePoint() do traktowania każdego bajta w Uint8Array jako punktu kodu i tworzenia z niego ciągu znaków, co powoduje, że ciąg punktów kodu można reprezentować jako pojedynczy bajt.
  4. Weź ten ciąg znaków i użyj btoa(), aby go zakodować w formacie Base64.

Proces dekodowania jest taki sam, ale odwrotny.

Działa to, ponieważ krok między Uint8Array a ciągiem znaków gwarantuje, że ciąg znaków w JavaScript jest reprezentowany jako kodowanie UTF-16, dwubajtowe, a punkt kodu reprezentowany przez każdy z tych 2 bajtów jest zawsze mniejszy niż 128.

Ten kod działa dobrze w większości przypadków, ale w innych może nie działać.

Przypadek bezgłośnego 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 dekodowaniu weźmiesz ostatni znak (�) i sprawdzisz jego wartość szesnastkową, okaże się, że jest to \uFFFD, a nie pierwotne \uDE75. Nie powoduje to błędów ani błędów, ale dane wejściowe i wyjściowe zostały dyskretnie zmienione. Dlaczego?

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

Jak już wspomnieliśmy, JavaScript przetwarza ciągi znaków jako UTF-16. Jednak ciągi znaków UTF-16 mają jedną unikalną właściwość.

Weźmy na przykład emotikon serka. Emotikon (🧀) ma punkt kodu Unicode 129472. Maksymalna wartość liczby 16-bitowej to 65 535. Jak UTF-16 reprezentuje tak dużo większą liczbę?

UTF-16 zawiera pojęcie podwójnych kodów zastępczych. Możesz to wyobrazić sobie w ten sposób:

  • Pierwsza liczba w parze określa, w której „książce” ma być przeprowadzone wyszukiwanie. Takie dane nazywamy „zastępczymi”.
  • Druga cyfra w parze to wpis w „książce”.

Jak możesz sobie wyobrazić, czasami może być kłopotliwe posiadanie tylko numeru książki, a nie samego wpisu w niej. W UTF-16 jest to tzw. samotna zastępcza.

Jest to szczególnie trudne w przypadku kodu JavaScript, ponieważ niektóre interfejsy API działają pomimo posiadania pojedynczych zastępników, podczas gdy inne nie.

W tym przypadku podczas dekodowania z base64 używasz parametru TextDecoder. W szczególności domyślne wartości funkcji TextDecoder:

Domyślnie jest to wartość false, co oznacza, że dekoder zastępuje błędne dane za pomocą znaku zastępczego.

Znak �, który wcześniej zauważyłeś/zauważyłaś, a który w notacji szesnastkowej ma postać \uFFFD, jest właśnie tym znakiem zastępczym. W UTF-16 ciągi z samymi znakami zastępczymi są uważane za „nieprawidłowo sformatowane” lub „nieprawidłowo sformatowane”.

Istnieją różne standardy internetowe (np. 1, 2, 3, 4), które dokładnie określają, kiedy źle sformatowany ciąg znaków wpływa na działanie interfejsu API. Jednym z takich interfejsów API jest TextDecoder. Przed przetworzeniem tekstu warto sprawdzić, czy ciągi znaków są poprawnie sformatowane.

Sprawdzanie poprawności ciągów znaków

Bardzo aktualne przeglądarki mają teraz funkcję w tym celu: isWellFormed().

Obsługa przeglądarek

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

Źródło

Podobny efekt można uzyskać, używając funkcji encodeURIComponent(), która wyrzuca błądURIError, jeśli ciąg znaków zawiera tylko jeden znak zastępczy.

Ta funkcja używa isWellFormed(), jeśli jest dostępny, lub encodeURIComponent(), jeśli nie jest. Podobny kod można użyć do utworzenia polyfilla 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

Teraz, gdy już wiesz, jak obsługiwać zarówno kod Unicode, jak i pojedyncze substytuty, możesz połączyć wszystko razem, aby utworzyć kod, który obsługuje wszystkie przypadki bez cichej wymiany tekstu.

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

W tym kodzie można wprowadzić wiele optymalizacji, takich jak przekształcenie w polyfill, zmiana parametrów TextDecoder na throw zamiast milczącego zastępowania pojedynczych zastępników itp.

Dzięki tej wiedzy i kodom możesz też podejmować świadome decyzje dotyczące sposobu obsługi źle sformatowanych ciągów znaków, np. odrzucania danych lub wyraźnego włączania ich zastępowania, a także zgłaszania błędów do późniejszej analizy.

Ten post zawiera nie tylko cenny przykład kodowania i dekodowania w standardzie base64, ale też pokazuje, dlaczego staranne przetwarzanie tekstu jest szczególnie ważne, zwłaszcza gdy dane tekstowe pochodzą ze źródeł zewnętrznych lub od użytkowników.