[UNICODE] 9 мин чтения

[UNICODE] Base64 для UTF-8 и Unicode

btoa('héllo') падает. btoa('日本語') падает. Это не баг — это API из 1990-х встречается с текстом 2020-х. Ниже — правильный путь кодировать Unicode в Base64, байт за байтом.

Апрель 2026 | encoding

// ЛОВУШКА

btoa() в браузере выглядит невинно. Передаёте строку — получаете Base64. И однажды пользователь вводит своё имя — Renée — и код падает с InvalidCharacterError: The string to be encoded contains characters outside of the Latin1 range.

Это не причуда и не вина одного лишь JavaScript. btoa() специфицировали в эпоху, когда «строка» и «байты» считались одним и тем же, пока вы не выходили за U+00FF. Unicode завоевал мир, а контракт btoa() остался заморожен в Latin-1. Чтобы закодировать в Base64 что-то, помимо западноевропейских символов, нужно сперва сделать крюк через UTF-8.

// ИСПРАВЛЕНИЕ ЗА 30 СЕКУНД

// Browser — encode any Unicode string to Base64
function encodeUtf8Base64(str) {
  const bytes = new TextEncoder().encode(str);           // UTF-8 bytes
  let bin = '';
  for (const b of bytes) bin += String.fromCharCode(b);  // Latin-1 wrapper
  return btoa(bin);                                       // now btoa can handle it
}

// Browser — decode Base64 back to a Unicode string
function decodeUtf8Base64(b64) {
  const bin = atob(b64);
  const bytes = Uint8Array.from(bin, c => c.charCodeAt(0));
  return new TextDecoder().decode(bytes);                // UTF-8 → string
}

// Usage
encodeUtf8Base64('Renée');         // 'UmVuw6ll'
encodeUtf8Base64('日本語');         // '5pel5pys6Kqe'
encodeUtf8Base64('👋 hello');       // '8J+RiyBoZWxsbw=='

decodeUtf8Base64('5pel5pys6Kqe');   // '日本語'

// ПОЧЕМУ ЭТО РАБОТАЕТ — КОРОТКИЙ КРЮК ЧЕРЕЗ UTF-8

Строки JavaScript — это последовательности кодовых единиц UTF-16. Символ — это U+65E5, то есть одна 16-битная кодовая единица. Эмодзи 👋 (U+1F44B) находится за пределами Basic Multilingual Plane и представлен суррогатной парой из двух 16-битных единиц.

btoa() нужны байты, а не кодовые единицы. И не любые байты, а именно байты Latin-1 в диапазоне 0–255. Кодовые единицы UTF-16 регулярно превышают 255 (например, = 0x65E5 = 26085), поэтому btoa() падает.

UTF-8 — это 8-битно-чистая кодировка всего набора символов Unicode. Любой Unicode-символ превращается в 1–4 байта, и каждый байт гарантированно лежит в диапазоне 0–255. Если мы сначала переводим строку в байты UTF-8, а затем обманом подсовываем их btoa() как Latin-1-строку (один символ на байт), всё сходится: btoa() видит знакомые ему байты, а получатель декодирует те же байты обратно в UTF-8.

// ЧТО TextEncoder ДЕЛАЕТ ПОД КАПОТОМ

TextEncoder — это стандартный способ превратить JavaScript-строку в байты UTF-8. Он есть во всех браузерах примерно с 2017 года и является глобалом в Node.js 11+.

Быстрый взгляд на генерируемые им байтовые последовательности:

new TextEncoder().encode('A');
// Uint8Array [ 0x41 ]                         (1 byte)

new TextEncoder().encode('é');
// Uint8Array [ 0xC3, 0xA9 ]                   (2 bytes)

new TextEncoder().encode('日');
// Uint8Array [ 0xE6, 0x97, 0xA5 ]             (3 bytes)

new TextEncoder().encode('👋');
// Uint8Array [ 0xF0, 0x9F, 0x91, 0x8B ]       (4 bytes)

new TextEncoder().encode('Renée');
// Uint8Array [ 0x52, 0x65, 0x6E, 0xC3, 0xA9, 0x65 ]   (6 bytes for 5 chars)

// NODE.JS: Buffer ЗНАЕТ ПРО UTF-8 ПО УМОЛЧАНИЮ

