As nuances das strings de codificação base64 no JavaScript

a codificação e decodificação em base64 é uma forma comum de transformar conteúdo binário para ser representado como texto seguro para a Web. Ele é usado normalmente para URLs de dados, como imagens inline.

O que acontece quando você aplica a codificação e a decodificação em base64 a strings em JavaScript? Esta postagem explora as nuances e os problemas comuns a serem evitados.

btoa() e atob()

As principais funções para codificar e decodificar base64 em JavaScript são btoa() e atob(). btoa() vai de uma string para uma string codificada em base64, e atob() a decodifica de volta.

Confira a seguir um exemplo rápido:

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

Infelizmente, conforme analisado pelos documentos do MDN, isso só funciona com strings que contêm caracteres ASCII ou que podem ser representados por um único byte. Em outras palavras, isso não funcionará com Unicode.

Para ver o que acontece, tente este código:

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

Qualquer um dos emojis na string causará um erro. Por que o Unicode causa esse problema??

Para entender, vamos voltar um pouco e entender as strings, tanto em ciência da computação quanto em JavaScript.

Strings em Unicode e JavaScript

Unicode é o padrão global atual para codificação de caracteres ou a prática de atribuir um número a um caractere específico para que ele possa ser usado em sistemas de computador. Para mais detalhes sobre o Unicode, acesse este artigo do W3C (em inglês).

Alguns exemplos de caracteres em Unicode e os números associados a eles:

  • h - 104
  • ñ - 241
  • ❤ - 2764
  • ❤️ - 2764 com um modificador oculto numerado 65039
  • ⛳ - 9.971
  • 🧀 - 129472

Os números que representam cada caractere são chamados de "pontos de código". Pense em "pontos de código" como um endereço para cada caractere. No emoji de coração vermelho, na verdade há dois pontos de código: um para um coração e outro para "variar" a cor e deixá-lo sempre vermelho.

O Unicode tem duas maneiras comuns de tomar esses pontos de código e transformá-los em sequências de bytes que os computadores podem interpretar consistentemente: UTF-8 e UTF-16.

Uma visualização simplificada demais é esta:

  • Em UTF-8, um ponto de código pode usar entre um e quatro bytes (8 bits por byte).
  • Em UTF-16, um ponto de código é sempre dois bytes (16 bits).

É importante ressaltar que o JavaScript processa as strings como UTF-16. Isso quebra funções como btoa(), que operam com base no pressuposto de que cada caractere na string é mapeado para um único byte. Isso é declarado explicitamente no MDN:

O método btoa() cria uma string ASCII codificada em Base64 com base em uma string binária (ou seja, uma string em que cada caractere é tratado como um byte de dados binários).

Agora você sabe que os caracteres em JavaScript geralmente exigem mais de um byte, a próxima seção demonstra como lidar com esse caso para codificação e decodificação em base64.

btoa() e atob() com Unicode

Como você sabe agora, o erro gerado é devido à nossa string que contém caracteres fora de um único byte em UTF-16.

Felizmente, o artigo da MDN sobre base64 (link em inglês) inclui alguns exemplos de código úteis para resolver esse "problema de Unicode". Você pode modificar esse código para trabalhar com o exemplo anterior:

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

As etapas a seguir explicam o que esse código faz para codificar a string:

  1. Use a interface TextEncoder para converter a string JavaScript codificada em UTF-16 em um fluxo de bytes codificados em UTF-8 usando TextEncoder.encode().
  2. Isso retorna um Uint8Array, que é um tipo de dados menos usado em JavaScript e é uma subclasse de TypedArray.
  3. Pegue esse Uint8Array e o forneça para a função bytesToBase64(), que usa String.fromCodePoint() para tratar cada byte no Uint8Array como um ponto de código e criar uma string a partir dele, o que resulta em uma string de pontos de código que podem ser representados como um único byte.
  4. Use essa string e use btoa() para codificá-la em base64.

O processo de decodificação é o mesmo, mas ao contrário.

Isso funciona porque a etapa entre Uint8Array e uma string garante que, embora a string no JavaScript seja representada como uma codificação UTF-16 de dois bytes, o ponto de código que cada dois bytes representa será sempre menor que 128.

Esse código funciona bem na maioria das circunstâncias, mas falhará silenciosamente em outras.

Caso de falha silenciosa

Use o mesmo código, mas com uma string diferente:

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

Se você pegar esse último caractere após a decodificação ( ) e verificar seu valor hexadecimal, verá que é \uFFFD em vez do \uDE75 original. Ele não falha nem gera um erro, mas os dados de entrada e saída são alterados silenciosamente. Por quê?

As strings variam de acordo com a API JavaScript

Conforme descrito anteriormente, o JavaScript processa as strings como UTF-16. Mas as strings UTF-16 têm uma propriedade exclusiva.

Veja o emoji de queijo como exemplo. O emoji (🧀) tem um ponto de código Unicode de 129472. Infelizmente, o valor máximo para um número de 16 bits é 65535. Então, como UTF-16 representa esse número tão maior?

UTF-16 tem um conceito chamado pares substitutos. Pense assim:

  • O primeiro número do par especifica em qual "livro" pesquisar. Isso é chamado de "alternativa".
  • O segundo número no par é a entrada no "livro".

Como você pode imaginar, às vezes pode ser problemático ter apenas o número que representa o livro, mas não a entrada real nesse livro. Em UTF-16, isso é conhecido como alternativo único.

Isso é particularmente desafiador em JavaScript, porque algumas APIs funcionam mesmo com alternativas únicas, enquanto outras falham.

Nesse caso, você está usando TextDecoder para decodificar de base64. Especificamente, os padrões de TextDecoder especificam o seguinte:

O padrão é false, o que significa que o decodificador substitui os dados incorretos por um caractere de substituição.

O caractere observado anteriormente, representado como \uFFFD em hexadecimal, é esse caractere de substituição. Em UTF-16, as strings com únicos alternativos são consideradas "malformadas" ou "inválidas".

Existem vários padrões da Web (exemplos 1, 2, 3, 4) que especificam exatamente quando uma string malformada afeta o comportamento da API, mas TextDecoder é uma dessas APIs. É uma prática recomendada garantir que as strings estejam bem formadas antes de fazer o processamento de texto.

Verificar se há strings bem formadas

Navegadores muito recentes agora têm uma função com essa finalidade: isWellFormed().

Compatibilidade com navegadores

  • 111
  • 111
  • 119
  • 16.4

Origem

Você pode alcançar um resultado semelhante usando encodeURIComponent(), que gera um erro URIError se a string tiver um alternativo único.

A função a seguir usará isWellFormed() se estiver disponível, e encodeURIComponent() se não estiver. Um código semelhante pode ser usado para criar um polyfill para 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;
    }
  }
}

Vamos colocar em prática

Agora que você sabe como lidar com Unicode e substitutos únicos, pode juntar tudo para criar um código que gerencie todos os casos e faça isso sem substituição de texto silencioso.

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

Há muitas otimizações que podem ser feitas nesse código, como a generalização em um polyfill, a mudança dos parâmetros TextDecoder para serem gerados em vez de substituições isoladas e muito mais.

Com esse conhecimento e código, você também pode tomar decisões explícitas sobre como lidar com strings malformadas. Por exemplo, rejeitar os dados, permitir explicitamente a substituição de dados ou talvez gerar um erro para análise posterior.

Além de ser um exemplo valioso de codificação e decodificação base64, esta postagem fornece um exemplo do motivo pelo qual o processamento de texto cuidadoso é particularmente importante, especialmente quando os dados de texto vêm de fontes geradas pelo usuário ou externas.