Skip to content
Назад к блогу
Руководства

UTF-8 vs UTF-16 vs Unicode — полный гид по кодировкам

UTF-8, UTF-16 и UTF-32 для разработчиков — кодпоинты, суррогатные пары, BOM, ловушки utf8mb4 и обман JS length. Узнайте, как выбрать кодировку.

12 мин чтения

UTF-8 vs UTF-16 vs Unicode — полный гид по кодировкам

Короткий ответ на то, что на самом деле ищут по запросу utf-8 unicode encoding: Unicode и UTF-8 — это не одно и то же. Unicode — это огромная нумерованная таблица, в которой каждому символу присвоен кодпоинт (число вида U+1F600). UTF-8, UTF-16 и UTF-32 — это кодировки, три разных способа превратить эти кодпоинты в байты. UTF-8 — то, что нужно почти всегда: для английского текста он побайтово идентичен ASCII, расширяется до четырёх байт на любой emoji и обязателен в JSON, HTML5 и большинстве современных протоколов.

Этот гид написан для разработчика, которого уже укусило: ошибка MySQL Incorrect string value на 😀, неожиданное "😀".length === 2 в JavaScript, CSV, который нормально открывается в cat и превращается в кашу в Excel. Пройдём от кодпоинтов через побайтовую механику UTF-8, суррогатные пары, BOM, поведение по умолчанию в девяти языках и восемь продакшен-ловушек, а в конце — матрица выбора и FAQ.

Чтобы проверить байтовую последовательность по ходу чтения, вставьте любую строку в Base64 кодировщик и декодер онлайн — мгновенно: декодированная полезная нагрузка и есть тот UTF-8 поток байт, о котором рассказывает эта статья.

Почему кодировки всё ещё кусают вас в 2026 году

Три сценария, все — из реальных багтрекеров за последние двенадцать месяцев:

  1. MySQL отвергает emoji. Пользователь отправляет Hello 😀, а сервер возвращает Incorrect string value: '\xF0\x9F\x98\x80'. Таблица в utf8, разработчик думает: «это же UTF-8, в чём дело?» — ответ зарыт в истории MySQL (раздел 7).
  2. Счётчик символов уезжает в продакшн сломанным. Валидатор твита на 280 символов использует text.length, принимает сообщение с emoji, а API его отклоняет. Бывает и наоборот: корректный пост отвергается фронтендом. Симптом разобран в разделе 4.
  3. Локальный HTML превращается в «ä¸­æ–‡». Разработчик сохраняет файл в Windows-1252, открывает в браузере, который угадывает UTF-8, и наблюдает, как расцветает Mojibake. Это история про BOM и объявление charset из раздела 5; параллели — в URL-кодирование и декодирование: руководство по percent-encoding, где то же несоответствие байт и символов ломает query-строки.

К последней странице вы сможете: (а) одной фразой отличить Unicode от UTF-8, (б) выбрать между UTF-8, UTF-16 и UTF-32 для нового проекта, (в) написать код, корректно считающий emoji в каждом крупном языке, (г) отладить charset-баг по одному байтовому потоку. Внутри тема глубокая, но практическая поверхность невелика.

Что такое Unicode? Кодпоинты vs символы vs глифы

Unicode — это таблица символов, в которой каждому символу присвоен уникальный номер — кодпоинт, например U+1F600. UTF-8, UTF-16 и UTF-32 — это кодировки, превращающие кодпоинты в байты. Сам Unicode не хранит байты; он лишь задаёт отображение из абстрактного символа в целое число.

Ещё три термина путают картину, потому что часто относятся к одному и тому же видимому знаку:

Три слоя, которые нужно различать

  • Кодпоинт (U+0041, U+1F600): целое число, которое назначает Unicode. Пространство тянется от U+0000 до U+10FFFF — примерно 1,1 миллиона ячеек, из которых сейчас занято около 150 000.
  • Символ (или абстрактный символ): смысловая идентичность — латинская заглавная A, emoji «улыбающееся лицо».
  • Глиф: визуальная форма, которую рисует шрифт. У одного символа множество глифов: засечный A, курсивный A, рукописный A. Unicode о глифах ничего не знает.
  • Кластер графем: то, что пользователь воспринимает как один «символ». Иногда это один кодпоинт, иногда — несколько. Буква á может быть одним кодпоинтом U+00E1 или двумя кодпоинтами a + U+0301 (комбинируемый акут) — а Лимиты символов и слов 2026 — гид по Twitter, SMS, SEO, Instagram рассматривает, как Twitter, SMS и SEO проводят эту границу по-разному.

