Нюансы кодирования строк base64 в JavaScript

Кодирование и декодирование base64 — это распространенная форма преобразования двоичного содержимого для представления в виде безопасного для Интернета текста. Обычно он используется для URL-адресов данных , например встроенных изображений.

Что происходит, когда вы применяете кодировку и декодирование Base64 к строкам в JavaScript? В этом посте рассматриваются нюансы и распространенные ошибки, которых следует избегать.

Основными функциями кодирования и декодирования base64 в JavaScript являются btoa() и atob() . btoa() переходит из строки в строку в кодировке Base64, а atob() декодирует обратно.

Ниже приведен краткий пример:

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

К сожалению, как отмечено в документации MDN , это работает только со строками, содержащими символы ASCII или символы, которые могут быть представлены одним байтом. Другими словами, это не будет работать с Unicode.

Чтобы увидеть, что произойдет, попробуйте следующий код:

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

Любой из смайлов в строке вызовет ошибку. Почему Unicode вызывает эту проблему??

Чтобы понять это, давайте сделаем шаг назад и разберемся со строками как в информатике, так и в JavaScript.

Строки в Юникоде и JavaScript

Unicode — это текущий глобальный стандарт кодировки символов или практика присвоения номера определенному символу, чтобы его можно было использовать в компьютерных системах. Для более глубокого изучения Unicode посетите эту статью W3C .

Некоторые примеры символов Юникода и связанных с ними чисел:

  • ч - 104
  • с - 241
  • ❤ - 2764
  • ❤️ - 2764 со скрытым модификатором под номером 65039.
  • ⛳ - 9971
  • 🧀 - 129472

Числа, представляющие каждый символ, называются «кодовыми точками». Вы можете думать о «кодовых точках» как об адресе каждого символа. В эмодзи красного сердца на самом деле есть две кодовые точки: одна для сердца, а другая для «изменения» цвета и создания всегда красного цвета.

В Unicode есть два распространенных способа преобразования этих кодовых точек в последовательности байтов, которые компьютеры могут последовательно интерпретировать: UTF-8 и UTF-16.

Упрощенный взгляд таков:

  • В UTF-8 кодовая точка может использовать от одного до четырех байтов (8 бит на байт).
  • В UTF-16 кодовая точка всегда состоит из двух байтов (16 бит).

Важно отметить, что JavaScript обрабатывает строки как UTF-16. Это нарушает работу таких функций, как btoa() , которые эффективно работают в предположении, что каждый символ в строке отображается в один байт. Об этом прямо сказано в MDN :

Метод btoa() создает строку ASCII в кодировке Base64 из двоичной строки (т. е. строки, в которой каждый символ строки рассматривается как байт двоичных данных).

Теперь вы знаете, что символы в JavaScript часто требуют более одного байта. В следующем разделе показано, как обрабатывать этот случай при кодировании и декодировании Base64.

btoa() и atob() с Unicode

Как вы теперь знаете, возникающая ошибка связана с тем, что наша строка содержит символы, находящиеся за пределами одного байта в UTF-16.

К счастью, статья MDN о base64 содержит несколько полезных примеров кода для решения этой «проблемы Unicode». Вы можете изменить этот код для работы с предыдущим примером:

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

Следующие шаги объясняют, что делает этот код для кодирования строки:

  1. Используйте интерфейс TextEncoder , чтобы взять строку JavaScript в кодировке UTF-16 и преобразовать ее в поток байтов в кодировке UTF-8 с помощью TextEncoder.encode() .
  2. Это возвращает Uint8Array , который является менее часто используемым типом данных в JavaScript и является подклассом TypedArray .
  3. Возьмите этот Uint8Array и передайте его функции bytesToBase64() , которая использует String.fromCodePoint() для обработки каждого байта в Uint8Array как кодовой точки и создания из него строки, в результате чего получается строка кодовых точек, которые могут быть все быть представлено в виде одного байта.
  4. Возьмите эту строку и используйте btoa() для ее кодирования в base64.

