Декодирование Base64 в JavaScript/браузере: полное руководство
Она приходит в десятке разных обличий: JWT, спрятанный в заголовке Authorization, блоб image/png внутри JSON-ответа, значение Sec-WebSocket-Accept в логе рукопожатия, вложение письма, завёрнутое в MIME, значение, которое ваш бэкенд вежливо запихнул в строку запроса. Сама строка всегда выглядит одинаково: длинный ряд букв и цифр, изредка + или /, а в конце, возможно, один-два знака =. Если домашняя страница этого сайта рассказала вам, что такое Base64 - четыре печатаемых символа за каждые три байта, со знаками =, чтобы дописать последнюю группу, - то эта статья о том, что вы делаете на самом деле в коде: превращать эти символы обратно в байты, а байты - обратно в смысл, пользуясь только тем, что уже есть в браузере.
Прежде чем начать, два быстрых базовых правила. Во-первых, декодирование - это сжимающее направление: на каждые четыре прочитанных символа выходит три байта, поэтому результат всегда занимает в памяти меньше, чем вход. Во-вторых, декодированная Base64-строка не становится текстом автоматически. Это байты, и они могут оказаться UTF-8, Windows-1252, заголовком PNG или криптографической подписью. Самый частый баг в Base64-коде - забыть, что именно у вас в руках, поэтому разделы ниже построены вокруг этого вопроса.
Три слоя декодирования
Современные браузеры дают вам три нативных слоя, и хорошие новости в том, что ни один пакет не понадобится. Каждый отвечает на чуть свой вопрос, и правильный выбор избавит вас от кучи скопированных из Stack Overflow кусков кода:
| Слой | Что он ест | Что он вам отдаёт | Характер | Доступность |
|---|---|---|---|---|
atob() |
стандартная Base64-строка | «бинарная строка» (один байт на символ) | очень снисходительный: пропускает ASCII-пробельные символы, принимает отсутствующее заполнение | каждый браузер с 2000-х, IE 10 и выше, Node 16 и выше |
TextDecoder |
байты (Uint8Array) |
читаемый текст JavaScript | настраиваемый: ярлык кодировки, флаг fatal для строгости |
Firefox 18, Chrome 38, Safari 10.1 и новее (никогда в IE) |
Uint8Array.fromBase64() |
Base64-строка плюс опции | настоящий Uint8Array |
строгий, но с крутилками: алфавит и обработка последнего фрагмента | Baseline 2025: Chrome 140, Firefox 133, Safari 18.2, Node 25 |
Структура всей статьи следует из этой таблицы. atob() - рабочая лошадка, которую вы встретите повсюду, включая старый код. TextDecoder - мост от байтов к словам. А Uint8Array.fromBase64() - апгрейд 2025 года, который полностью пропускает промежуточный шаг, если вам и так нужны были только байты.
atob: быстрый, снисходительный и очень старый
Весь договор умещается в одну строку: atob(encodedData). Она принимает Base64-строку и возвращает «бинарную строку»: обычную JavaScript-строку, в которой каждый символ хранит ровно один декодированный байт, код от 0 до 255. Тип возвращаемого значения важен, потому что это не то же самое, что читаемый текст (об этом ниже). Сама функция - что может быть быстрее, - и существует уже очень давно: Chrome 4, Firefox 1, Safari 3, и - это помнят, пожалуй, все - Internet Explorer только начиная с версии 10, поэтому код, написанный до 2012 года, полон самописных таблиц Base64.
То, что делает atob() приятным, - сколько она прощает, прежде чем сдаться. Стандарт WHATWG HTML велит игнорировать все ASCII-пробельные символы - пробел, табуляцию, перевод строки, перевод формы, возврат каретки - перед декодированием, поэтому MIME-завёрнутая строка с переводами строк каждые 76 символов декодируется без какой-либо чистки с вашей стороны. Прощается и отсутствующее заполнение. Но стоит ей увидеть символ вне алфавита или длину, которая никогда не могла быть корректной, - и она бросает DOMException с именем InvalidCharacterError. Никакого молчаливого мусора, никаких частичных результатов.
Вот отчёт о повреждениях, построчно:
| Вход | Результат |
|---|---|
"SGVsbG8sIFdvcmxkIQ==" |
"Hello, World!" - классический случай |
"aGVsbG8" (без заполнителя) |
"hello" - отсутствующий = прощается |
"SGVs\nbG8s\nIFdvcmxkIQ==" (завёрнутые строки) |
"Hello, World!" - ASCII-пробельные символы пропускаются в первую очередь |
"" (пустая строка) |
"" - пустой вход корректен и проходит туда-обратно |
"A" (один оставшийся символ) |
бросает InvalidCharacterError - одним символом не закодировать ничего |
"Zm9vYmFy!" (лишний !) |
бросает InvalidCharacterError - вне алфавита |
"ZGFua29nYWk-" (подмешан URL-безопасный символ) |
бросает InvalidCharacterError - два алфавита смешивать нельзя |
"Zm9v====" (слишком много заполнения) |
бросает InvalidCharacterError - в конце не больше двух = |
Одна практическая заметка: само сообщение об ошибке различается в движках (Firefox говорит «String contains an invalid character», Chrome говорит, что строка «contains characters outside of the Latin1 range» для не-Latin1 входа или «is not correctly encoded» для некорректного Base64), поэтому ловите по имени исключения, а не по тексту сообщения.
От сырых байтов к настоящему тексту
Тип возвращаемого значения «бинарная строка» заслуживает остановки, потому что это источник большинства путаниц в декодировании. JavaScript-строки - это UTF-16, поэтому atob() отдаёт вам строку, в которой символы - это значения байтов, а не читаемые глифы. Если ваши полезные данные были UTF-8-кодировкой текста «hello 你好», прямой вывод результата даст вам кракозябры. Исправление - двухшаговое декодирование: из Base64 в байты, затем из байтов в текст.
Сначала шаг из Base64 в байты. Эта маленькая вспомогательная функция - классический рецепт, и её стоит держать в кармане, потому что это несущая деталь большинства примеров в этой статье:
function base64ToBytes (base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
Потом шаг из байтов в текст, с TextDecoder. Для UTF-8 (по умолчанию, и верный выбор для JSON, полезных данных JWT и большинства веб-данных) вызов - одна строка:
const bytes = base64ToBytes('aGVsbG8g5L2g5aW9');
const text = new TextDecoder('utf-8').decode(bytes);
console.log(text); // "hello 你好"
Зачем вообще два шага? Потому что atob() не имеет понятия, в какой кодировке были получены байты. Это чистый конвертер битов. TextDecoder - тот компонент, который интерпретирует байты как кодировку, и для работы он принимает ярлык: utf-8, windows-1252, iso-8859-1, utf-16le, плюс ещё около 220 ярлыков. Данные, пришедшие из приложения 1990-х, обычно Windows-1252, и достаточно одного аргумента конструктора:
const decoder = new TextDecoder('windows-1252');
const text = decoder.decode(bytes); // те же байты, другое толкование
Конструктор TextDecoder также принимает флаг fatal, и его стоит выставлять в true каждый раз, когда декодированный текст питает что-то важное. По умолчанию декодер снисходительный: некорректные последовательности байтов тихо заменяются символом замещения Unicode, U+FFFD, и вам об этом никогда не скажут. С fatal: true те же повреждения бросают TypeError вместо того, чтобы прятаться:
const strict = new TextDecoder('utf-8', { fatal: true });
try {
strict.decode(corruptedBytes);
} catch (error) {
console.log(error.name); // "TypeError"
}
Это одна из тех тумблеров, которые выглядят незначительными в документации, а в проде - как инцидент с данными. Если ваш вход приходит от пользователя или из сети, декодируйте строго и обрабатывайте ошибку осознанно.
URL-безопасный вход требует обхода
Один из вариантов Base64 заслуживает собственного раздела, потому что он постоянно встречается в дикой природе, а atob() его не прочитает. Это безопасный для URL и имён файлов алфавит из раздела 5 RFC 4648, обычно называемый base64url: те же 64 символа, только + и / заменены на - и _, а заполнение = часто отбрасывают, поскольку длина данных известна по умолчанию. Замена нужна по конкретной причине: в URL + означает пробел, а / начинает сегмент пути, так что стандартный алфавит пришлось бы процентно кодировать по символу. Base64url чисто путешествует в строках запроса, сегментах пути, фрагментах и именах файлов.
Подвох в том, что два алфавита не взаимозаменяемы, и atob() понимает только стандартный. Суньте ей - или _ - получите InvalidCharacterError. У вас есть два чистых варианта.
Вариант первый, который работает везде: преобразуйте алфавит и восстановите заполнение перед вызовом atob():
function fromUrlBase64 (segment) {
let s = segment.replace(/-/g, '+').replace(/_/g, '/');
const missing = (4 - (s.length % 4)) % 4;
return atob(s + '='.repeat(missing));
}
console.log(fromUrlBase64('aGVsbG8')); // "hello"
Выражение (4 - (s.length % 4)) % 4 - весь трюк: оно вычисляет, сколько символов = понадобилось бы строке этой длины с правильным заполнением, - от нуля до двух.
Вариант второй, в браузерах 2025 года и новее: новый нативный декодер принимает алфавит как опцию, так что никакой хирургии строк:
const bytes = Uint8Array.fromBase64('P3-0', { alphabet: 'base64url' });
console.log(Array.from(bytes).join(', ')); // "63, 127, 180"
Два правила спасают от неприятностей. Никогда не смешивайте алфавиты внутри одного значения - декодер, увидевший и +, и -, не может знать, какую семью он читает, и поведение в соответствии со спецификацией - упасть. И договоритесь с другой стороной провода о наличии заполнителя: отбрасывать его в base64url законно, так что получатель должен быть готов к обеим формам. atob() уже готова; нативные опции ниже дают вам крутилку для этого.
Шорткат 2025 года: Uint8Array.fromBase64
Если взглянуть на вспомогательную функцию base64ToBytes, станет видно, что она делает две вещи: декодирует Base64, а потом на JavaScript копирует символы в массив байтов по одному. Этот цикл копирования - медленная и устранимая часть, именно её убирает новый метод ECMAScript. Uint8Array.fromBase64(string, options) идёт напрямую от закодированной строки к массиву байтов, и он поставляется в Chrome 140, Edge 140, Firefox 133, Safari 18.2, Node 25 и Deno 2.5 - первая фича платформенного уровня для JavaScript из этого списка, отмеченная как Baseline Newly available в программе Baseline браузерных вендоров.
Объект опций имеет две крутилки. Первая - alphabet: "base64" (по умолчанию) или "base64url". Вторая - lastChunkHandling, которая управляет тем, что происходит с последней незавершённой группой символов:
| Режим | Правило для последнего фрагмента |
|---|---|
"loose" (по умолчанию) |
два или три символа, либо четыре с заполнением; оставшиеся переполняющие биты игнорируются |
"strict" |
ровно четыре символа (заполнение только там, где требует длина), и все переполняющие биты должны быть нулями |
"stop-before-partial" |
декодируются только полные группы по четыре символа; незавершённый хвост остаётся непрочитанным |
Как и atob(), метод игнорирует ASCII-пробельные символы на входе, так что завёрнутые строки в порядке. В отличие от atob(), он с характером по всем остальным пунктам: символ вне выбранного алфавита или последний фрагмент, нарушающий выбранный режим, бросают SyntaxError; если передать что-то не строку - TypeError. Вот режим strict в деле, отклоняющий фрагмент без заполнителя:
const ok = Uint8Array.fromBase64('SGVsbG8=', { lastChunkHandling: 'strict' });
try {
Uint8Array.fromBase64('VR', { lastChunkHandling: 'strict' });
} catch (error) {
console.log(error.name); // "SyntaxError"
}
Производительность - другая причина предпочитать её. На свежем Firefox на машине автора декодирование пелода в 10 мегабайт занимает однозначное число миллисекунд с fromBase64, тогда как классический atob плюс посимвольное отображение в байты занимает примерно в двадцать раз больше, потому что медленная часть - это цикл на уровне JavaScript, а не Base64-математика. Если ваши данные - байты, пропустите строку целиком.
Для старых браузеров ситуация простая: оставьте вспомогательную функцию base64ToBytes выше либо подключите небольшой полифилл (core-js и пакет es-arraybuffer-base64 из проекта es-shims оба поставляют его для fromBase64), если хотите писать код в новом стиле везде. API стабилен - он уже в спецификации ECMAScript, - так что всё, что вы напишете на его основе, не будет объявлено устаревшим.
Чтение JWT
Самая частая «таинственная строка» в логах приложений - это JSON Web Token: три сегмента, разделённые точками, header.payload.signature, где первые два - JSON-объекты, закодированные в base64url. Декодировать один - дело пяти строк, и это идеальная разминка для всего, что было до этого:
function jwtSegmentToBytes (segment) {
let s = segment.replace(/-/g, '+').replace(/_/g, '/');
s += '='.repeat((4 - (s.length % 4)) % 4);
return base64ToBytes(s);
}
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
const [header64, payload64] = token.split('.');
const payload = JSON.parse(new TextDecoder().decode(jwtSegmentToBytes(payload64)));
console.log(payload.name); // "John Doe"
Теперь та часть, которую новички пропускают, а продакшен-системы узнают самым трудным путём: пелод не проверяется тем, что его можно декодировать. Любой может написать JWT с каким угодно пелодом; именно сегмент подписи привязывает его к секрету. Проверка токена HS256 в браузере использует Web Crypto API, которому нужна подпись в виде байтов - ещё одна причина, по которой вспомогательная функция из сегмента в байты окупает себя:
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode('shared-secret'),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const [h, p, sig64] = token.split('.');
const valid = await crypto.subtle.verify(
'HMAC',
key,
jwtSegmentToBytes(sig64),
encoder.encode(h + '.' + p)
);
console.log(valid); // true, только если подпись совпадает с секретом
Три ловушки заслуживают имени. Во-первых, проверяйте заголовок перед тем, как верифицировать: токен, который утверждает alg: "none", просит вас доверять пелоду без подписи, и наивный код уже не раз давался на это. Во-вторых, учитывайте временные заявления - exp, nbf, iat - после проверки, а не до. В-третьих, классическая атака путаницы ключей: сервер, настроенный на RS256, но принимающий ещё и HS256, позволяет злоумышленнику подписывать токены публичным ключом (который публичен специально), используемым как секрет HMAC. Коротко: декодируйте свободно, не доверяйте ничему, проверяйте всё.
Открытие data URL
Data URL вмещает целый файл прямо внутри URL: data:, необязательный медиатип, необязательный флаг ;base64, запятая, а потом пелод. Текстовые нагрузки процентно кодируются, бинарные - в Base64, и браузер отрисовывает их без единого HTTP-запроса - ни fetch, ни круговое путешествие на сервер, ни кэшировать нечего. Браузер воспринимает каждый data URL как уникальное непрозрачное происхождение, а ещё поэтому это излюбленный вектор для хитрого контента: документ data:text/html, открытый в iframe, выполняет свои скрипты, а строгая Content-Security-Policy может заблокировать data URL совсем. Подумайте о CSP, если начнёте отдавать их разметке, управляемой пользователем.
Декодировать один - в основном хирургия строк, а дальше тот же байтовый конвейер, что и раньше:
const url = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADgQFY/fWoOgAAAABJRU5ErkJggg==';
const comma = url.indexOf(',');
const meta = url.slice(5, comma); // "image/png;base64"
const bytes = base64ToBytes(url.slice(comma + 1));
const blob = new Blob([bytes], { type: 'image/png' });
const objectUrl = URL.createObjectURL(blob);
Часть meta сообщает вам медиатип (здесь image/png, и маркер ;base64 подтверждает, что пелод - Base64). Как только пелод становится Blob, действует всё как обычно: object URL для <img>, ссылка для скачивания или отправка на сервер. Единственная настоящая цена пути через data URL - размер: пелод занимает примерно на 33 процента больше исходного файла, - и большое изображение в URL может перегрузить строковые лимиты страницы, что ещё один голос за object URL, когда файлу никогда не нужно выходить из браузера.
Декодирование файлов, которые приходят текстом
Файлы добираются до браузера двумя путями. Современный путь - сырые байты: fetch, который вы читаете как ArrayBuffer, или File из файлового выбора, который вы читаете через file.arrayBuffer(). Если вы на этом пути, поздравляю - тут вообще нет никакого Base64, и оставаться на этом пути стоит, потому что перенос байтов ничего не стоит, а Base64 берёт за эту привилегию лишнюю треть полосы пропускания и памяти. Другой путь - когда канал чисто текстовый: JSON API, возвращающий {"attachment": "data:application/pdf;base64,JVBERi..."}, вложение письма, строка конфигурации, значение в столбце базы данных. Тогда Base64 - и есть протокол, и ваша задача - просто вытащить из него байты:
async function loadRemoteBytes (fileUrl) {
const response = await fetch(fileUrl);
return new Uint8Array(await response.arrayBuffer());
}
const record = JSON.parse(await (await fetch('/api/record/42')).text());
const pdfBytes = base64ToBytes(record.attachment.split(',')[1]);
Три заметки к этому фрагменту. Разбиение по первой запятой - всё, что нужно, чтобы сорвать шапку data URL (в медиатипе не бывает запятых, поэтому первая всегда и есть разделитель). А если значение - чистый Base64 без префикса data URL, просто пропустите разбиение. И наконец, этот голый await - верхнеуровневый, а браузеры разрешают такие только внутри модулей, поэтому фрагменту нужен тег <script type="module"> или async-обёртка вокруг тех двух строк. MIME-части почты - та же история с дополнительными шагами: тело вложения - это Base64, завёрнутый по 76 символов в строку, но раз atob() пропускает пробельные символы, можно отдать ей завёрнутый текст ровно в том виде, в каком он пришёл в сыром сообщении, - разворачивать не надо. Это одно поведение тихо экономит кучу regex.
Проверка WebSocket-рукопожатия
Одно из более обаятельных применений декодирования в браузере - проверка самого WebSocket-рукопожатия. RFC 6455 требует, чтобы клиент прислал заголовок Sec-WebSocket-Key (16 случайных байтов, закодированных в Base64), а сервер ответил Sec-WebSocket-Accept: SHA-1-хэшем ключа, склеенного с фиксированным магическим GUID, тоже в Base64. Если значение не совпадает, рукопожатие падает, и соединение не повышается. Весь смысл этой церемонии в том, чтобы сервер, говорящий только HTTP, не мог случайно её завершить - магический GUID существует, чтобы вычисление намеренно выглядело избыточно запутанным. А раз в браузере есть и хэширование, и кодирование, вы можете сами вычислить ожидаемый ответ, и отладка прокси и шлюзов превращается в одну строчку:
const MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
async function expectedAccept (clientKey) {
const digest = await crypto.subtle.digest(
'SHA-1',
new TextEncoder().encode(clientKey + MAGIC)
);
return btoa(String.fromCharCode(...new Uint8Array(digest)));
}
const accept = await expectedAccept('dGhlIHNhbXBsZSBub25jZQ==');
console.log(accept); // "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
Последняя строка - не совпадение: это точный пример из RFC, воспроизведённый байт в байт. Когда ваш шлюз отвечает чем-нибудь другим, теперь вы точно знаете, какая сторона уравнения врёт.
HTTP-заголовки и строки запроса
Base64 - любимец HTTP-заголовков, потому что заголовки обязаны быть ASCII, и самый знаменитый случай - аутентификация Basic: Authorization: Basic за Base64-кодировкой username:password. Прочитать такой заголовок (например, показывая, что несёт запрос) - это одно разбиение и одно декодирование:
const header = 'Basic YWxpY2U6c2VjcmV0MTIz';
const [user, ...rest] = atob(header.slice(6)).split(':');
const password = rest.join(':');
console.log(user, password); // "alice secret123"
Приём «разбросать и собрать заново» справляется с неловким, но законным случаем пароля со знаком двоеточия, потому что точка разбиения всегда первая - после имени пользователя. Тот же приём работает везде, где заголовок контрабандой провозит структурированное значение: Proxy-Authorization, некоторые вендорские заголовки и изредка куки. В строках запроса и глубоких ссылках Base64 появляется, когда приложение хочет поделиться состоянием без сервера: значение OAuth state, восстановленная поисковая форма, метка «продолжить с того места, где остановился». Декодируйте защитно - обёрните в try/catch, потому что значение пересекло сетевую границу, и с ним могло случиться что угодно, - и относитесь к полученному как к недоверенному входу, точка.
И это приводит нас к фразе, которую стоит прибить над каждым терминалом: Base64 - это не шифрование. Это даже не маскирование в каком-то настоящем смысле, потому что «расшифровка» - один вызов функции, который реализует каждый язык на свете. Если значение должно оставаться секретным, предварительное кодирование в Base64 делает его менее, а не более безопасным: оно создаёт иллюзию конфиденциальности и добавляет ровно один тривиальный шаг для всех, кто хочет получить оригинал.
Состояние в URL и в хранилище
Та же логика распространяется на всё, что должно пережить перезагрузку страницы или ссылку для раздачи. Обычные подозреваемые: значения localStorage и sessionStorage, несущие структурированные или бинарные данные, хэш-фрагмент URL для состояния маршрутизации одностраничного приложения и конфигурационные блобы, встроенные в страницы сборочными инструментами. Истории хранилища стоит один конкретный пример, потому что сторона чтения образует пару со стороной записи, которую вы захотите запомнить:
const raw = localStorage.getItem('profile');
const profile = JSON.parse(new TextDecoder().decode(base64ToBytes(raw)));
Три вещи держать в голове. Во-первых, бюджеты: браузеры дают каждому происхождению примерно 5 мегабайт localStorage, а ваша сохранённая Base64-строка ест на 33 процентов больше исходных данных, так что файл в 3.5 мегабайта тихо становится 4.6 мегабайт хранилища, - а строка живёт в памяти как UTF-16, что вдвое увеличивает её след, пока страница открыта. Во-вторых, согласованность: кодируйте и декодируйте в одной кодировке на обеих сторонах, иначе сохраните полностью хорошие байты и прочтёте кракозябры. В-третьих, ссылки для раздачи: если состояние едет в URL, используйте URL-безопасный алфавит, чтобы значение переживало копирование, и держите его коротким, потому что длина URL больше пары тысяч символов начинает нервировать старые клиенты и инструменты логирования.
Когда данные приходят кусками
Иногда Base64 не приходит одной строкой: граница WebSocket-сообщения режет его пополам, поток событий, рассылаемых сервером, подливает его по капле, чанковая загрузка подаёт его по несколько килобайт за раз. Вы не можете вызвать atob() на фрагменте, потому что группы Base64 - это 3-байтовые единицы, выраженные 4-символьными блоками, и разрез посередине группы оставляет висеть незавершённый остаток. Старый добрый фикс - буферизовать символы, пока не наберётся кратное четырём число, и декодировать буфер слайсами. API 2025 года делает это чисто: Uint8Array.prototype.setFromBase64(string, options) записывает декодированные байты в существующий массив и возвращает объект с двумя числами, read (сколько символов он потребил) и written (сколько байтов он произвёл). С lastChunkHandling: "stop-before-partial" он декодирует только полные группы и оставляет незавершённый хвост непрочитанным, - ровно то поведение, которое хочет поточный декодер:
const parts = [];
let carry = '';
for (const piece of incomingPieces) {
let pending = carry + piece;
for (;;) {
const room = new Uint8Array(8);
const result = room.setFromBase64(pending, {
lastChunkHandling: 'stop-before-partial'
});
parts.push(room.subarray(0, result.written));
pending = pending.slice(result.read);
if (result.read === 0) {
carry = pending;
break;
}
}
}
const size = parts.reduce((sum, part) => sum + part.length, 0);
const bytes = new Uint8Array(size);
let at = 0;
for (const part of parts) {
bytes.set(part, at);
at += part.length;
}
const text = new TextDecoder().decode(bytes);
Читайте внутренний цикл медленно, потому что в нём весь паттерн: подайте перенесённый остаток плюс новый кусок, позвольте декодеру потребить столько полных групп, сколько влезло, запомните, сколько осталось, отрезав result.read символов, и когда полное уже не осталось (result.read === 0), отложите остаток как новый перенос и дождитесь следующего куска. Uint8Array(8) - всего лишь черновой буфер: одна группа из четырёх символов даёт в крайнем случае три байта, так что восемь - с запасом. В конце carry хранит всё то, что поток так и не довёл до конца, - это либо ваш сигнал об ошибке, либо проверка «соединение завершилось чисто».
Когда Base64 не стоит декодировать
Хорошая шпаргалка учит, когда инструмент надо отложить. Если вы управляете обеими концами канала, берите сырые байты: fetch с response.arrayBuffer() для скачиваний, file.arrayBuffer() для файлов из выбора, ArrayBuffer-нагрузки в WebSockets и multipart FormData для загрузок. Ничто из этого не трогает Base64, и вы получаете данные на полной скорости - без налога на размер и без следа строки в памяти. Base64 окупается ровно тогда, когда канал только текстовый: JSON-тела, строки запроса, почта, хранилище, легаси API и всё, чей договор говорит «ASCII или ничего». В тот момент, когда достаточно байта, Base64-строка платит 33-процентную наценку за привилегию быть печатаемой, и наценка списывается полосой пропускания, памятью и CPU - три счёта, все из которых можно не оплачивать.
Частые ловушки декодирования
После всех радужных путей - список того, как это кусается, примерно в том порядке, в котором вы с этим встретитесь:
- Отношение к результату
atob()как к тексту. Это бинарная строка. ЧерезTextDecoderона становится текстом; выведенная напрямую - кракозябрами. Одного этого заблуждения достаточно для большинства обращений «Base64 не работает». - Ожидание, что Unicode заработает сам. Байты «你好» декодируются с удовольствием, но они остаются байтами, пока декодер не скажет, что это UTF-8. Кодируйте и декодируйте в одной кодировке на обеих сторонах.
- Подача base64url в
atob(). Один-или_- и исключение. Сначала преобразуйте алфавит, либо используйтеfromBase64с правильной опцией. - Верование, что любая длинная строка - это Base64. Корректная Base64-строка с заполнителем имеет длину, кратную четырём (base64url без заполнителя может оканчиваться на 2 или 3), и использует не больше одного алфавита. Длина с остатком один по модулю четырёх - мгновенный провал: проверьте это, прежде чем тратить на него try/catch.
- Доверие заполнителю, о котором вы не договаривались. Некоторые системы срезывают
=, некоторые оставляют, а некоторые вставляют его посреди завёрнутой строки, где оно не положено. Договоритесь с отправителем, а потом решите, быть ли снисходительным (atob) или строгим (fromBase64). - Тихая порча от снисходительного декодера. Декодер
TextDecoderпо умолчанию заменяет некорректные байты U+FFFD и молчит. Выставьтеfatal: true, когда данные важны. - Предположение, что Base64 что-то защищает. Нет. Это формат сериализации, один вызов функции от обычного текста, и «мы кодируем в Base64, чтобы пользователи не могли прочитать» - это позиция в безопасности, а не мера контроля.
- Забывание про память. Декодированная бинарная строка в один мегабайт занимает два мегабайта как UTF-16-строка, тогда как
Uint8Arrayтех же данных - один. Для больших нагрузок идите сразу вfromBase64. - Повторное декодирование на каждом рендере. Декодировать несколько мегабайт быстро, но не бесплатно - и это не то, что делают раз на кадр. Декодируйте один раз, кэшируйте байты, рендерьте из кэша.
Заметки о производительности
Короткая версия: нативные декодеры быстрые, и медленная часть старого кода - обычно JavaScript вокруг них, а не сам Base64. На размерах, которые важны, картина та же: пелод в 10 мегабайт декодируется за однозначное число миллисекунд с Uint8Array.fromBase64; один atob медленнее в несколько раз, а классический доводящий цикл, отображающий символы в массив байтов, для того же входа занимает примерно в двадцать раз больше, чем fromBase64, потому что он прогоняет где-то тринадцать миллионов записей свойств на главном потоке. Практические последствия: предпочитайте fromBase64, где ваша аудитория его имеет; держите atob-вспомогательную функцию, где нет; никогда не стройте массив байтов конкатенацией строк в цикле; и если вам нужно обработать огромный груз, подумайте о том, чтобы передать декодированный Uint8Array в Web Worker - байты переносятся без копирования, а главный поток остаётся свободным, чтобы держать UI на 60 кадрах в секунду. И помните направление математики: декодирование сжимает, так что декодированный буфер всегда занимает в памяти меньше, чем строка, из которой он вышел. Из-за декодирования память не кончится никогда; память кончится, только если вы будете держать и строку, и байты дольше, чем нужно.
Краткая история декодирования в браузерах
Base64 старше большей части современного веба, но у декодеров браузеров есть история, которую стоит знать, потому что она объясняет, почему экосистема полна реликвий. atob и её сестра btoa старше той спецификации, которая теперь их покрывает: стандарт WHATWG HTML определил их только в феврале 2011 года, когда их давнее поведение в браузерах было вывернуто наизнанку и превращено в стандарт. Движки всё равно поставляли их рано: Firefox с версии 1 в 2004 году, Safari 3, Chrome 4. Internet Explorer совсем пропустил их до IE 10 в 2012 году, поэтому JavaScript до 2012 года - это музей самописного Base64: таблицы поиска, гимнастика с String.fromCharCode и знаменитое заклинание unescape(encodeURIComponent()) для Unicode, пара функций, объявленных устаревшими в языке, но продержавшихся в браузерах десятилетие чистой инерцией. Потом пришёл слой кодировок: TextEncoder и TextDecoder из стандарта Encoding появились между 2013 и 2017 годами (Firefox 18, Chrome 38, Safari 10.1 и ни в одном IE), наконец дав платформе принципиальный способ превращать байты в слова. Node.js, у которого atob и btoa как глобалов не было вплоть до версии 16 в 2021 году, прожил раннюю жизнь с Buffer и парой мелких npm-шимов. А потом кольцо замкнулось: Firefox 133 (ноябрь 2024) и Safari 18.2 (декабрь 2024) первыми поставили Uint8Array.fromBase64, toBase64 и друзей, а вторая половина 2025 года завершила набор, когда пришли Chrome 140 (сентябрь) и Node 25 (середина октября), и программа Baseline пометила их Newly available - первый раз, когда сам язык, а не веб-платформа, получил встроенный Base64. Формат, старый десятилетиями, стал фичей стандартной библиотеки языка, и следующие десять лет кода могут перестать таскать вспомогательные функции с места на место.
Весёлые факты
- Самый быстрый тест «а это вообще Base64?» во всей вселенной -
string.length % 4 === 0. Каждая корректная Base64-строка с заполнителем его проходит; всё остальное - чужак. atob('')возвращает''. Пустая строка - единственный вход без байтов, и она чисто проходит через весь конвейер туда-обратно - никакой особой ситуации, никогда.- Магический GUID WebSocket,
258EAFA5-E914-47DA-95CA-C5AB0DC85B11, - фиксированное значение, залитое в RFC, выбранное так, чтобы обычный HTTP-сервер никогда не смог случайно завершить рукопожатие. Это самая знаменитая константа в проектировании протоколов, которую никто никогда не генерирует. - Chrome и Firefox бросают одно и то же исключение при одном и том же сбое, но с разными сообщениями. Ловите по
error.name, а не по строке сообщения, иначе ваша обработка ошибок будет звучать с браузерным акцентом. - Одномегабайтная бинарная строка весит в памяти два мегабайта, потому что JavaScript-строки - это UTF-16: каждый декодированный байт едет в сопровождении байта неиспользованного запаса. У
Uint8Arrayтакого налога нет. - «Data URI» - название, отставшее в отставку. WHATWG переименовал его в «data URL» в рамках великой гармонизации URI в URL, поэтому вы встретите оба написания в спецификациях, постах и именах пакетов.
- RFC 4648 поставляет таблицу тестовых векторов - «f», «fo», «foo», «foob», «fooba», «foobar» и компания, каждый со своим известным кодированием, - по которой авторы декодеров сверяются уже двадцать лет. Если ваш декодер проходит эти строки, он почти наверняка корректен.
- Самая производимая Base64-строка в истории вычислительной техники - почти наверняка
aGVsbG8=, кодирование «hello». Каждая «с чего начать» статья-туториал, каждый тестовый набор и каждый ответ на Stack Overflow на планете вносят свой голос.
Подводим итоги
Итак, всё мастерство декодирования в браузере умещается на одной странице: atob() для быстрого, снисходительного и универсального декодирования; TextDecoder для превращения байтов в те слова, которые вам на самом деле нужны, с fatal: true, когда данные важны; и Uint8Array.fromBase64 для современного, строгого и быстрого пути, который пропускает строку целиком. Между ними варианты имеют имена и правила: base64url для всего, что путешествует в URL, заполнение, которое может быть, а может и не быть, пробельные символы, которые старый декодер тихо съедает. А под всем этим - две установки: байты - это не текст, и текст - это не секрет. Декодируйте осознанно, проверяйте, прежде чем доверять, и когда канал позволяет, пропустите Base64 и берите байты.
Другая половина пути - взять ваши байты и текст и превратить их в печатаемую строку, с которой всё это началось, - подробно разобрана в сопутствующем руководстве по кодированию Base64 в JavaScript, ссылка ниже.
Последнее обновление: 2026-09-08
Связанная статья: Кодирование Base64 в JavaScript/браузере: полное руководство