Если запомнить только одно: кодпоинт → кодировка → байты → отрисовка. Каждая стрелка ломается независимо.

Нотация кодпоинтов — U+XXXX и \uXXXX

Кодпоинты записывают в нескольких вариантах. U+0041 — каноническая Unicode-запись: от четырёх до шести шестнадцатеричных цифр с префиксом U+. В исходниках:

  • JavaScript / JSON: "A" (четыре hex-цифры, только BMP) и "\u{1F600}" (фигурные скобки ES6, любой кодпоинт).
  • Python: "A" (4 цифры), "\U00000041" (8 цифр, заглавная U), "\N{LATIN CAPITAL LETTER A}" (по имени).
  • Shell / git log / вывод sed: часто видны сырые UTF-8 байты, например \xc3\xa9 для é — это не кодпоинт, а закодированная форма, что подводит нас к разделу 3.

17 плоскостей — BMP и за её пределами

Unicode делит пространство кодпоинтов на 17 плоскостей по 65 536 кодпоинтов в каждой — 17 × 2^16 = 1 114 112.

  • Plane 0, Basic Multilingual Plane (BMP): U+0000U+FFFF. Латиница, иероглифы CJK, кириллица, арабица, греческий — почти каждая письменность, встречающаяся в унаследованных текстах, живёт здесь.
  • Plane 1-16, дополнительные плоскости: U+10000U+10FFFF. Большая часть emoji (U+1F600 и компания), редкие иероглифы CJK, исторические письменности (египетские иероглифы, клинопись), музыкальная нотация.

Граница BMP и дополнительных плоскостей на U+FFFF — самое нагруженное число в этой статье. Именно там UTF-16 перестаёт быть «один code unit на символ», UTF-8 прыгает с трёх байт на четыре, а ошибочно названный utf8 в MySQL сдаётся.

Быстрая sanity-проверка на emoji

"a"        → 1 codepoint  U+0061             → 1 grapheme
"é" (NFC)  → 1 codepoint  U+00E9             → 1 grapheme
"é" (NFD)  → 2 codepoints U+0065 U+0301      → 1 grapheme
"😀"        → 1 codepoint  U+1F600 (Plane 1)  → 1 grapheme
"👨‍👩‍👧"      → 5 codepoints (3 people + 2 ZWJ U+200D) → 1 grapheme

Последняя строка — соль. Emoji «семья» — один символ с точки зрения пользователя, но пять кодпоинтов, склеенных Zero-Width Joiner. Каждый слой стека считает его по-своему; баг-репорт, оформляющий это разногласие, — ловушка 6 из раздела 7.

Механика UTF-8 — как работают 1-4 байта

UTF-8 кодирует кодпоинты Unicode в 1-4 байта. ASCII (U+0000U+007F) занимает 1 байт и побайтово идентичен ASCII. Старшие кодпоинты используют многобайтовые последовательности: первый байт сигнализирует общую длину, каждый продолжающий байт начинается с битового шаблона 10xxxxxx. Эта самоописательная схема — причина, по которой UTF-8 выиграл войну кодировок.

Таблица байтовых шаблонов — UTF-8 на одной диаграмме

Диапазон кодпоинтовБайт в UTF-8Битовый шаблон
U+0000U+007F1 байт0xxxxxxx
U+0080U+07FF2 байта110xxxxx 10xxxxxx
U+0800U+FFFF3 байта1110xxxx 10xxxxxx 10xxxxxx
U+10000U+10FFFF4 байта11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

Каждый x — бит данных из двоичного представления кодпоинта. Ведущие 0 / 110 / 1110 / 11110 сообщают декодеру общее число байт; ведущие 10 помечают каждый продолжающий байт. Эта избыточность и делает UTF-8 самосинхронизирующимся: потеряйте байт — продолжите с ближайшего стартового, а не испортите весь хвост.

Разбор примера — кодирование (U+4E2D)

Кодпоинт 0x4E2D попадает в U+0800U+FFFF, поэтому берём трёхбайтовый шаблон.

  1. Двоичное: 0x4E2D = 0100 1110 0010 1101 (16 бит).
  2. Разбиваем 4-6-6 под слоты x: 0100 / 111000 / 101101.
  3. Подставляем в 1110xxxx 10xxxxxx 10xxxxxx: 11100100 10111000 10101101.
  4. Hex: 0xE4 0xB8 0xAD.

