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:
- 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()
. - Zwraca
Uint8Array
, czyli rzadziej używany typ danych w JavaScript i należący do podklasyTypedArray
. - Pobierz ten obiekt
Uint8Array
i podaj go do funkcjibytesToBase64()
, która używaString.fromCodePoint()
do traktowania każdego bajta w elemencieUint8Array
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. - 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()
.
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.