Кодировка (encoding) и декодирование (decoding) в Base64 — распространенный способ преобразования двоичных данных в безопасный текст. Он часто используется в Data URL, таких как встроенные (inline) изображения.
Прим. пер.: с помощью data URL можно решить проблему (ошибку) отсутствующей фавиконки в браузере.
<link rel="icon" href="data:." />
Что происходит при кодировке и декодировании в base64 строк в JS? В этой статье мы рассмотрим некоторые особенности и ловушки, связанные с этими процессами.
btoa() и atob()
Основными функциями для кодировки и декодирования строк в base64 в JS являются btoa() и atob(), соответственно. btoa()
(binary to ASCII) кодирует строку в base64, а atob()
(ASCII to binary) декодирует.
Пример:
// Обычная строка, состоящая из кодовых точек (code points) ниже 128.
const asciiString = 'hello';
// Работает
const asciiStringEncoded = btoa(asciiString);
console.log(`Encoded string: [${asciiStringEncoded}]`);
// Encoded string: [aGVsbG8=]
// Работает
const asciiStringDecoded = atob(asciiStringEncoded);
console.log(`Decoded string: [${asciiStringDecoded}]`);
// Decoded string: [hello]
К сожалению, как отмечается в статье MDN, это работает только со строками, содержащими символы ASCII или символы, которые могут быть представлены одним байтом. Другими словами, это не будет работать с Unicode.
Рассмотрим следующий пример:
// Строка, представляющая собой сочетание маленьких, средних и больших кодовых точек.
// Эта строка является валидным UTF-16.
// 'hello' состоит из кодовых точек ниже 128.
// '⛳' - это одна 16-битная кодовая единица.
// '❤️' - это две 16-битных кодовых единицы: U+2764 и U+FE0F (сердце и вариант (variant)).
// '????' - это 32-битная кодовая точка (U+1F9C0), которая также может быть представлена в виде суррогатной пары (surrogate pair) 16-битных кодовых единиц '\ud83e\uddc0'.
const validUTF16String = 'hello⛳❤️????';
// Не работает
try {
const validUTF16StringEncoded = btoa(validUTF16String);
console.log(`Encoded string: [${validUTF16StringEncoded}]`);
} catch (error) {
console.log(error);
// DOMException: Failed to execute 'btoa' on 'Window': The string to be encoded contains characters outside of the Latin1 range.
}
Любое эмодзи в строке приводит к возникновению ошибки. Почему юникод вызывает проблемы?
Рассмотрим, что такое строки в компьютерной науке и JS.
Строки в юникоде и JS
Юникод — это современный стандарт кодировки символов, когда каждому символу присваивается определенное число для обеспечения возможности использования символов в компьютерных системах. Для более глубокого погружения в юникод взгляните на эту статью W3C.
Примеры некоторых символов юникода и соответствующих им чисел:
- h — 104
- ñ — 241
- ❤ — 2764
- ❤️ — 2764 со скрытым модификатором 65039
- ⛳ — 9971
- ???? — 129472
Числа, представляющие символы, называются "кодовыми точками" (code points). Они напоминают адреса символов. В эмодзи красного сердца на самом деле две кодовых точки: одна для сердца, вторая для "варианта" цвета — красного. Подробнее о селекторах варианта можно почитать здесь.
Юникод может преобразовывать эти кодовые точки в последовательность байтов двумя способами: UTF-8 и UTF-16.
В двух словах:
- в UTF-8 кодовая точка может использовать от 1 до 4 байт (по 8 бит на байт)
- в UTF-16 кодовая точка всегда использует 2 байта (16 бит)
Важно: JS всегда обрабатывает строки как UTF-16. Это ломает такие функции как btoa()
, которые ожидают, что каждый символ строки будет использовать один байт. Цитата с MDN:
Метод btoa()
создает закодированную в Base64 строку ASCII из двоичной строки (т.е. строки, каждый символ которой рассматривается как байт двоичных данных).
Теперь мы знаем, что символы в JS часто требуют больше одного байта. Но как быть с кодировкой и декодированием таких символов в base64?
btoa() и atob() с юникодом
В ранее упоминавшейся статье на MDN имеется сниппет для решения "проблемы юникода":
function base64ToBytes(base64) {
const binString = atob(base64);
return Uint8Array.from(binString, (m) => m.codePointAt(0));
}
function bytesToBase64(bytes) {
const binString = String.fromCodePoint(...bytes);
return btoa(binString);
}
const validUTF16String = 'hello⛳❤️????';
// Работает
const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
console.log(`Encoded string: [${validUTF16StringEncoded}]`);
// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA]
// Работает
const validUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(validUTF16StringEncoded));
console.log(`Decoded string: [${validUTF16StringDecoded}]`);
// Decoded string: [hello⛳❤️????]
Вот что делает этот код:
- Интерфейс TextEncoder используется для преобразования строки UTF-16 в поток байтов UTF-8 с помощью метода TextEncoder.encode().
- Этот метод возвращает Uint8Array, редко используемый тип данных в JS, являющийся подклассом TypedArray.
- Функция
bytesToBase64()
принимает этотUint8Array
и использует метод String.fromCodePoint() для преобразования каждого байта массива в кодовую точку и создания строки. Результатом является строка кодовых точек, каждая из которых может быть представлена одним байтом. - Наконец, эта строка передается функции
btoa()
для кодировки в base64.
Процесс декодирования делает тоже самое, но в обратном порядке.
Это работает, поскольку преобразование строки в Uint8Array
гарантирует, что хотя строки в JS представлены как UTF-16, т.е. двумя байтами, кодовая точка, которую представляет каждый байт, всегда меньше 128.
Это работает в большинстве случаев, но есть один нюанс.
Случай тихого провала
Рассмотрим следующий пример:
function base64ToBytes(base64) {
const binString = atob(base64);
return Uint8Array.from(binString, (m) => m.codePointAt(0));
}
function bytesToBase64(bytes) {
const binString = String.fromCodePoint(...bytes);
return btoa(binString);
}
// '\uDE75' - это кодовая точка, которая является половиной суррогатной пары.
const partiallyInvalidUTF16String = 'hello⛳❤️????\uDE75';
// Работает
const partiallyInvalidUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(partiallyInvalidUTF16String));
console.log(`Encoded string: [${partiallyInvalidUTF16StringEncoded}]`);
// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA77+9]
// Работает, но неправильно
const partiallyInvalidUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(partiallyInvalidUTF16StringEncoded));
console.log(`Decoded string: [${partiallyInvalidUTF16StringDecoded}]`);
// Обратите внимание на последний символ
// Decoded string: [hello⛳❤️????�]
Если мы возьмем последний символ после декодирования строки (�) и проверим его шестнадцатеричное (HEX) значение, то получим \uFFFD
вместо оригинального \uDE75
. Ошибки не возникло, но символ изменился. Почему?
Обработка строк JavaScript
Как упоминалось ранее, JS обрабатывает строки как UTF-16. Но у таких строк есть одно уникальное свойство.
Рассмотрим в качестве примера эмодзи сыра. Кодовой точкой юникода этого эмодзи (????) является 129472
. Но максимальным значение 16-битного числа является 65535
! Как представляются большие числа в UTF-16?
Для этого используется концепция суррогатной пары (surrogate pair). Об этом можно думать следующим образом:
- первое число пары определяет "книгу" для поиска. Это называется "суррогатом"
- второе число пары — это запись в "книге"
Как вы понимаете, наличие книги без записи — это проблема. В UTF-16 это называется одиноким суррогатом (lone surrogate).
Одни интерфейсы JS умеют работать с одинокими суррогатами, другие нет.
В нашем случае для декодирования строки используется TextDecoder
. В качестве второго опционального параметра конструктор TextDecoder()
принимает объект с настройками, одной из которых является fatal — логическое значение, являющееся индикатором того, должен ли метод decode()
выбрасывать TypeError
при декодировании невалидных данных. По умолчанию эта настройка имеет значение false
. Это означает, что по умолчанию искаженные (malformed) данные заменяются символом замены (replacement character).
Символ �, представленный HEX-значением \uFFFD
является символом замены. В UTF-16 строки с одинокими суррогатами считаются "искаженными" или "плохо сформированными".
Существуют разные веб-стандарты (например, 1, 2, 3, 4), определяющие поведение интерфейса при обработке искаженных строк, и TextDecoder
является одним из таких интерфейсов. Поэтому проверка правильной формы строки при обработке текста считается хорошей практикой.
Проверка формы строки
Для этой цели все современные браузеры предоставляют функцию String.isWellFormed() (поддержка — 79,65%).
Того же можно добиться с помощью метода encodeURIComponent(), который выбрасывает URIError, если строка содержит одинокий суррогат.
Следующая функция использует isWellFormed()
, если она доступна, и encodeURIComponent()
в противном случае:
function isWellFormed(str) {
if (typeof str.isWellFormed !== "undefined") {
return str.isWellFormed();
} else {
try {
encodeURIComponent(str);
return true;
} catch (error) {
return false;
}
}
}
Все вместе
Полный сниппет кодировки и декодирования строк JS в base64 с учетом юникода и одиноких суррогатов выглядит следующим образом:
function base64ToBytes(base64) {
const binString = atob(base64);
return Uint8Array.from(binString, (m) => m.codePointAt(0));
}
function bytesToBase64(bytes) {
const binString = String.fromCodePoint(...bytes);
return btoa(binString);
}
function isWellFormed(str) {
if (typeof str.isWellFormed !== "undefined") {
return str.isWellFormed();
} else {
try {
encodeURIComponent(str);
return true;
} catch (error) {
return false;
}
}
}
const validUTF16String = 'hello⛳❤️????';
const partiallyInvalidUTF16String = 'hello⛳❤️????\uDE75';
if (isWellFormed(validUTF16String)) {
const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
console.log(`Encoded string: [${validUTF16StringEncoded}]`);
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}]`);
}
Videoman
Цитата:
И тут же противоречие:
Вывод: В UTF-16 кодовая точка, также как и в UTF-8, занимает максимум 4 байта.