Поэтому превращается в %E4%B8%AD после URL-encoding: percent-encoding оборачивает каждый UTF-8 байт в %XX, а сам кодпоинт не кодирует. Цепочку разбирает ловушка 3 раздела 7.

Разбор примера — кодирование 😀 (U+1F600)

Кодпоинт 0x1F600 выходит за BMP, поэтому берём четырёхбайтовый шаблон.

  1. Двоичное: 0x1F600 = 0 0001 1111 0110 0000 0000 (21 бит с дополнением).
  2. Разбиваем 3-6-6-6: 000 / 011111 / 011000 / 000000.
  3. Подставляем в 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx: 11110000 10011111 10011000 10000000.
  4. Hex: 0xF0 0x9F 0x98 0x80.

На этих четырёх байтах и захлёбывается коллация utf8 в MySQL: она выделяет максимум три байта на символ. Исправление — ловушка 1 раздела 7.

Почему UTF-8 победил — три суперсилы

  1. Совместимость с ASCII. Файл из чистого ASCII текста побайтово идентичен своему UTF-8 представлению. Доюникодные инструменты — grep, awk, классические shell-пайпы — продолжают работать на этом подмножестве.
  2. Самосинхронизация. Продолжающие байты всегда начинаются с 10, что никогда не пересекается со стартовым байтом. Потерянный байт в сетевой передаче — и синхронизация восстанавливается на ближайшей границе символа, без каскадной порчи.
  3. Нет байтового порядка. UTF-8 — это поток байт, а не блоков по 16 или 32 бита, так что endianness неприменим. UTF-16 и UTF-32 нуждаются в Byte Order Mark, чтобы объявить, какой конец идёт первым; UTF-8 не нуждается (и обычно не должен — см. раздел 5).

Невалидный UTF-8 — что запрещает спецификация

Строгий декодер отвергнет такие байтовые последовательности:

  • Последовательности из 5 или 6 байт. Ранние RFC их допускали; RFC 3629 (2003) ограничил UTF-8 четырьмя байтами под 21-битное пространство Unicode.
  • Переудлинённые (overlong) кодировки. Кодирование / тремя байтами 0xE0 0x80 0xAF вместо одного 0x2F. Когда-то — почва для эксплойтов directory-traversal в валидаторах путей, декодировавших после санитизации.
  • Одиночные суррогатные кодпоинты (U+D800U+DFFF). Зарезервированы под UTF-16, в UTF-8 появляться не должны.
  • Усечённые последовательности. Трёхбайтовый стартовый байт, за которым следует только один продолжающий — обычно когда пользовательский ввод обрезан по границе байта посреди многобайтового символа.

Чтобы увидеть это руками, бросьте строку в Base64 кодировщик и декодер онлайн — мгновенно, закодируйте её, затем декодируйте обратно как байты: байтовый массив между кодировщиком и декодером и есть UTF-8 поток из этого раздела.

UTF-16 и суррогатные пары — почему JavaScript length обманывает

Самый частый поиск вокруг utf-8 vs utf-16 на самом деле звучит как «почему "😀".length равен 2 в моём коде?» Ответ — суррогатные пары, решение 1990-х, унаследованное JavaScript, Java, C# и Windows.

UTF-16 одним абзацем

UTF-16 представляет Unicode 16-битными code units. Символы из BMP (U+0000U+FFFF) занимают ровно один code unit. Символы из дополнительных плоскостей (U+10000U+10FFFF) занимают два code units, называемых суррогатной парой: high surrogate из U+D800U+DBFF, за которым следует low surrogate из U+DC00U+DFFF. Блок U+D800U+DFFF навсегда зарезервирован в Unicode, реальных символов там нет. UTF-16 — внутренний формат строк в JavaScript, Java, C# (.NET), Windows kernel API, Objective-C NSString и Qt: всё это проектировалось тогда, когда 65 536 символов казались избытком.

Ловушка String.length

"a".length          // 1   — BMP, single code unit
"é".length          // 1   — BMP (U+00E9), single code unit
"中".length         // 1   — BMP (U+4E2D), single code unit
"😀".length         // 2   — supplementary plane (U+1F600), surrogate pair!
"a😀".length        // 3   — one BMP + two surrogate units

String.prototype.length возвращает число UTF-16 code units, а не символов. Всё из дополнительной плоскости читается как 2. Та же ловушка живёт в String.length() Java и string.Length C#.

