Нюансы base64-кодирования строк в JavaScript
Что происходит, когда вы применяете кодирование и декодирование base64 к строкам в JavaScript? В этой заметке рассматриваются нюансы и типичные "подводные камни", которых следует избегать.
btoa()
и atob()
Основными функциями для кодирования и декодирования base64 в JavaScript являются btoa()
и atob()
. btoa()
преобразует строку в base64-кодированную строку, а atob()
декодирует обратно.
Далее приведён быстрый пример:
// Действительно простая строка, состоящая только из кодовых точек меньше 128.
const asciiString = 'hello';
// Это будет работать. Она напечатает:
// Encoded string: [aGVsbG8=]
const asciiStringEncoded = btoa(asciiString);
console.log(`Encoded string: [${asciiStringEncoded}]`);
// Это будет работать. Она напечатает:
// Decoded string: [hello]
const asciiStringDecoded = atob(asciiStringEncoded);
console.log(`Decoded string: [${asciiStringDecoded}]`);
К сожалению, как отмечается в документации MDN, это работает только со строками, содержащими символы ASCII, или символы, которые могут быть представлены одним байтом. Другими словами, это не работает с Unicode.
Чтобы посмотреть, что произойдёт, попробуйте выполнить следующий код:
// Образец строки, представляющий собой комбинацию малых, средних и больших кодовых точек.
// Данный образец строки имеет валидный формат UTF-16.
// 'hello' имеет кодовые точки, каждая из которых меньше 128.
// '⛳' - это одна 16-разрядная кодовая единица.
// '❤️' - это две 16-битные кодовые единицы, U+2764 и U+FE0F (сердце и вариант).
// '🧀' - это 32-битная кодовая точка (U+1F9C0), которая также может быть представлена как суррогатная пара двух 16-битных кодовых единиц '\ud83e\uddc0'.
const validUTF16String = 'hello⛳❤️🧀';
// Это не будет работать. Будет напечатано:
// 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);
}
Любой из emojis в строке приведёт к ошибке. Почему Unicode вызывает эту проблему?
Чтобы понять это, давайте сделаем шаг назад и разберёмся со строками, как в информатике, так и в JavaScript.
Строки в Unicode и JavaScript
Unicode — это современный глобальный стандарт кодирования символов, т.е. присвоения номера определённому символу для использования его в компьютерных системах. Для более глубокого изучения Unicode обратитесь к этой статье W3C.
Примеры символов в Unicode и соответствующих им номеров:
- h — 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);
}
// Образец строки, представляющий собой комбинацию малых, средних и больших кодовых точек.
// Данный образец строки имеет валидный формат UTF-16.
// 'hello' имеет кодовые точки, каждая из которых меньше 128.
// '⛳' - это одна 16-разрядная кодовая единица.
// '❤️' - это две 16-битные кодовые единицы, U+2764 и U+FE0F (сердце и вариант).
// '🧀' - это 32-битная кодовая точка (U+1F9C0), которая также может быть представлена как суррогатная пара двух 16-битных кодовых единиц '\ud83e\uddc0'.
const validUTF16String = 'hello⛳❤️🧀';
// Это будет работать. Она напечатает:
// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA]
const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
console.log(`Encoded string: [${validUTF16StringEncoded}]`);
// Это будет работать. Она напечатает:
// 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);
}
// Образец строки, представляющий собой комбинацию малых, средних и больших кодовых точек.
// Данный образец строки имеет валидный формат UTF-16.
// 'hello' имеет кодовые точки, каждая из которых меньше 128.
// '⛳' - это одна 16-разрядная кодовая единица.
// '❤️' - это две 16-битные кодовые единицы, U+2764 и U+FE0F (сердце и вариант).
// '🧀' - это 32-битная кодовая точка (U+1F9C0), которая также может быть представлена как суррогатная пара двух 16-битных кодовых единиц '\ud83e\uddc0'.
// '\uDE75' - кодовая единица, являющаяся половиной суррогатной пары.
const partiallyInvalidUTF16String = 'hello⛳❤️🧀\uDE75';
// Это будет работать. Она напечатает:
// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA77+9]
const partiallyInvalidUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(partiallyInvalidUTF16String));
console.log(`Encoded string: [${partiallyInvalidUTF16StringEncoded}]`);
// Это будет работать. Она напечатает:
// Decoded string: [hello⛳❤️🧀�]
const partiallyInvalidUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(partiallyInvalidUTF16StringEncoded));
console.log(`Decoded string: [${partiallyInvalidUTF16StringDecoded}]`);
Если взять последний символ после декодирования (�
) и проверить его шестнадцатеричное значение, то окажется, что это \uFFFD
, а не исходное \uDE75
. При этом не происходит сбоя или ошибки, но входные и выходные данные молча изменились. Почему?
Строки зависят от JavaScript API
Как было описано ранее, JavaScript обрабатывает строки в формате UTF-16. Но строки UTF-16 обладают уникальным свойством.
В качестве примера можно привести эмодзи "сыр". Этот эмодзи (🧀) имеет кодовую точку Unicode 129472. К сожалению, максимальное значение 16-битного числа равно 65535! Как же UTF-16 представит это гораздо большее число?
В UTF-16 существует понятие, называемое суррогатными парами. Её можно представить себе следующим образом:
- Первое число в паре указывает, в какой "книге" производить поиск. Это называется "суррогат".
- Второе число в паре — это запись в "книге".
Как вы можете себе представить, иногда может возникнуть проблема, когда номер обозначает только книгу, но не саму запись в этой книге. В UTF-16 это называется одиночным суррогатом.
Это особенно сложно в JavaScript, поскольку некоторые API работают, несмотря на наличие одиноких суррогатов, а другие — нет.
В данном случае при обратном декодировании из base64 используется TextDecoder. В частности, в настройках по умолчанию для TextDecoder указано следующее:
По умолчанию он имеет значение
false
, что означает, что декодер заменяет неправильно сформированные данные символом замены.
Замеченный ранее символ �, который в шестнадцатеричном виде представляется как \uFFFD
, и есть тот самый символ замены. В UTF-16 строки с одинокими суррогатами считаются "неверно сформированными"/"malformed" или "неправильно сформированными"/"not well formed".
Существуют различные веб-стандарты (примеры 1, 2, 3, 4), которые точно определяют, когда неправильно сформированная строка влияет на поведение API, но, в частности, TextDecoder
является одним из таких API. Перед обработкой текста рекомендуется убедиться в том, что строки правильно сформированы.
Проверка правильности формирования строк
В последних версиях браузеров для этой цели предусмотрена функция isWellFormed()
.
Аналогичного результата можно добиться с помощью функции encodeURIComponent()
, которая выбрасывает ошибку URIError
, если строка содержит одинокий суррогат.
Следующая функция использует isWellFormed()
, если она доступна, и encodeURIComponent()
, если её нет. Аналогичный код может быть использован для создания полифилла для функции isWellFormed()
.
// Быстрый полифилл, поскольку старые браузеры не поддерживают функцию isWellFormed().
// encodeURIComponent() выдаёт ошибку для одиноких суррогатов, что, по сути, одно и то же.
function isWellFormed(str) {
if (typeof(str.isWellFormed)!="undefined") {
// Используем новую функцию isWellFormed().
return str.isWellFormed();
} else {
// Используем старую функцию 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);
}
// Быстрый полифилл, поскольку старые браузеры не поддерживают функцию isWellFormed().
// encodeURIComponent() выдаёт ошибку для одиноких суррогатов, что, по сути, одно и то же.
function isWellFormed(str) {
if (typeof(str.isWellFormed)!="undefined") {
// Используем новую функцию isWellFormed().
return str.isWellFormed();
} else {
// Используем старую функцию encodeURIComponent().
try {
encodeURIComponent(str);
return true;
} catch (error) {
return false;
}
}
}
const validUTF16String = 'hello⛳❤️🧀';
const partiallyInvalidUTF16String = 'hello⛳❤️🧀\uDE75';
if (isWellFormed(validUTF16String)) {
// Это будет работать. Она напечатает:
// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA]
const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
console.log(`Encoded string: [${validUTF16StringEncoded}]`);
// Это будет работать. Она напечатает:
// Decoded string: [hello⛳❤️🧀]
const validUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(validUTF16StringEncoded));
console.log(`Decoded string: [${validUTF16StringDecoded}]`);
} else {
// В данном примере не достигается.
}
if (isWellFormed(partiallyInvalidUTF16String)) {
// В данном примере не достигается.
} else {
// Это не корректно сформированная строка, поэтому мы обрабатываем этот случай.
console.log(`Cannot process a string with lone surrogates: [${partiallyInvalidUTF16String}]`);
}
В этот код можно внести множество оптимизаций, таких как обобщение в полифилл, изменение параметров TextDecoder
на выбрасывание ошибки вместо молчаливой замены одиноких суррогатов и т.д.
С помощью этих знаний и кода можно также принимать явные решения о том, как обрабатывать неправильно сформированные строки, например, отвергать данные или явно разрешать замену данных, или, возможно, выбрасывать ошибку для последующего анализа.
Помимо того, что эта заметка является ценным примером кодирования и декодирования base64, она показывает, почему тщательная обработка текста особенно важна, особенно если текстовые данные поступают из пользовательских или внешних источников.