Класс Buffer в Node выполняет преобразование в UTF-8 внутри. TextEncoder вам не нужен; передача строки в Buffer.from() кодирует её как UTF-8, если вы явно не укажете другую кодировку. Поэтому серверный Base64 в Node — это однострочник:

// Encode
Buffer.from('日本語').toString('base64');
// '5pel5pys6Kqe'

// Decode
Buffer.from('5pel5pys6Kqe', 'base64').toString();
// '日本語'

// Explicit UTF-8 (the default, but clearer)
Buffer.from('Renée', 'utf8').toString('base64');
// 'UmVuw6ll'

// UNICODE BASE64 В КАЖДОМ ЯЗЫКЕ

// Python
import base64
base64.b64encode('日本語'.encode('utf-8'))
# b'5pel5pys6Kqe'
base64.b64decode('5pel5pys6Kqe').decode('utf-8')
# '日本語'

// PHP
base64_encode('日本語');          // '5pel5pys6Kqe'
base64_decode('5pel5pys6Kqe');     // '日本語'
// (PHP strings are byte strings, so UTF-8 is handled implicitly
// if your source file is saved as UTF-8.)

// Java
import java.util.Base64;
import java.nio.charset.StandardCharsets;
Base64.getEncoder().encodeToString("日本語".getBytes(StandardCharsets.UTF_8));
// '5pel5pys6Kqe'
new String(Base64.getDecoder().decode("5pel5pys6Kqe"), StandardCharsets.UTF_8);
// '日本語'

// Go
import "encoding/base64"
base64.StdEncoding.EncodeToString([]byte("日本語"))
// '5pel5pys6Kqe'
b, _ := base64.StdEncoding.DecodeString("5pel5pys6Kqe")
string(b)
// '日本語'

// Ruby (source file must be UTF-8)
require 'base64'
Base64.strict_encode64('日本語')     # '5pel5pys6Kqe'
Base64.decode64('5pel5pys6Kqe')      # '日本語'

// Rust
use base64::{Engine, engine::general_purpose::STANDARD};
STANDARD.encode("日本語");            // "5pel5pys6Kqe"
STANDARD.decode("5pel5pys6Kqe")
  .map(|b| String::from_utf8(b).unwrap())?;  // "日本語"

// Shell (bash/zsh with GNU coreutils)
echo -n '日本語' | base64                   # '5pel5pys6Kqe'
echo -n '5pel5pys6Kqe' | base64 -d          # '日本語'

// ТИПИЧНЫЕ СБОИ С UNICODE

  • > Мохибакэ при декодировании — вы раскодировали через atob(), и получатель видит мусор. Решение: оберните в TextDecoder.
  • > Ошибки с суррогатными парами — ваш код режет строки по количеству символов и попадает в середину суррогатной пары эмодзи 👋. Решение: применяйте TextEncoder ко всей строке, а не по символу.
  • > Проблемы с BOM — исходный текст начинается с UTF-8 BOM (EF BB BF). Он попадает в кодировку вместе со всем остальным, и получатель видит загадочный \uFEFF. Убирайте BOM до кодирования.
  • > Неверные метаданные charset — вы положили Base64 от байт UTF-8 в поле, помеченное charset=iso-8859-1. Декодер использует метку для интерпретации байт. Всегда согласовывайте метаданные с фактической кодировкой.
  • > Путаница с URL-безопасным Base64 — вы использовали base64url для хранения, но забыли восстановить стандартный алфавит перед декодированием. Решение: нормализуйте через replace(/-/g, '+').replace(/_/g, '/') и дополните паддингом до кратности 4.
  • > Кодирование в зависимости от локали — в Python 2 '日本語'.encode() по умолчанию шёл через ASCII и падал. В любом современном рантайме всегда указывайте utf-8 явно.

// КРУГОВОЙ ТЕСТ — ОКОНЧАТЕЛЬНОЕ ДОКАЗАТЕЛЬСТВО

Лучший способ убедиться, что ваш конвейер Base64 корректно работает с Unicode — круговой тест. Закодируйте заведомо правильную строку, декодируйте и сравните побайтово с оригиналом.

// Vitest / Jest / any test runner
const samples = [
  'hello',
  'héllo',
  '日本語',
  '한국어',
  '👋 Renée — 日本語 — مرحبا',
  '\n\t\0 edge cases',
  '𐀀 ancient greek (U+10000)',
];