Как правильно считать кодпоинты в JS

[..."😀"].length              // 1 — spread iterator walks codepoints
Array.from("😀").length       // 1 — Array.from also walks codepoints
"😀".match(/./gu).length      // 1 — /u flag = unicode-aware regex

// "😀".charAt(0) returns the lone high surrogate (visually broken)
"😀".codePointAt(0)           // 128512 — the full codepoint U+1F600

Spread-оператор и Array.from используют протокол итератора, который спецификация языка определяет как обход по кодпоинтам. Индексный доступ (str[0], charAt) по-прежнему возвращает code units и выдаст половину суррогатной пары для emoji.

Python — len() уже делает правильное (почти)

len("😀")           # 1   — Python 3 strings are codepoint-indexed
len("👨‍👩‍👧")        # 5   — codepoints (3 humans + 2 ZWJ), not graphemes
# Python 2 was byte-indexed by default — len("😀") returned 4

Python 3 хранит строки в гибком 1-, 2- или 4-байтовом представлении (PEP 393) и индексирует по кодпоинтам. len("😀") равен 1, но это всё ещё не число графем: emoji-семья по-прежнему считается за 5. Чтобы считать пользовательские символы, нужна библиотека графем: Intl.Segmenter в JavaScript (Node 22+, все актуальные браузеры), grapheme или regex в Python, либо Swift — единственный массовый язык, у которого String.count по умолчанию считает графемы.

UTF-16 vs UCS-2 — тихая миграция

До 1996 года Unicode обещал уместиться в 16 бит, и соответствующей кодировкой был UCS-2 — фиксированное 2-байтовое отображение. Unicode 2.0 обещание нарушил, добавив дополнительные плоскости. UTF-16 — пропатченная версия с суррогатными парами. Спецификация JavaScript местами всё ещё использует старый лексикон UCS-2 — поэтому язык терпит одиночные суррогаты, которые должны были бы запрещаться (шутки про «WTF-16» не на пустом месте). Веб-платформенные API (DOM, fetch, TextEncoder) одиночные суррогаты отвергают, поскольку их нельзя закодировать в валидный UTF-8.

UTF-32, BOM и вопрос байтового порядка

UTF-32 — простой и расточительный

UTF-32 использует фиксированные 4 байта на кодпоинт. U+0041 хранится как 0x00000041, U+1F600 — как 0x0001F600. Плюс — произвольный доступ за константное время: n-й кодпоинт лежит по смещению 4n. Минус — размер: чистый ASCII разбухает вчетверо относительно своего UTF-8 следа, даже CJK удваивается. Почти ни одна система не хранит UTF-32 на диске. Внутри Python 3 выбирает 1, 2 или 4 байта на строку в зависимости от старшего кодпоинта; стек fontconfig в Linux использует UTF-32 для своих in-memory glyph-таблиц.

Байтовый порядок — почему endianness важен для UTF-16 / UTF-32

UTF-8 — поток одиночных байт, и endianness к нему неприменим. UTF-16 и UTF-32 работают с многобайтовыми единицами, и разные CPU расходятся в том, какой конец числа идёт первым.

U+0041 ('A') in UTF-16 BE → 00 41
U+0041 ('A') in UTF-16 LE → 41 00

x86 и ARM — little-endian; более старый PowerPC и «network byte order» — big-endian. Когда вы пишете файл UTF-16, нужно зафиксировать один из вариантов и сообщить читателю, какой именно. Для этого и существует BOM.

BOM — что это и когда применять

Byte Order Mark — это U+FEFF, помещённый в начало файла. Будучи закодированным, он объявляет и кодировку, и (для UTF-16 / UTF-32) байтовый порядок.

КодировкаБайт BOM
UTF-8EF BB BF
UTF-16 BEFE FF
UTF-16 LEFF FE
UTF-32 BE00 00 FE FF
UTF-32 LEFF FE 00 00

UTF-8 BOM существует, но не несёт информации о порядке байт, потому что у UTF-8 байтового порядка нет. Его единственная задача — объявить «это файл UTF-8»: полезно для инструментов без другого сигнала, вредно для тех, кто ждёт magic number или директиву в начале файла.

Матрица решений по BOM — нужно ли его добавлять?

