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 году
Три сценария, все — из реальных багтрекеров за последние двенадцать месяцев:
- MySQL отвергает emoji. Пользователь отправляет
Hello 😀, а сервер возвращаетIncorrect string value: '\xF0\x9F\x98\x80'. Таблица вutf8, разработчик думает: «это же UTF-8, в чём дело?» — ответ зарыт в истории MySQL (раздел 7). - Счётчик символов уезжает в продакшн сломанным. Валидатор твита на 280 символов использует
text.length, принимает сообщение с emoji, а API его отклоняет. Бывает и наоборот: корректный пост отвергается фронтендом. Симптом разобран в разделе 4. - Локальный 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+0000–U+FFFF. Латиница, иероглифы CJK, кириллица, арабица, греческий — почти каждая письменность, встречающаяся в унаследованных текстах, живёт здесь. - Plane 1-16, дополнительные плоскости:
U+10000–U+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+0000–U+007F) занимает 1 байт и побайтово идентичен ASCII. Старшие кодпоинты используют многобайтовые последовательности: первый байт сигнализирует общую длину, каждый продолжающий байт начинается с битового шаблона 10xxxxxx. Эта самоописательная схема — причина, по которой UTF-8 выиграл войну кодировок.
Таблица байтовых шаблонов — UTF-8 на одной диаграмме
| Диапазон кодпоинтов | Байт в UTF-8 | Битовый шаблон |
|---|---|---|
U+0000 – U+007F | 1 байт | 0xxxxxxx |
U+0080 – U+07FF | 2 байта | 110xxxxx 10xxxxxx |
U+0800 – U+FFFF | 3 байта | 1110xxxx 10xxxxxx 10xxxxxx |
U+10000 – U+10FFFF | 4 байта | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
Каждый x — бит данных из двоичного представления кодпоинта. Ведущие 0 / 110 / 1110 / 11110 сообщают декодеру общее число байт; ведущие 10 помечают каждый продолжающий байт. Эта избыточность и делает UTF-8 самосинхронизирующимся: потеряйте байт — продолжите с ближайшего стартового, а не испортите весь хвост.
Разбор примера — кодирование 中 (U+4E2D)
Кодпоинт 0x4E2D попадает в U+0800–U+FFFF, поэтому берём трёхбайтовый шаблон.
- Двоичное:
0x4E2D=0100 1110 0010 1101(16 бит). - Разбиваем 4-6-6 под слоты
x:0100 / 111000 / 101101. - Подставляем в
1110xxxx 10xxxxxx 10xxxxxx:11100100 10111000 10101101. - Hex:
0xE4 0xB8 0xAD.
Поэтому 中 превращается в %E4%B8%AD после URL-encoding: percent-encoding оборачивает каждый UTF-8 байт в %XX, а сам кодпоинт не кодирует. Цепочку разбирает ловушка 3 раздела 7.
Разбор примера — кодирование 😀 (U+1F600)
Кодпоинт 0x1F600 выходит за BMP, поэтому берём четырёхбайтовый шаблон.
- Двоичное:
0x1F600=0 0001 1111 0110 0000 0000(21 бит с дополнением). - Разбиваем 3-6-6-6:
000 / 011111 / 011000 / 000000. - Подставляем в
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx:11110000 10011111 10011000 10000000. - Hex:
0xF0 0x9F 0x98 0x80.
На этих четырёх байтах и захлёбывается коллация utf8 в MySQL: она выделяет максимум три байта на символ. Исправление — ловушка 1 раздела 7.
Почему UTF-8 победил — три суперсилы
- Совместимость с ASCII. Файл из чистого ASCII текста побайтово идентичен своему UTF-8 представлению. Доюникодные инструменты —
grep,awk, классические shell-пайпы — продолжают работать на этом подмножестве. - Самосинхронизация. Продолжающие байты всегда начинаются с
10, что никогда не пересекается со стартовым байтом. Потерянный байт в сетевой передаче — и синхронизация восстанавливается на ближайшей границе символа, без каскадной порчи. - Нет байтового порядка. 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+D800–U+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+0000–U+FFFF) занимают ровно один code unit. Символы из дополнительных плоскостей (U+10000–U+10FFFF) занимают два code units, называемых суррогатной парой: high surrogate из U+D800–U+DBFF, за которым следует low surrogate из U+DC00–U+DFFF. Блок U+D800–U+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-8 | EF BB BF |
| UTF-16 BE | FE FF |
| UTF-16 LE | FF FE |
| UTF-32 BE | 00 00 FE FF |
| UTF-32 LE | FF FE 00 00 |
UTF-8 BOM существует, но не несёт информации о порядке байт, потому что у UTF-8 байтового порядка нет. Его единственная задача — объявить «это файл UTF-8»: полезно для инструментов без другого сигнала, вредно для тех, кто ждёт magic number или директиву в начале файла.
Матрица решений по BOM — нужно ли его добавлять?
| Формат | UTF-8 BOM | UTF-16 BOM | UTF-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 / len | I/O по умолчанию | Безопасно с 4-байт emoji? |
|---|---|---|---|---|---|
| JavaScript (V8 / SpiderMonkey) | UTF-8 | UTF-16 | UTF-16 code units | UTF-8 (Node, Web) | Да, но .length === 2 |
| Python 3 | UTF-8 (PEP 3120) | динамически 1 / 2 / 4 байта (PEP 393) | кодпоинты | UTF-8 (PEP 540, с 3.7) | Да, len === 1 |
| Java | UTF-8 (javac по умолчанию) | UTF-16 | UTF-16 code units | platform charset → UTF-8 (JEP 400, JDK 18+) | Да, но .length() === 2 |
| Go | UTF-8 | байты UTF-8 | байты (utf8.RuneCountInString для кодпоинтов) | UTF-8 | Да, len(s) возвращает байты |
| Rust | UTF-8 | байты UTF-8 (инвариант String) | байты .len(), кодпоинты .chars().count() | UTF-8 | Да, явно |
| C# (.NET) | UTF-8 (по умолчанию с .NET Core 3.0) | UTF-16 | UTF-16 code units | UTF-8 (Encoding.Default с .NET 5) | Да, но .Length === 2 |
| Ruby | UTF-8 (с 2.0) | per-string encoding tag | кодпоинты (.length) | UTF-8 | Да, length === 1 |
| PHP | (нет кодировки исходника) | байтовая строка | байты (strlen); mb_strlen для кодпоинтов | зависит от default_charset | Да, через семейство mb_* |
| MySQL | — | column 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 на Windows | UTF-8 с BOM | Excel читает UTF-8 без BOM как ANSI и портит CJK-заголовки |
| Новый проект без ограничений | UTF-8 (без BOM) | Войны кодировок окончательно закончились |
Два эмпирических правила
- По умолчанию — UTF-8 везде, если только платформа не вынуждает иное. W3C, IETF и Unicode Consortium сходятся в этом.
- Конвертируйте на границе, а не в середине. Декодируйте байты в нативный строковый тип при приёме. В бизнес-логике работайте со строками, а не с байтами. На выходе кодируйте обратно в UTF-8. Этот «UTF-8 sandwich» закрывает целый класс багов с mojibake посередине пайплайна.
Часто задаваемые вопросы
UTF-8 всегда обратно совместим с ASCII?
Да. Любой валидный ASCII-файл побитово идентичен своему UTF-8 представлению. Первые 128 кодпоинтов (U+0000–U+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+0000–U+FFFF) используют один 16-битный code unit (2 байта), а символы дополнительных плоскостей (U+10000–U+10FFFF) — суррогатные пары (4 байта). Покрытие совпадает с UTF-8 и UTF-32; различаются только байтовая раскладка и семантика обработки. Выбор между ними — про вписывание в экосистему, не про возможности.