L'encodage et le décodage en base64 sont une forme courante de transformation du contenu binaire pour le représenter sous forme de texte Web sécurisé. Il est couramment utilisé pour les URL de données, telles que les images intégrées.
Que se passe-t-il lorsque vous appliquez l'encodage et le décodage en base64 aux chaînes en JavaScript ? Cet article explore les nuances et les pièges courants à éviter.
btoa() et atob()
Les fonctions de base pour l'encodage et le décodage en base64 en JavaScript sont btoa()
et atob()
.
btoa()
passe d'une chaîne à une chaîne encodée en base64, et atob()
la décode.
Voici un exemple simple:
// 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}]`);
Malheureusement, comme indiqué dans les documents MDN, cela ne fonctionne que avec des chaînes contenant des caractères ASCII ou des caractères pouvant être représentés par un seul octet. En d'autres termes, cela ne fonctionnera pas avec Unicode.
Pour voir ce qui se passe, essayez le code suivant :
// 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);
}
L'un des emoji de la chaîne entraînera une erreur. Pourquoi Unicode pose-t-il ce problème ?
Pour comprendre, prenons du recul et examinons les chaînes, à la fois en informatique et en JavaScript.
Chaînes en Unicode et en JavaScript
Unicode est la norme internationale actuelle pour l'encodage des caractères, qui consiste à attribuer un nombre à un caractère spécifique afin qu'il puisse être utilisé dans les systèmes informatiques. Pour en savoir plus sur Unicode, consultez cet article du W3C.
Voici quelques exemples de caractères Unicode et les numéros qui leur sont associés :
- h - 104
- ñ - 241
- ❤ - 2764
- ❤️ : 2764 avec un modificateur masqué numéroté 65039
- ⛳ - 9971
- 🧀 - 129472
Les nombres représentant chaque caractère sont appelés "points de code". Vous pouvez considérer les "points de code" comme une adresse pour chaque caractère. L'emoji cœur rouge comporte deux points de code: un pour un cœur et un autre pour "varier" la couleur et le rendre toujours rouge.
Unicode propose deux méthodes courantes pour transformer ces points de code en séquences d'octets que les ordinateurs peuvent interpréter de manière cohérente : UTF-8 et UTF-16.
Voici une vue simplifiée :
- En UTF-8, un point de code peut utiliser entre un et quatre octets (8 bits par octet).
- En UTF-16, un point de code est toujours de deux octets (16 bits).
Il est important de noter que JavaScript traite les chaînes au format UTF-16. Cela casse des fonctions telles que btoa()
, qui fonctionnent effectivement sur l'hypothèse que chaque caractère de la chaîne est mappé sur un seul octet. Cela est indiqué explicitement sur MDN:
La méthode
btoa()
crée une chaîne ASCII encodée en Base64 à partir d'une chaîne binaire (c'est-à-dire une chaîne dans laquelle chaque caractère de la chaîne est traité comme un octet de données binaires).
Vous savez maintenant que les caractères en JavaScript nécessitent souvent plus d'un octet. La section suivante montre comment gérer ce cas pour l'encodage et le décodage en base64.
btoa() et atob() avec Unicode
Comme vous le savez maintenant, l'erreur générée est due au fait que notre chaîne contient des caractères situés en dehors d'un seul octet en UTF-16.
Heureusement, l'article MDN sur base64 inclut des exemples de code utiles pour résoudre ce "problème Unicode". Vous pouvez modifier ce code pour qu'il fonctionne avec l'exemple précédent:
// 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}]`);
Les étapes suivantes expliquent comment ce code encode la chaîne:
- Utilisez l'interface
TextEncoder
pour convertir la chaîne JavaScript encodée en UTF-16 en flux d'octets encodés en UTF-8 à l'aide deTextEncoder.encode()
. - Cela renvoie un
Uint8Array
, qui est un type de données moins couramment utilisé en JavaScript et qui est une sous-classe deTypedArray
. - Prenez cet élément
Uint8Array
et transmettez-le à la fonctionbytesToBase64()
, qui utiliseString.fromCodePoint()
pour traiter chaque octet de l'élémentUint8Array
comme un point de code et en créer une chaîne, ce qui donne une chaîne de points de code pouvant tous être représentés par un seul octet. - Prenez cette chaîne et utilisez
btoa()
pour l'encoder en base64.
Le processus de décodage est le même, mais dans l'ordre inverse.
Cela fonctionne, car l'étape entre Uint8Array
et une chaîne garantit que, bien que la chaîne en JavaScript soit représentée sous la forme d'un encodage UTF-16 à deux octets, le point de code que chaque deux octets représente est toujours inférieur à 128.
Ce code fonctionne bien dans la plupart des cas, mais échoue de manière silencieuse dans d'autres.
Cas d'échec silencieux
Utilisez le même code, mais avec une chaîne différente :
// 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}]`);
Si vous prenez ce dernier caractère après le décodage (�) et vérifiez sa valeur hexadécimale, vous constaterez qu'il s'agit de \uFFFD
plutôt que du \uDE75
d'origine. Elle n'échoue pas et ne génère pas d'erreur, mais les données d'entrée et de sortie ont été modifiées silencieusement. Pourquoi ?
Les chaînes varient selon l'API JavaScript
Comme décrit précédemment, JavaScript traite les chaînes au format UTF-16. Toutefois, les chaînes UTF-16 ont une propriété unique.
Prenons l'exemple de l'emoji fromage. L'emoji (🧀) a un point de code Unicode de 129472
. Malheureusement, la valeur maximale pour un nombre de 16 bits est 65 535 ! Comment UTF-16 représente-t-il ce nombre beaucoup plus élevé ?
UTF-16 utilise un concept appelé paires de substitution. Vous pouvez considérer les choses de cette façon:
- Le premier chiffre de la paire indique dans quel "livre" effectuer la recherche. C'est ce qu'on appelle un surrogate.
- Le deuxième chiffre de la paire correspond à l'entrée dans le "livre".
Comme vous pouvez l'imaginer, il peut parfois être problématique de ne disposer que du numéro représentant le livre, mais pas de l'entrée réelle dans ce livre. En UTF-16, c'est ce qu'on appelle un substitution isolé.
Cela est particulièrement difficile en JavaScript, car certaines API fonctionnent malgré l'utilisation de substituts seuls, tandis que d'autres échouent.
Dans ce cas, vous utilisez TextDecoder
pour décoder à partir de base64. En particulier, les valeurs par défaut pour TextDecoder
spécifient les éléments suivants :
La valeur par défaut est false, ce qui signifie que le décodeur remplace les données non valides par un caractère de remplacement.
Le caractère "�" que vous avez observé précédemment, représenté par \uFFFD
en hexadécimal, est ce caractère de remplacement. En UTF-16, les chaînes avec des caractères de substitution solitaires sont considérées comme "mal formées" ou "non valides".
Il existe différentes normes Web (exemples 1, 2, 3, 4) qui spécifient précisément à quel moment une chaîne non valide affecte le comportement de l'API, mais TextDecoder
est notamment l'une de ces API. Il est recommandé de s'assurer que les chaînes sont bien formées avant de traiter du texte.
Vérifier que les chaînes sont bien formées
Les navigateurs très récents disposent désormais d'une fonction à cette fin : isWellFormed()
.
Vous pouvez obtenir un résultat similaire à l'aide de encodeURIComponent()
, qui génère une erreur URIError
si la chaîne contient un seul caractère de substitution.
La fonction suivante utilise isWellFormed()
si elle est disponible et encodeURIComponent()
dans le cas contraire. Un code similaire peut être utilisé pour créer un polyfill pour 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;
}
}
}
Regrouper tous les éléments
Maintenant que vous savez gérer à la fois Unicode et les caractères de substitution solitaires, vous pouvez tout mettre en œuvre pour créer un code qui gère tous les cas et le fait sans remplacement de texte silencieux.
// 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}]`);
}
De nombreuses optimisations peuvent être apportées à ce code, telles que la généralisation dans un polyfill, la modification des paramètres TextDecoder
à générer au lieu de remplacer silencieusement les substituts uniques, etc.
Grâce à ces connaissances et à ce code, vous pouvez également prendre des décisions explicites sur la façon de gérer les chaînes mal formées, par exemple en rejetant les données ou en activant explicitement le remplacement des données, ou en générant une erreur pour une analyse ultérieure.
En plus d'être un exemple utile pour l'encodage et le décodage en base64, cet article explique pourquoi le traitement minutieux du texte est particulièrement important, en particulier lorsque les données textuelles proviennent de sources externes ou générées par l'utilisateur.