ФорматUTF-8 BOMUTF-16 BOMUTF-32 BOM
HTMLНет (ломает определение <!doctype> в старых парсерах)
JSONНет (RFC 8259 запрещает)
JavaScript / CSS sourceИзбегайте (старые Node и IE захлёбываются)
CSV, открываемый в ExcelДа (Excel читает UTF-8 без BOM как ANSI и портит CJK)
XMLОпционально (XML-объявление уже задаёт кодировку)ОбязательноОбязательно
Plain text .txtОпционально (Windows Notepad добавляет по умолчанию)ОбязательноОбязательно

Короткое правило: уберите UTF-8 BOM из всего, что отдаётся в веб; добавляйте в CSV, которые должен открывать Excel; для остального пусть решает читатель.

9 языков бок о бок — поведение кодировок по умолчанию

Кроссязыковая работа — место, где это знание окупается. Одна и та же строка "a😀é" даёт разную длину в каждом рантайме, вызванном из вашего Bash-скрипта.

Таблица поведения по языкам

ЯзыкКодировка исходникаХранение строкЧто считает length / lenI/O по умолчаниюБезопасно с 4-байт emoji?
JavaScript (V8 / SpiderMonkey)UTF-8UTF-16UTF-16 code unitsUTF-8 (Node, Web)Да, но .length === 2
Python 3UTF-8 (PEP 3120)динамически 1 / 2 / 4 байта (PEP 393)кодпоинтыUTF-8 (PEP 540, с 3.7)Да, len === 1
JavaUTF-8 (javac по умолчанию)UTF-16UTF-16 code unitsplatform charset → UTF-8 (JEP 400, JDK 18+)Да, но .length() === 2
GoUTF-8байты UTF-8байты (utf8.RuneCountInString для кодпоинтов)UTF-8Да, len(s) возвращает байты
RustUTF-8байты UTF-8 (инвариант String)байты .len(), кодпоинты .chars().count()UTF-8Да, явно
C# (.NET)UTF-8 (по умолчанию с .NET Core 3.0)UTF-16UTF-16 code unitsUTF-8 (Encoding.Default с .NET 5)Да, но .Length === 2
RubyUTF-8 (с 2.0)per-string encoding tagкодпоинты (.length)UTF-8Да, length === 1
PHP(нет кодировки исходника)байтовая строкабайты (strlen); mb_strlen для кодпоинтовзависит от default_charsetДа, через семейство mb_*
MySQLcolumn charsetбайты (LENGTH), символы (CHAR_LENGTH)системные переменные character_set_*Только с utf8mb4

Что таблица на самом деле говорит

