Кодирование и декодирование 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}]`);
Следующие шаги объясняют, что делает этот код для кодирования строки:
- Используйте интерфейс
TextEncoder
, чтобы взять строку JavaScript в кодировке UTF-16 и преобразовать ее в поток байтов в кодировке UTF-8 с помощьюTextEncoder.encode()
. - Это возвращает
Uint8Array
, который является менее часто используемым типом данных в JavaScript и является подклассомTypedArray
. - Возьмите этот
Uint8Array
и передайте его функцииbytesToBase64()
, которая используетString.fromCodePoint()
для обработки каждого байта вUint8Array
как кодовой точки и создания из него строки, в результате чего получается строка кодовых точек, которые могут быть все быть представлено в виде одного байта. - Возьмите эту строку и используйте
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()
.
Вы можете добиться аналогичного результата, используя 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, он также показывает, почему тщательная обработка текста особенно важна, особенно когда текстовые данные поступают из пользовательских или внешних источников.