Процесс декодирования аналогичен, но в обратном порядке.

Это работает, поскольку шаг между Uint8Array и строкой гарантирует, что, хотя строка в JavaScript представлена ​​как двухбайтовая кодировка UTF-16, кодовая точка, которую представляют каждые два байта, всегда меньше 128.

Этот код работает хорошо в большинстве случаев, но в других он просто терпит неудачу.

Случай тихого сбоя

Используйте тот же код, но с другой строкой:

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

Если вы возьмете этот последний символ после декодирования (�) и проверите его шестнадцатеричное значение, вы обнаружите, что это \uFFFD а не исходный \uDE75 . Он не дает сбоя и не выдает ошибку, но входные и выходные данные незаметно изменились. Почему?

Строки различаются в зависимости от API JavaScript.

Как описано ранее, JavaScript обрабатывает строки как UTF-16. Но строки UTF-16 обладают уникальным свойством.

Возьмем, к примеру, смайлик с сыром. Эмодзи (🧀) имеет код Unicode 129472 . К сожалению, максимальное значение 16-битного числа — 65535! Так как же UTF-16 представляет это гораздо большее число?

В UTF-16 есть концепция, называемая суррогатными парами . Вы можете думать об этом так:

  • Первое число в паре указывает, в какой «книге» производить поиск. Это называется « суррогатом ».
  • Второе число в паре — запись в «книге».

Как вы можете себе представить, иногда может быть проблематично иметь только число, обозначающее книгу, но не фактическую запись в этой книге. В UTF-16 это называется одиноким суррогатом .

Это особенно сложно в JavaScript, поскольку некоторые API работают, несмотря на наличие одиночных суррогатов, а другие терпят неудачу.

В этом случае вы используете TextDecoder при обратном декодировании из base64. В частности, значения по умолчанию для TextDecoder указывают следующее:

По умолчанию он имеет значение false , что означает, что декодер заменяет неверные данные символом замены.

Символ �, который вы видели ранее и который представлен в шестнадцатеричном виде как \uFFFD , является этим символом замены. В UTF-16 строки с одиночными суррогатами считаются «неправильно сформированными» или «неправильно сформированными».

Существуют различные веб-стандарты (примеры 1 , 2 , 3 , 4 ), которые точно определяют, когда неверная строка влияет на поведение API, но, в частности, TextDecoder является одним из таких API. Перед обработкой текста рекомендуется убедиться, что строки имеют правильный формат.

Проверьте правильность формирования строк

В самых последних браузерах для этой цели появилась функция: isWellFormed() .

Поддержка браузера

  • Хром: 111.
  • Край: 111.
  • Фаерфокс: 119.
  • Сафари: 16.4.

Источник

Вы можете добиться аналогичного результата, используя encodeURIComponent() , который выдает ошибку URIError , если строка содержит единственный суррогат.

Следующая функция использует isWellFormed() , если она доступна, и encodeURIComponent() если нет. Подобный код можно использовать для создания полифилла для 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;
    }
  }
}

Сложите все это вместе

Теперь, когда вы знаете, как обрабатывать как Unicode, так и одиночные суррогаты, вы можете собрать все вместе и создать код, который обрабатывает все случаи и делает это без автоматической замены текста.

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

В этом коде можно выполнить множество оптимизаций, таких как обобщение в полифил, изменение параметров TextDecoder для выдачи вместо автоматической замены одиночных суррогатов и многое другое.

Обладая этими знаниями и кодом, вы также можете принимать явные решения о том, как обрабатывать неверные строки, например отклонять данные или явно включать замену данных или, возможно, выдавать ошибку для последующего анализа.

Помимо того, что этот пост является ценным примером кодирования и декодирования base64, он также показывает, почему тщательная обработка текста особенно важна, особенно когда текстовые данные поступают из пользовательских или внешних источников.