Три философии — три набора багов:

  • UTF-8 внутри (Go, Rust, Ruby). Нативная строка — это байты; length определён, но считает то, что считает. Конвертируйте в кодпоинты или графемы только на границе UI или валидации.
  • UTF-16 внутри (JavaScript, Java, C#). Унаследовано из допущений 1990-х; length считает code units, суррогатная пара — это 2. Для пользовательского подсчёта обходите по кодпоинтам.
  • Индексация по кодпоинтам (Python 3). len даёт кодпоинты, что кажется правильным, пока не встретится ZWJ-emoji — и тогда всё равно нужна библиотека графем.

PHP — особый случай. Все встроенные str*-функции работают с байтами, рассматривая UTF-8 как непрозрачные блобы. Любой не-ASCII проект обязан использовать семейство mb_* (multibyte), и год за годом баг-репорты показывают, как часто это забывают.

Практический совет: держите UTF-8 как wire-формат везде — в файлах, в HTTP-телах, в колонках базы — и конвертируйте в нативный строковый тип рантайма на границе. Это и есть «UTF-8 sandwich», к которому вернёмся в разделе 8.

8 продакшен-ловушек инженерной практики

Паттерны ниже всплывают на каждом code review глобализованной кодовой базы.

Ловушка 1: MySQL utf8 — это 3-байтовая ложь, переключайтесь на utf8mb4

Симптом. INSERT INTO users (bio) VALUES ('Hello 😀'); возвращает Incorrect string value: '\xF0\x9F\x98\x80' for column 'bio'.

Корневая причина. Исторический utf8 в MySQL — это псевдоним для utf8mb3: вариант UTF-8 с ограничением в три байта на символ. Любой кодпоинт выше U+FFFF (все emoji, несколько тысяч редких CJK-символов, все исторические письменности) требует четырёх UTF-8 байт и отвергается.

Исправление.

ALTER DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE users CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
SET NAMES utf8mb4;  -- client connection
# my.cnf
[mysqld]
character-set-server = utf8mb4
collation-server     = utf8mb4_unicode_ci

MySQL 8.0 по-прежнему поставляет utf8 как псевдоним utf8mb3. utf8mb3 объявлен устаревшим, но пока не удалён. Используйте utf8mb4 для каждой новой колонки, каждой новой базы, каждого нового соединения: у легаси-варианта нет ни одного плюса.

Ловушка 2: Откат на Windows-1252 — загадка вопросительного знака

Симптом. .txt, экспортированный из Notepad коллеги на Windows, у него читается как "smart quotes" и em-dash. На вашем сервере он превращается в ? или U+FFFD (replacement character).

Корневая причина. Старые версии Notepad по умолчанию пишут в Windows-1252 (CP-1252), где кудрявая кавычка " кодируется как 0x93. Декодер UTF-8 видит 0x93 как одинокий продолжающий байт (старший бит 10) без предшествующего стартового и подставляет replacement character.

Исправление. Определить исходную кодировку (file в Unix, chardet или charset-normalizer в Python, jschardet в Node), декодировать корректным кодеком и заново закодировать в UTF-8 перед сохранением. Стандартизация на UTF-8 при приёме данных закрывает рецидив.

Ловушка 3: URL percent-encoding ≠ UTF-8 (но опирается на него)

Симптом. fetch("/search?q=中文") в одном бекенд-фреймворке возвращает 404, в другом работает.

Корневая причина. Percent-encoding работает с байтами, а не с кодпоинтами. — это один кодпоинт, но три UTF-8 байта (E4 B8 AD), каждый по отдельности percent-кодируется как %E4%B8%AD — девять ASCII-символов в URL. Фреймворк, декодирующий URL как Latin-1 вместо UTF-8, передаст обработчику три искажённых байта, интерпретированных как три однобайтовых символа.

Исправление. Используйте encodeURIComponent("中文") на клиенте (браузеры делают UTF-8 + percent-encode за один шаг) и убедитесь, что серверный фреймворк декодирует URL как UTF-8 (все современные фреймворки так делают по умолчанию). Для визуального подтверждения вставьте 中文 в URL декодер и кодировщик — декодирование, кодирование, разбор URL и посмотрите, как он превращается в %E4%B8%AD%E6%96%87. Полная цепочка — в URL-кодирование и декодирование: руководство по percent-encoding.

Ловушка 4: На вход Base64 — байты, а вы передали строку

Симптом. btoa("你好") бросает InvalidCharacterError: The string contains characters outside the Latin1 range.

Корневая причина. btoa спроектирован в эпоху ASCII / Latin-1. Он ожидает, что каждый входной символ уместится в один байт (кодпоинты 0-255). 你好 в JS-движке — это UTF-16 с кодпоинтами U+4F60 U+597D, оба заведомо выше 255.

Исправление. Сначала закодировать в UTF-8 байты, затем Base64-кодировать эти байты.

// Wrong:
btoa("你好");  // throws

// Correct:
const bytes = new TextEncoder().encode("你好");
// Uint8Array(6) [228, 189, 160, 229, 165, 189]
const b64 = btoa(String.fromCharCode(...bytes));
// "5L2g5aW9"

Подробности — в Что такое кодирование Base64? Руководство для начинающих и Base64 в продакшне: MIME, data URL, производительность и безопасность; Base64 кодировщик и декодер онлайн — мгновенно делает преобразование за один шаг и показывает промежуточный байтовый поток.

Ловушка 5: String.length для валидации (лимиты Twitter / SMS)

Симптом. Composer на 280 символов проходит клиентскую валидацию, а API возвращает 422. Или наоборот: нормальный пост отвергается клиентом.

Корневая причина. .length в JavaScript считает UTF-16 code units; один emoji идёт за 2. Twitter считает кодпоинты (emoji = 1). Счётчик символов разбегается в противоположные стороны в зависимости от того, какому API доверять.

Исправление. Используйте [...text].length для подсчёта кодпоинтов или Intl.Segmenter для честного подсчёта графем (подход Bluesky / iMessage). Числа по платформам и граница SMS GSM-7 / UCS-2 каталогизированы в Лимиты символов и слов 2026 — гид по Twitter, SMS, SEO, Instagram.

Ловушка 6: ZWJ-emoji семья считается как N кодпоинтов и 1 графема

Симптом. "👨‍👩‍👧".length === 8. Подсчёт кодпоинтов даёт 5. Для пользователя это одна картинка.

Корневая причина. Zero-Width Joiner (U+200D) склеивает несколько emoji-кодпоинтов в один отрисовываемый кластер: три emoji-«человек» плюс два ZWJ равно пять кодпоинтов, восемь UTF-16 code units, одна графема.

Исправление.

const seg = new Intl.Segmenter('en', { granularity: 'grapheme' });
[...seg.segment("👨‍👩‍👧")].length;  // 1

Intl.Segmenter есть в Node 22+ и во всех актуальных браузерах. Для более старых рантаймов пакет grapheme-splitter реализует UAX #29.

Ловушка 7: JSON \uXXXX — кодпоинты выше U+FFFF требуют суррогатной пары

Симптом. JSON-полезная нагрузка содержит "😀", и принимающий декодер либо корректно рендерит 😀, либо показывает два квадратика — в зависимости от того, понимает ли он суррогатные пары в JSON.

Корневая причина. Escape \uXXXX в JSON принимает ровно четыре hex-цифры, то есть один UTF-16 code unit. Чтобы закодировать 😀 (U+1F600), нужна суррогатная пара 😀. Синтаксиса \u{...} с фигурными скобками в JSON нет.

Исправление. Либо принимайте суррогатную пару (любой spec-совместимый парсер с ней справляется), либо пишите emoji литералом: JSON допускает любой UTF-8 символ вне escape-синтаксиса, и большинство современных парсеров предпочитают этот вариант.

Ловушка 8: Значения Content-Type: charset= по умолчанию не такие, как кажется

Симптом. Страница HTML в UTF-8 в одном браузере отрисовывается как Mojibake, а в другом — корректно.

Корневая причина. RFC 2616 изначально предписывал ISO-8859-1 как умолчание для ответов text/* без явного charset. RFC 7231 (2014) это умолчание убрал, оставив каждому браузеру догадываться. Один нюхает содержимое, другой откатывается на UTF-8, третий — на системную локаль.

Исправление. Всегда отправляйте Content-Type: text/html; charset=utf-8 с сервера и <meta charset="utf-8"> в head документа. Каждый работает по отдельности, оба вместе — «и пояс, и подтяжки» на случай легаси-прокси, срезающих заголовки.

Чтобы увидеть любую из этих ловушек вживую на уровне байт, удобнее всего Base64 кодировщик и декодер онлайн — мгновенно: вставьте строку, закодируйте в Base64, декодированная полезная нагрузка — это и есть UTF-8 поток.

Выбор правильной кодировки — матрица решений

На вопрос utf-8 vs utf-16 ответ почти всегда — UTF-8. Таблица ниже покрывает крайние случаи.

Матрица решений

СценарийВыборПочему
Веб-страницы, API JSON, исходникиUTF-8 (без BOM)Совместим с ASCII, нет байтового порядка, минимальный размер для латинского текста, RFC 8259 предписывает UTF-8 для JSON
Массивное хранение CJK (китайская БД, японские игровые данные)UTF-8 (utf8mb4)UTF-8 тратит 3 байта на CJK-символ против 2 у UTF-16, но накладные ASCII-расходы от разметки и JSON-ключей в реальности всё равно оставляют UTF-8 впереди — а вокруг и так экосистема UTF-8
Нативные Windows API, легаси Java / C#UTF-16Платформенное умолчание; конвертация на каждом API-вызове плодит баги
In-memory обработка текста с тяжёлой индексациейUTF-32Константный доступ по кодпоинтам; оправдан только в горячих участках парсера
CSV, открываемый в Excel на WindowsUTF-8 с BOMExcel читает UTF-8 без BOM как ANSI и портит CJK-заголовки
Новый проект без ограниченийUTF-8 (без BOM)Войны кодировок окончательно закончились

Два эмпирических правила

  1. По умолчанию — UTF-8 везде, если только платформа не вынуждает иное. W3C, IETF и Unicode Consortium сходятся в этом.
  2. Конвертируйте на границе, а не в середине. Декодируйте байты в нативный строковый тип при приёме. В бизнес-логике работайте со строками, а не с байтами. На выходе кодируйте обратно в UTF-8. Этот «UTF-8 sandwich» закрывает целый класс багов с mojibake посередине пайплайна.

Часто задаваемые вопросы

UTF-8 всегда обратно совместим с ASCII?

Да. Любой валидный ASCII-файл побитово идентичен своему UTF-8 представлению. Первые 128 кодпоинтов (U+0000U+007F) кодируются одним байтом с очищенным старшим битом. Легаси-инструменты с поддержкой только ASCII — ранний grep, sed, классические shell-пайпы — обрабатывают чисто ASCII UTF-8 файлы без изменений. Сложности начинаются, когда в поток попадают не-ASCII байты (со взведённым старшим битом).

Стоит ли использовать UTF-8 BOM в файлах?

По умолчанию — нет. HTML, JSON, JavaScript и CSS либо ломаются, либо выдают предупреждения у некоторых парсеров, когда в начале появляется BOM. Стандартное исключение — CSV для Excel на Windows: без BOM Excel угадывает ANSI и портит заголовки на китайском, японском или корейском. См. матрицу решений по BOM в разделе 5.

Почему "😀".length === 2 в JavaScript?

Строки в JavaScript хранятся как UTF-16, а .length возвращает число code units, а не символов. 😀 (U+1F600) лежит в дополнительной плоскости и требует суррогатной пары — двух 16-битных code units — поэтому .length равен 2. Используйте [..."😀"].length, Array.from("😀").length или Intl.Segmenter для честного подсчёта.

В чём разница между Unicode и UTF-8?

Unicode — это таблица символов, в которой каждому символу присвоен кодпоинт (число вида U+1F600). UTF-8 — одна из нескольких кодировок, превращающих эти кодпоинты в байты (1-4 байта на кодпоинт). Unicode задаёт, что такое символ; UTF-8 — как этот символ путешествует через файл или сеть. UTF-16 и UTF-32 — альтернативные кодировки той же самой Unicode-таблицы.

utf8mb4 всегда безопаснее, чем utf8 в MySQL?

Для новых проектов — да. utf8 в MySQL — это ошибочно названный 3-байтовый вариант utf8mb3, который не способен хранить ни один символ выше U+FFFF: ни emoji, ни редкие CJK-символы, ни исторические письменности. utf8mb4 — полный 4-байтовый UTF-8. Единственная оговорка — длина индексов: каждый символ utf8mb4 может занимать 4 байта, поэтому легаси-лимит индекса InnoDB в 767 байт ограничивает уникальные индексы 191 символом (решено через innodb_large_prefix в MySQL 5.7+ и по умолчанию в 8.0).

Как определить кодировку неизвестного файла?

Используйте file в Unix, chardet или charset-normalizer в Python либо jschardet в Node. Ни один не идеален: все они статистически угадывают по распределению байт. Определение UTF-8 очень надёжно благодаря паттерну продолжающих байт. Windows-1252, ISO-8859-1 и другие однобайтовые легаси-кодировки почти неотличимы друг от друга, так что определение часто сводится к языковым эвристикам.

Может ли UTF-16 представить любой символ Unicode?

Да. UTF-16 покрывает все 1 114 112 кодпоинтов. Символы BMP (U+0000U+FFFF) используют один 16-битный code unit (2 байта), а символы дополнительных плоскостей (U+10000U+10FFFF) — суррогатные пары (4 байта). Покрытие совпадает с UTF-8 и UTF-32; различаются только байтовая раскладка и семантика обработки. Выбор между ними — про вписывание в экосистему, не про возможности.

Теги: unicode utf-8 utf-16 character-encoding surrogate-pair encoding

Похожие статьи

Все статьи
tutorials

Шпаргалка по curl: 40+ примеров команд для HTTP и API

Полная шпаргалка по curl для разработчиков: GET/POST, заголовки, Bearer-авторизация, загрузка и скачивание файлов, тестирование API — более 40 готовых примеров. Попробуйте наши инструменты.

#curl #http #rest-api
2 июн. 2026 г.
14 мин чтения
tutorials

Lorem Ipsum: значение, происхождение и текст-заполнитель

Всё о Lorem Ipsum: что означает эта псевдолатынь, откуда она взялась, зачем дизайнеры используют текст-заполнитель, как сгенерировать его где угодно и когда его лучше не применять. Бесплатный онлайн-генератор.

#lorem-ipsum #placeholder-text #dummy-text
2 июн. 2026 г.
12 мин чтения
tutorials

XML в JSON: соглашения, подводные камни и примеры кода

Правильно преобразуйте XML в JSON: как сопоставляются атрибуты, массивы и пространства имён, почему значения остаются строками, с кодом на JavaScript, Python и в браузере.

#xml #json #data-conversion
2 июн. 2026 г.
13 мин чтения