for (const s of samples) {
  const encoded = encodeUtf8Base64(s);
  const decoded = decodeUtf8Base64(encoded);
  expect(decoded).toBe(s);
  expect([...new TextEncoder().encode(s)])
    .toEqual([...new TextEncoder().encode(decoded)]);
}

// BASE64 ОТ БИНАРЯ vs BASE64 ОТ ТЕКСТА

Тонкое, но важное различие. Когда вы кодируете PNG-файл в Base64, вход уже является байтами — вопроса про Unicode нет. Просто скармливайте эти байты энкодеру.

Когда вы кодируете в Base64 строку, вход — это последовательность кодовых точек Unicode, а не байты. Нужно определиться с байтовым кодированием (по факту в 2026 году это всегда UTF-8), прежде чем энкодер сможет хоть что-то увидеть. Получаемая Base64-строка корректна только в том случае, если получатель разворачивает её тем же байтовым кодированием.

Это источник большинства тикетов вида «почему мой Base64 неверный»: две системы кодируют Base64 одну и ту же логическую строку, но используют разные базовые байтовые кодировки (одна — UTF-8, другая — UTF-16 или Latin-1), и декодер получателя исправно воспроизводит неправильные байты.

// ВСЕГДА ЛИ ИСПОЛЬЗОВАТЬ UTF-8?

Да, если только вы не работаете внутри легаси-системы, которая диктует иное. UTF-8 — это:

• доминирующая кодировка веба (> 97% сайтов в 2026 году)
• умолчание в стандартной библиотеке любого современного языка программирования
• совместима с ASCII в первых 128 кодовых точках, поэтому не ломает английский
• чётко определена и круговая для любого Unicode-символа
• рекомендуемый IETF charset для новых протоколов (RFC 2277)

Старые альтернативы (UTF-16, UTF-32, Latin-1, GBK, Shift-JIS) всё ещё встречаются в легаси-БД, Windows API и некоторых телеком-протоколах. Если данные пришли из такой системы, переводите их в UTF-8 на границе, кодируйте UTF-8 в Base64 и документируйте принятое решение в формате полезной нагрузки.

// РЕЗЕРВНЫЙ ВАРИАНТ БЕЗ TextEncoder (ЛЕГАСИ-БРАУЗЕРЫ)

Если нужно поддерживать совсем старый браузер без TextEncoder (IE11 и раньше), классический трюк — использовать encodeURIComponent как UTF-8-кодировщик, а затем развернуть через unescape обратно в байтовую строку. Этот паттерн встречается в каждом ответе на StackOverflow про Base64 и Unicode с 2008 по 2020 год.

// Legacy browser fallback (pre-2017)
function encodeUtf8Base64Legacy(str) {
  return btoa(unescape(encodeURIComponent(str)));
}
function decodeUtf8Base64Legacy(b64) {
  return decodeURIComponent(escape(atob(b64)));
}

encodeUtf8Base64Legacy('日本語');    // '5pel5pys6Kqe'
decodeUtf8Base64Legacy('5pel5pys6Kqe'); // '日本語'

// Note: unescape() and escape() are deprecated.
// Only use this pattern if TextEncoder is genuinely unavailable.
// It also mishandles some edge-case surrogates.

// ЧТО ЗАПОМНИТЬ

  • > Base64 кодирует байты, а не символы. Сначала выберите байтовое кодирование — почти всегда UTF-8.
  • > В браузере: TextEncoderString.fromCharCodebtoaодно направление.
  • > В браузере: atobUint8Array.fromTextDecoderобратное направление.
  • > В Node.js: Buffer.from(str).toString('base64') и Buffer.from(b64, 'base64').toString() — обе стороны по умолчанию UTF-8.
  • > В Python / Go / Java: всегда указывайте явный 'utf-8' в .encode() / .getBytes().
  • > Всегда прогоняйте круговые тесты с реальным Unicode (эмодзи, CJK, арабский, комбинируемые знаки) перед релизом.

// СВЯЗАННОЕ

Что такое Base64 и как он работает? — 6-битная группировка, лежащая в основе всего здесь рассказанного.
Base64 в JavaScript (браузер + Node) — полный кроссрантаймовый хелпер.
URL-safe и стандартный Base64 — ортогональный выбор, который тоже придётся сделать.
Попробуйте живой кодировщик Base64 — тот же Unicode-безопасный конвейер прямо в браузере.