Дайджесты новостей
Вид от первого лица на экран веб-разработчика с потоковой визуализацией входящих чанков данных fetch и цикла for await...of.

Потоковое чтение fetch в JavaScript: парсинг больших данных через for await...of

Каждый JavaScript-разработчик сотни раз писал один и тот же шаблонный вызов: const res = await fetch(url); const data = await res.json();. Пока размер полезной нагрузки в теле ответа измеряется десятками килобайт, этот подход работает безупречно. Браузер быстро скачивает весь буфер в память, парсер моментально превращает JSON-строку в дерево объектов, и приложение живет спокойно.

Но представь другую картину: твой клиент запрашивает выгрузку аналитического отчета на 150 мегабайт, тяжелый CSV-файл или слушает потоковый ответ генеративной нейросети (LLM text streaming). Если попытаться дождаться завершения загрузки всего тела через res.text() или res.blob(), приложение мгновенно испытает спайк потребления оперативной памяти, вкладка браузера зависнет на несколько секунд при сборке мусора, а пользователь будет тоскливо смотреть в пустой экран.

Стандарт Fetch API и спецификация веб-потоков Streams API предлагают нативное решение: объект response.body представляет собой полноценный интерфейс ReadableStream. А благодаря реализации протокола асинхронной итерации Symbol.asyncIterator разработчики могут обрабатывать входящий трафик потоково с помощью простого и чистого цикла for await...of.

Анатомия потока: почему нельзя просто разрезать байты по границам

Давай разберем, что происходит на сетевом уровне, когда сервер начинает отдавать большой ответ. TCP-стек операционной системы разбивает поток данных на сегменты и передает их сетевому адаптеру. В JavaScript эти сетевые порции приходят в виде бинарных массивов Uint8Array (чанков).

Когда ты используешь for await (const chunk of response.body), цикл просыпается на каждый пришедший бинарный чанк. Это позволяет сразу начать отображение данных пользователю или запустить построчный парсинг.

Однако здесь скрывается опаснейший подводный камень, на котором спотыкаются многие программисты: разрыв многобайтовых символов UTF-8 на стыке чанков. В кодировке UTF-8 символы латиницы занимают 1 байт, но русские буквы, иероглифы и эмодзи кодируются последовательностью от 2 до 4 байт. Если граница сетевого пакета случайно пройдет прямо посередине четырехбайтового эмодзи, первая половина байтов придет в текущем чанке, а вторая — в следующем!

Если наивно вызывать стандартный декодер new TextDecoder().decode(chunk) на каждом шаге цикла, декодер наткнется на оторванный кусок байтов, посчитает его поврежденным и заменит на символ нечитаемой кракозябры `` (Unicode Replacement Character U+FFFD). Данные будут необратимо испорчены.

Чтобы избежать этой катастрофы, декодер TextDecoder создается один раз вне цикла, а при декодировании передается обязательный флаг { stream: true }:

// Создаем экземпляр декодера один раз до входа в поток
const utf8Decoder = new TextDecoder('utf-8');

async function processNetworkStream(url) {
  const response = await fetch(url);
  if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
  if (!response.body) throw new Error('ReadableStream не поддерживается');

  let totalBytesReceived = 0;

  // Итерация по потоку чанков через нативный асинхронный итератор
  for await (const chunk of response.body) {
    totalBytesReceived += chunk.byteLength;
    
    // Флаг stream: true указывает декодеру буферизовать незавершенные байты
    const textChunk = utf8Decoder.decode(chunk, { stream: true });
    renderIncrementalUI(textChunk, totalBytesReceived);
  }

  // Финальный сброс остаточного буфера декодера после закрытия потока
  const trailingText = utf8Decoder.decode();
  if (trailingText) renderIncrementalUI(trailingText, totalBytesReceived);
}

Флаг { stream: true } заставляет декодер сохранять незавершенные байты во внутреннем регистре и склеивать их со следующим входящим чанком.

Построчный потоковый парсинг больших данных (NDJSON и CSV)

Второй распространенный сценарий — потоковая обработка логов, метрик или таблиц в форматах NDJSON (Newline Delimited JSON) и CSV, где каждая запись отделена переводом строки . Сетевой чанк почти никогда не совпадает с границей строки: строка может начаться в одном чанке, а закончиться через два других.

Ниже представлен проверенный генератор-трансформер, преобразующий поток произвольных чанков ReadableStream в поток готовых текстовых строк без избыточного расхода памяти:

async function* streamLines(readableStream) {
  const decoder = new TextDecoder('utf-8');
  let leftoverBuffer = '';

  for await (const chunk of readableStream) {
    leftoverBuffer += decoder.decode(chunk, { stream: true });
    const lines = leftoverBuffer.split('
');
    
    // Последний элемент массива может быть неполной строкой: сохраняем его в буфер
    leftoverBuffer = lines.pop() || '';
    
    for (const line of lines) {
      const trimmed = line.trim();
      if (trimmed.length > 0) yield trimmed;
    }
  }

  // Обрабатываем остаток строки после завершения сетевого потока
  leftoverBuffer += decoder.decode();
  if (leftoverBuffer.trim().length > 0) {
    yield leftoverBuffer.trim();
  }
}

// Пример использования: парсинг тяжелого потока строк JSON
async function consumeEventStream(endpointUrl) {
  const response = await fetch(endpointUrl);
  for await (const line of streamLines(response.body)) {
    const payload = JSON.parse(line);
    dispatchApplicationEvent(payload);
  }
}

Мы уже рассматривали техники бережливой работы с памятью в статье печать книги из переписки без сервера: canvas, jsPDF и оптимизация памяти, и потоковое чтение fetch развивает этот подход на сетевом уровне.

Границы применимости и отмена запросов

Спецификация ReadableStream поддерживается всеми современными браузерами и серверными рантаймами (Node.js 18+, Deno, Bun). Однако при построении потоковых пайплайнов всегда помни о контроле жизненного цикла:

  • Прерывание потока: Обязательно передавай в fetch сигнал отмены AbortSignal. Если пользователь уходит со страницы или компонент размонтируется, вызов abortController.abort() немедленно закроет сетевое соединение, не допуская утечки сокетов.
  • Backpressure (обратное давление): Цикл for await...of нативно обеспечивает обратное давление: следующая порция данных запрашивается из сетевого буфера только после того, как завершилось тело текущей итерации цикла.

Потоковое чтение превращает браузер из пассивного накопителя данных в производительный реактивный конвейер, сохраняя стабильный FPS интерфейса даже при обработке сотен мегабайт информации.