StringDecoder: UTF-8 в стримах

Декодер байтов в строку, который буферизует неполные multibyte UTF-8 символы на границе чанков. Без него ручная склейка chunk.toString() ломает кириллицу/эмодзи.

Зачем

UTF-8 — variable-width: 1 байт для ASCII, 2 для кириллицы, 3 для большинства не-латиницы, 4 для эмодзи. Когда стрим читает файл буферами по 64 КБ, граница чанка может попасть в середину codepoint:

// Плохо
let text = '';
stream.on('data', (chunk) => {
  text += chunk.toString('utf8'); // chunk может закончиться на половине 'ё'
});
// результат: «знак замены» (U+FFFD) на стыке чанков

StringDecoder помнит «хвост» неполного символа и приклеивает к началу следующего чанка.

API

const { StringDecoder } = require('node:string_decoder');

const decoder = new StringDecoder('utf8');

stream.on('data', (chunk) => {
  text += decoder.write(chunk); // вернёт только полные codepoint
});

stream.on('end', () => {
  text += decoder.end(); // оставшийся хвост (если есть)
});

Использовать готовое: setEncoding

В большинстве случаев StringDecoder не вызывают напрямую — это внутренность стримов. Достаточно сказать стриму кодировку:

const stream = fs.createReadStream('big.txt', { encoding: 'utf8' });
// или
stream.setEncoding('utf8');

stream.on('data', (chunk) => {
  // chunk — уже строка, склеена правильно (StringDecoder под капотом)
});

Юникод: codepoint ≠ символ

  • JS строки внутри — UTF-16: 4-байтовый codepoint = 2 surrogate ('😀'.length === 2)
  • .length считает code units, не codepoints и не графемы
  • В Unicode вообще нет понятия «символ»: есть code points, графемы, графемные кластеры (например, эмодзи флага = 2 regional indicator + ZWJ)
  • Для подсчёта «настоящих символов» — Intl.Segmenter или [...str].length

Подводные камни

  • Ручной chunk.toString('utf8') без StringDecoder → битые символы на границах
  • Передача между Worker'ами через postMessage со строкой — структурное клонирование сохраняет UTF-16 корректно, проблема только при работе с сырыми байтами
  • setEncoding('utf8') нельзя менять на лету — после первого read поведение неопределено

🎓 Источник: Архив 2018 — Часть 17 Потоки (Streams) в Node.js

  • 📅 2020-01-15 · YouTube · 3ZRkNvs_SaE
  • Тезисы:
    • Для строк лучше setEncoding('utf8') вместо toString после получения чанка
    • Под капотом — StringDecoder из stdlib: один codepoint UTF-8 занимает 1–4 байта
    • Ручная склейка байтов рвёт codepoint на границе чанков → невалидная UTF-8 строка
    • StringDecoder буферизует неполный символ и склеивает с началом следующего чанка
    • JS использует UTF-16: 4-байтовый codepoint представлен двумя surrogate-парами
    • В Unicode нет «символа» — есть графемы, графемные кластеры, codepoints; .length считает code units
  • Цитата:

    «Если будете по кусочкам эти потоки байтов склеивать, то, что у вас получится в итоге, может быть невалидной UTF-8 строкой.»

См. также