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 ona często używana do adresów URL danych, takich jak obrazy wstawiane w tekście.

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

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 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 zauważają twórcy dokumentacji MDN, ta metoda działa tylko w przypadku ciągów znaków zawierających znaki ASCII lub znaki, które można przedstawić za pomocą pojedynczego bajta. Innymi słowy, nie będzie działać w 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);
}

Każdy z emoji w ciągu spowoduje błąd. Dlaczego problem występuje z Unicode?

Aby to zrozumieć, cofnijmy się i poznajmy ciągi znaków w informatykach i w JavaScript.

Ciągi znaków w Unicode i JavaScript

Unicode to aktualny globalny standard kodowania znaków, czyli przypisywanie numeru do konkretnego znaku, tak by można było go 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 kodowe” to raczej 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 UTF-8 punkt kodowy może zajmować 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 określone 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 już wiesz, błąd jest spowodowany przez ciąg znaków zawierających znaki spoza zakresu jednego bajta w UTF-16.

Na szczęście w artykule MSDN na temat base64 znajdziesz przydatny przykładowy kod, który pomoże Ci 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 przekształcić ciąg znaków JavaScript w kodowaniu UTF-16 i przekonwertować go na strumień bajtów zakodowanych w formacie UTF-8 za pomocą narzędzia 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ć.

Ciche zgłoszenie awarii

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 błędu ani nie powoduje błędu, ale dane wejściowe i wyjściowe zostały 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 w formacie 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 liczba 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.

Zaobserwowany wcześniej znak, który ma postać \uFFFD w zapisie szesnastkowym, jest znakiem zastępczym. W UTF-16 ciągi znaków zawierające samotne substytuty są uważane za „nieprawidłowe” lub „nieprawidłowo sformułowane”.

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.

W tej funkcji użyto argumentu isWellFormed(), jeśli jest dostępny, lub encodeURIComponent(), jeśli nie. Podobny kod można wykorzystać do utworzenia kodu polyfill na potrzeby 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

Wiesz już, jak radzić sobie zarówno z kodowaniem Unicode, jak i samotnymi alternatywami, więc możesz połączyć wszystkie elementy, aby utworzyć kod, który obsługuje wszystkie przypadki i nie wymaga zamiany tekstu w trybie cichym.

// 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 jest nie tylko cennym przykładem kodowania i dekodowania base64, lecz także pokazuje, dlaczego staranne przetwarzanie tekstu jest szczególnie ważne, zwłaszcza gdy dane tekstowe pochodzą ze źródeł wygenerowanych przez użytkowników lub zewnętrznych.