Горшочек варит
2.26K subscribers
40 photos
11 videos
168 links
Про фронтенд и около, над чем работаю, разборы, мысли разные
Пишу для истории и тех, кому интересно как получается то, что у меня получается
// Рома Дворнов (@rdvornov)
Download Telegram
Для начала перечислим основные классы стримов:

- Node.js Streams (модуль node:stream ): Readable, Writable, Duplex (Readable & Writable) и Transform
- Web Streams: ReadableStream, WritableStream и TransformStream

Можно заметить симетрию, за исключением отсутствия DuplexStream в Web Streams. А еще можно вывести простое правило: если в имени класса есть суфикс "Stream", то это Web Stream, нет суффикса – Node.js Stream.

Для нашей темы интересны только Readable и ReadableStream соотвественно. Чтобы они стали iterable, им необходим метод Symbol.asyncIterator. Для класса Readable метод добавили еще в Node.js 10.0. C ReadableStream немного сложнее – в Node.js класс сразу появился с этим методом, а вот в браузерах поддержка стала появляться лишь недавно. Хронология занятная:

- 2015, май: Первая имплементация Web Streams в браузере, первопроходцем стал Chrome 43 – последним был Firefox в январе 2019
- 2017, август: В трекере WhatWG создан ишью по добавлению async iterable для ReadableStream по инициативе Node.js
- 2018, апрель: Выходит Node.js 10 с поддержкой Symbol.asyncIterator и for await ... of в V8, добавлен экпериментальный метод Readable[Symbol.asyncIterator]
- 2019, апрель: Завели тикеты для ReadableStream[Symbol.asyncIterator] в трекерах браузеров
- 2020, май: Релиз Deno 1.0 с полной поддержкой WebStreams, включая ReadableStream[Symbol.asyncIterator]
- 2021, июль: Релиз Node.js 16.5 с экспериментальной поддержкой Web Streams (модуль node:stream/web ), в том числе ReadableStream[Symbol.asyncIterator]
- 2022, апрель: Релиз Node.js 18.0 со стабильными Web Streams и fetch(), все в глобальном скоупе
- 2022, июль: Релиз Bun 0.1.1 с поддержкой Web Streams
- 2023, февраля: В Firefox 110 добавлен ReadableStream[Symbol.asyncIterator]
- 2024, апрель: В Chrome/Edge 124 добавлен ReadableStream[Symbol.asyncIterator]

Достаточно затянутая история, от предложения до поддержки во всех браузерах пройдет больше 7 лет (в Webkit/Safari тикет пока без движения). Интересно, что не смотря более сложное положение в "серверных" JavaScript рантаймах, полная поддержка Web Streams появилась именно в них. Задержка в браузерах связана с внедрением async iterable протокола как такого, судя по всему с этим были некоторые сложности.
Как бы то ни было, сегодня Symbol.asyncIterator для стримов доступен почти везде. Это небольшое, казалось бы, изменение значительно меняет эргономику использования стримов. Пример того, как приходилось использовались Node.js Streams раньше (и это по прежнему работает):


function getDataFromFile(filepath) {
return new Promise((resolve, reject) => {
let result = ...;

fs.createReadStream(filepath)
.on('data', chunk => /* chunk -> result */)
.on('end', () => resolve(result))
.on('error', reject);
});
}


Теперь же можно писать более естественный для JavaScript код:


async function getDataFromFile(filepath) {
let result = ...;

for await (const chunk of fs.createReadStream(filepath)) {
// chunk -> result
}

return result;
}


Или если мы говорим про поточный парсинг JSON, то используя json-ext выглядит это так:


import { parseChunked } from '@discoveryjs/json-ext';

const data = await parseChunked(fs.createReadStream('path/to/file.json'));


Для fetch() все тоже достаточно лаконично:


const response = await fetch(url);
const data = await parseChunked(response.body); // reponse.body -> ReadableStream


Чем это отличается от await reponse.json()? Для небольших JSON ничем. Для больших JSON parsedChunked() обычно быстрее, экономичнее по памяти и позволит избежать "заморозки" процесса при парсинге больших JSON. Причина: reponse.json() дожидается полной загрузки данных, а затем парсит за один присест со всеми вытекающими проблемами. Больше деталей и нюансов (например, как сделать не залипающий прогрессбар) в докладе, что я упоминал выше.
Кажется, что дело сделано, однако осталось еще две проблемы. Первая, самая важная, что делать, если имеется асинхронное апи без реализованного iterable протокола, например, тот же ReadableStream без Symbol.asyncIterator. Можно собрать асинхронный итератор вручную:


const data = await parseChunked({
[Symbol.asyncIterator]() {
const reader = stream.getReader();

return {
next: () => reader.read(),
return() { // вызовется в случае ошибки или преждевременного прерывания итерирования
reader.releaseLock();
return { done: true }; // важно
}
}
}
});


Но выглядит это как тайные заклинания. Лучше прибегнуть к асинхроному, в данном случае, генератору – да, да, вот где они нужны! Получается на пару строк больше, но код выглядит более целостным и линейным:


const data = await parseChunked({
[Symbol.asyncIterator]: async function*() {
const reader = stream.getReader();

try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
yield value;
}
} finally {
reader.releaseLock();
}
}
});


Стало лучше, однако если присмотрется, то обертка над генератором выглядит лишней и можно просто вызвать генератор, который вернет async iterable iterator. Внимательный читатель заметит, что здесь появились рядом два слова "iterable" и "iterator", то есть 2 в одном, что-то новенькое. Хоть это и итератор, но главное он iterable и это важно. Сделано это для того, чтобы итератор работал с for ... of и другими конструкциями. А получается это путем добавления итератору метода Symbol.asyncIterator (или Symbol.iterator для синхронного итератора), который возвращает this, то есть сам итератор. Таким элегантным трюком достигается выполнения протоколов и мы можем делать следующее:


const data = await parseChunked(async function*() {
// ...
}());


В такой записи все хорошо, кроме финальных скобок, которые могут визуально теряться и вызывать конфуз. Для этого я добавил возможность передавать в parseChunked() сам генератор, и он будет вызван перед началом парсинга. Кажется, что нужна какая то особая логика для определения, что переданное значение является генератором. Но по факту генератор выглядит как функция и "крякает" как функция, поэтому нет необходимости в особой детекции. Функции не iterable, поэтому не возникает конфликтов в логике. Бонусом становится возможным передавать функцию-фабрику, которая создает или возвращает iterable.


async function parseChunked(chunkEmitter) {
if (typeof chunkEmitter === 'function') {
chunkEmitter = chunkEmitter();
}
// ...
}


Таким образом, генераторы могут служить "мостом" между асинхронным апи и асинхронным итерированием. Протокол iterable делает стримы рядовыми участниками в этой схеме.

Ввиду неполной поддержки ReadableStream[Symbol.asyncIterator], в json-ext введена функция parseFromWebStream(), которую стоит использовать вместо parseChunked() для большей совместимости. Эта оборачивает стрим в асинхронный генератор, если необходимо, и вызывает parseChunked(). В будущем необходимость в ней отпадет, но пока так.
Последнее о чем стоит позаботиться это от "стрельбы по ногам". Передаваемые iterable могут эмитить любые значения, но в нашем случае нам подходят только строки и Uint8ArrayBuffer в Node.js, но он наследует от Uint8Array, поэтому не требует особо внимания в этом контексте). Кроме того, есть встроеные iterable значения, которые не пригодны для парсинго такие как String, TypedArray, Map и Set. Строки не подходят, так как итерируют символы, что даже будет работать, но явно не быстро и несколько странно. Если JSON не разбит на части, то смысл в parseChunked() тяряется, так как под капотом используется все тот же JSON.parse() только с оверхедом, лучше использовать нативное решение напрямую. По той же причине нет смысла особо обрабатывать TypedArray. Но чтобы не обрабатывать все возможные сценарии, достаточно проверить тип значения (чанк), которое эмитится (особый случай строки, так как символы это тоже строки).


function isIterable(value) {
return (
value !== null &&
typeof value === 'object' &&
(
typeof value[Symbol.iterator] === 'function' ||
typeof value[Symbol.asyncIterator] === 'function'
)
);
}

async function parseChunked(chunkEmitter) {
if (typeof chunkEmitter === 'function') {
chunkEmitter = chunkEmitter();
}

if (isIterable(chunkEmitter)) { // отсеивает строки, так как принимает только объекты
for await (const chunk of chunkEmitter) {
if (typeof chunk !== 'string' && !ArrayBuffer.isView(chunk)) {
throw new TypeError('Invalid chunk: Expected string, TypedArray or Buffer');
}

// выполняем парсинг
}

// return ...
}

throw new TypeError(
'Invalid chunk emitter: Expected an Iterable, AsyncIterable, generator, ' +
'async generator, or a function returning an Iterable or AsyncIterable'
);
}


Можно еще поддержать обычные итераторы (не iterable), но пока не увидел в этом необходимости.

С оберткой вокруг парсинга на этом все, можно переходить к stringify.
Генерация по частям

Генерация JSON по частям казалась более сложной задачей концептуально. Изначально основой решения был класс, наследующий от Readable, с нестандартным поведением: специальная обработка Promise и инстансов Node.js Streams. Таким образом решение было сильно привязано к Node.js стримам, что требовало переработки.

В таких ситуациях я обычно начинаю с удаления всего лишнего. В данном случае лишним было нестандартное поведение, унаследованное от json-stream-stringify, так как я начинал с улучшения этой библиотеки, пока не написал всё с нуля. Мне казалось удобным, что можно делать следующее:


stringifyStream({
dataFromService: fetch('url').then(res => res.json()),
files: {
foo: fs.createReadStream('path/to/file1'),
bar: fs.createReadStream('path/to/file2')
}
})


Но любое расширение имеет свою цену: нужно учитывать новые кейсы и конфликты (новые правила), опытным путем выявлять проблемы и находить для них решения. Например, Promise в свойстве объекта, который никогда не разрешится, может вызвать зависание сериализации. При сериализации отсутствует управление приоритетами и оптимизациями, например, если добавь await перед fetch() в примере выше, то мало что поменяется – имеет ли смысл имплементировать дополнительную логику внутри самой сериализации? Со стримами сложнее: контент стрима вставлялся как есть, и это невозможно повторить как-то иначе. Но и объяснить все кейсы сложно. Например, если вложенный стрим эмитит не JSON, то stringify не гарантирует валидный JSON. Кроме того, нестандартное поведение в обработке Promise и вложенных стримов было единственной причиной асинхронности генерации (stringify). Новые правила – это всегда головная боль.

Я решил, что следование стандартному поведению JSON.stringify() удовлетворит ожидания пользователей и избавит от необходимости дополнительной документации, поэтому убрал нестандартное поведение. Не зная, что делать дальше, я начал с переписывания сериализации на Web Streams. С этим получилось справиться достаточно быстро (что-то около получаса), так как Web Streams имеют более простой API по сравнению с Node.js Streams. Затем встал вопрос, как преобразовать Web Stream в Node.js Stream. Думал будет сложно, но оказалось всё уже придумано...
[Stream].from()

Из документации Node.js Streams я уже знал про Readable.from(), который создаёт поток из iterable. А теперь зная, что ReadableStream является iterable, метод заиграл новыми красками, так как достаточно:


Readable.from(readableStream)


Я предположил, что может уже есть специализированный методы для преобразований, и такие методы нашлись: Readable.fromWeb() и Readable.toWeb(). Правда помечены методы как экспериментальные и без документации (хотя добавлены еще в Node.js 17). Но можно предположить, что добавлены они для лучшей интеграции, например, для синхронизации highWaterMark и прочего. Таким образом преобразование стримов разных классов (Web <-> Node.js) оказалось тривиальной задачей.

Хотя методы Readable.fromWeb() и Readable.toWeb() экспериментальные и доступны только в Node.js 17, это не выглядит большой проблемой. Вместо Readable.fromWeb() всегда можно использовать Readable.from(), который работает с ReadableStream. Возможно, такая трансформация будет менее эффективна в определённых сценариях по сравнению с использованием Readable.fromWeb(), но в обычных сценариях я не заметил особой разницы.

Остаётся вопрос трансформации Readable в ReadableStream, то есть про альтернативу Readable.toWeb(). Было бы здорово, если бы существовал метод ReadableStream.from(), симметричный Readable.from()... На удачу обратился к статье Web Streams API документации Node.js и не поверил своим глазам, там такой метод обнаружился (полезно обращаться к документации). Выяснилось, что метод был добавлен не так давно и доступен в Node.js 20.6, Deno и Firefox (релизы июль-сентябрь 2023го). Это, конечно, не идеальное решение, но хорошая новость в том, что полифил или специализированная функция пишется несложно, и мы к этому ещё вернёмся.

В итоге, получение стрима нужного класса не является проблемой. База решения не обязана основываться на стримах, достаточно создать iterable. Как мы уже знаем, для этой задачи отлично подходят генераторы. Таким образом, задача сводится к написанию генератора, который потом используем как есть или преобразуем в нужный класс стрима.
Генератор для сериализации

Многие разработчики редко сталкиваются с необходимостью написания собственного генератора. На первый взгляд, это может показаться несложной задачей. Генераторы напоминают обычные функции, но отличаются синтаксисом (звездочкой в объявлении) и использованием оператора yield. Простой пример:


function* numericSequence(n) {
for (let i = 0; i < n; i++) {
yield i + 1;
}
}

console.log([...numericSequence(5)])
// [1, 2, 3, 4, 5]


Я не буду разбирать принцип работы и все нюансы генераторов, для этого лучше изучить документацию и статьи (раз, два, три – показались интересными, я не знаю "каноничных", посоветуйте). В контексте задачи сериализации следует учесть ключевую особенность генераторов: оператор yield может использоваться только внутри самой функции-генератора. Это логично, но создаёт определённые сложности, поскольку при сериализации в JSON (или другой формат) обычно применяется рекурсивный обход объектов и вызов вспомогательных функций. Чтобы использовать yield в таких функциях, они также должны быть генераторами. Для того чтобы значения из вложенных генераторов возвращались в основной генератор, необходимо использовать yield* (yield со звездочкой).


function* traverse(value) {
yield value;

if (value !== null && typeof value === 'object') {
for (const key of Object.keys(value)) {
yield* traverse(value[key]); // рекурсивный вызов
}
}
}

console.log([...traverse({ foo: 1, bar: { baz: [2, 3] } })]);
// [
// { foo: 1, bar: { baz: [2, 3] } },
// 1,
// { baz: [ 2, 3 ] },
// [ 2, 3 ],
// 2,
// 3
// ]


В этом примере каждый вызов генератора имеет своё собственное состояние в рамках своего скоупа. Это подходит для обхода, но не для задач вроде сериализации в JSON, где требуется общее состояние, доступное на всех этапах операции. Примерами такого состояния могут быть стек посещённых объектов (для предотвращения бесконечной рекурсии) или буфер для накопления фрагмента JSON. Эти элементы составляют внутреннее состояние генератора (операции).

Подробнее о буфере. В задаче сериализации генератор возвращает фрагменты JSON в виде строк. Эти фрагменты собираются из небольших строк (иногда даже из одного символа) в разных частях кода генератора. Если не использовать буфер для накопления строк, а возвращать каждую строку отдельно, это негативно скажется на производительности. Возврат значений из генератора — операция ресурсоёмкая, и частые вызовы могут привести к замедлению работы. Размер буфера обычно регулируется параметром, аналогичным highWaterMark в стримах, который определяет минимальный размер буфера перед возвратом его содержимого. Обычно значение по умолчанию составляет 64Kb. Это настройка помогает уменьшить количество операций и оптимизировать использование памяти.
Я выделяю три способа хранения состояния. При использовании классов (например, для генерации стрима) состояние хранится в свойствах экземпляра класса. В функциональном подходе состояние передается через объект между функциями. В случае генератора состояние можно хранить в переменных скоупа функции, как в обычных функциях, что упрощает реализацию. Вот как это может выглядеть:


function* traverse(value, highWaterMark = 10) {
function* push(str) {
buffer += str;

if (buffer.length >= highWaterMark) {
yield buffer;
buffer = '';
}
}

function* innerTraverse(v) {
if (v !== null && typeof v === 'object') {
yield* push('{');

let first = true;
for (const key of Object.keys(v)) {
yield* push(`${first ? '' : ','}"${key}":`);
yield* innerTraverse(v[key]); // рекурсивный вызов
first = false;
}

yield* push('}');
} else {
yield* push(JSON.stringify(v)); // для простоты
}
}

// внутренее состояние
let buffer = '';

// стартуем обход
yield* innerTraverse(value);

// возвращаем последнее значение
if (buffer !== '') {
yield buffer;
}
}

console.log([...traverse({ foo: 1, bar: { baz: 'hello world' } })]);
// [
// '{"foo":1,"bar":',
// '{"baz":"hello world"',
// '}}'
// ]


Представленный код реализует лишь небольшую часть логики, необходимой для сериализации данных в JSON. Цель примера — продемонстрировать возможный подход. Такой подход имеет место быть, но имеет недостатки: он изобилует оператором yield* и вряд ли будет эффективным. Причина кроется в накладных расходах, связанных с созданием множества итераторов, которые передают значения вверх по цепочке. Мои быстрые эксперименты показали, что производительность такого решения может быть ниже в 2-3 раза по сравнению с другим наиболее оптимальным подходом, который получилось найти на тот момент. Возможно, я еще вернусь к экспериментам с рекурсивными генераторами, но пока сомневаюсь, что такой подход может конкурировать по скорости работы.
Наиболее производительным подходом является использование конструкции, напоминающей конечный автомат, и цикла с единственной точкой возврата значения. На каждой итерации цикла выполняется небольшая операция в соответствии с текущим состоянием, проверяется состояние буфера и, если это необходимо, возвращается значение (в нашем случае – фрагмент JSON).

Общая структура генератора представлена ниже (полный код):


export function* stringifyChunked(value, options) {
// обработка опций
// ...

// внутренее состояние
const visited = [];
const rootValue = { '': value };
let prevState = null;
let state = () => printEntry('', value);
let stateValue = rootValue;
let stateEmpty = true;
let stateKeys = [''];
let stateIndex = 0;
let buffer = '';

// основной цикл
while (true) {
state();

if (buffer.length >= highWaterMark || prevState === null) {
// единственная точка возврата значения
yield buffer;
buffer = '';

if (prevState === null) {
break;
}
}
}

// ключевые функции
function printObject() { /* ... */ }
function printArray() { /* ... */ }
function printEntryPrelude(key) { /* ... */ }
function printEntry(key, value) { /* ... */ }
function pushState() { /* ... */ }
function popState() { /* ... */ }
}
Новая версия json-ext v0.6 опубликована около месяца назад, в которой реализованы ключевые изменения описанные выше. Изменения были нацелены на то, чтобы получить универсальное решение для генерации и парсинга JSON частями. Основной рефакторинг пришелся на stringifyChunked(), которая была переработана с Node.js Readable на функцию-генератор. Даже такой базовый рефакторинг дал небольшое ускорение (5-10%).

Благодаря тому, что вся логика (за исключением нескольких хелперов) и состояние стали изолированы внутри тела генератора, код стал более наглядным и удобным для рефакторинга. Последний месяц я набегами проводил эксперименты и перерабатывал stringifyChunked(), используя новые возможности организации кода. Как результат, реализация стала проще и короче (всего 191 строка), да к тому же быстрее в 1.5-3 раза. И уже в таком виде опубликована на этой неделе в версии json-ext v0.6.1.

Бонусом идет то, что код хорошо минифицируется – текущий размер stringifyChunked() всего 2141 байт (esbuild / ESM / minified), что в 3 раза меньше чем до перехода на генератор (то есть в v0.5). Весь минизированный код представлен на сриншоте выше. Впечатляет как столько сложной логики умещается в таком небольшом количестве символов. И даже начинает казаться, что всё не так уж и сложно.
Please open Telegram to view this post
VIEW IN TELEGRAM
Еще один вариант, который может помочь, если упираемся в размер кучи (heap), но физической памяти достаточно — это конвертация фрагментов JSON в Uint8Array (TypedArray) сразу после их получения, и сохранение их в таком виде в массив. Это может сработать, так как TypedArray хранятся вне кучи, и всё ограничивается доступной памятью в системе (включая своп и т.д.). Правда нужно надеяться, что GC будет успевать освобождать память под временные строки (фрагменты уже сконвертированные в Uint8Array).


const encoder = new TextEncoder();
const chunks = [];

for (const chunk of stringifyChunked(data)) {
chunks.push(encoder.encode(chunk))
}

Readable.from(chunks)
.pipe(fs.createWriteStream('path/to/file.json'));
В предыдущих постах я немало написал про то, как получать стримы, и главным образом Web Streams. И если применение Node.js Streams более-менее понятно — они с нами давно, и области их применения очевидны — то с Web Streams всё не так однозначно, особенно если исключить их использование в рамках fetch(), то есть потребление Response.body как стрима.

Тем не менее проникновение Web Streams в веб-платформу достаточно широкое, и когда это узнаёшь и начинаешь использовать, это вызывает восторг. По крайней мере, у меня так. Рассмотрим, где встречаются Web Streams и как они меняют подходы к решению задач.

Начнём с Blob, у которого появился метод stream(). Впервые это случилось в 2019 году в браузерах Chromium и Firefox, а Safari догнал остальных в 2021 году. И если Blob для большинства всё ещё экзотический примитив, то его наследник File (наследование не было добавлено сразу) встречается довольно часто. Так как File наследуется от Blob, он наследует и метод stream(). Этот метод возвращает экземпляр ReadableStream, и контент файла (или блоба) можно потреблять в более удобном и эффективном виде.

Чтобы почувствовать разницу, нужно рассмотреть, как читался контент до появления современных методов (уже упомянутого stream(), а также arrayBuffer() и text(), все они появились в 2019 году). Для этого использовался FileReader:


const reader = new FileReader();
reader.onload = (event) => {
console.log(event.target.result);
};
reader.onerror = (error) => {
console.error(error);
};
reader.readAsText(file);


Конечно, вряд ли получится использовать код в таком виде, и его необходимо дополнительно обернуть в функцию с колбэком или Promise.

Ещё печальнее обстояли дела, если нужно было вычитывать файл (блоб) по частям. Этот метод был достаточно сложным, но позволял обрабатывать большие файлы, избегая загрузки всего содержимого в память.

> Тут я уже обернул в Promise, чтобы было ближе к практическому применению.


function processFile(file, processChunk) {
const CHUNK_SIZE = 1024 * 1024; // Размер фрагмента 1 МБ

return new Promise((resolve, reject) => {
readChunk();
function readChunk(offset = 0) {
const end = offset + CHUNK_SIZE;
const slice = file.slice(offset, end);
const reader = new FileReader();

reader.onload = (event) => {
processChunk(event.target.result);

if (end >= file.size) {
resolve();
} else {
readChunk(end); // Читаем следующий фрагмент
}
};

reader.onerror = (err) => {
reject(err);
};

reader.readAsArrayBuffer(slice); // Читаем контент фрагмента
}
});
}

// Обработка файла по частям
await processFile(file, (chunk) => {
// Что-то делаем с фрагментом
});


Хвала инновациям, писать такой код больше не нужно, если нет задачи поддерживать браузеры старше 2021 года. Хотя такой код ещё можно встретить в статьях, документации или реальных проектах – не все ещё знают о нововведениях.

Современный подход, если нужно считать контент файла целиком:


await file.text(); // string
await file.arrayBuffer(); // ArrayBuffer
await file.bytes(); // Uint8Array — новинка этого года, доступно в Firefox и Safari, ждём в Chromium


А так считываем контент файла по частям:


const stream = file.stream();

// Метод 1. Для новых версий Chromium и Firefox
for await (const chunk of stream) {
// Делаем что-то с chunk
}

// Метод 2. Для Safari, старых версий Chromium и Firefox
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();

if (done) {
break;
}

// Делаем что-то с value (chunk)
}


Совсем другое дело!
Работа с File — это не только открытие файла через <input type="file">. Экземпляры File могут поступать, как минимум:

- На событие drop через dataTransfer
- На событие paste через clipboardData
- В составе экземпляра FormData

Возможно, где-то ещё, но уже этого немало. С обработкой FormData на клиенте мне работать не приходилось, но с этим не должно быть ничего особенного, а с остальным доводилось. Поэтому, если нам нужно поддержать загрузку файла с диска, через drag&drop, из буфера обмена, из fetch(), не нужно реализовывать отдельную логику считывания и обработки для каждого случая. Вместо этого необходимо реализовать основную логику как обработку стрима и несколько простых функций-хелперов, которые преобразуют определённый примитив в стрим:


async function loadDataFromStream(stream: ReadableStream) {
// Здесь основная логика: считываем и обрабатываем данные
}

function loadDataFromBlob(blob: Blob) { // File тоже принимается
return loadDataFromStream(blob.stream());
}

// Promise<Response> чтобы поддержать `loadDataFromResponse(fetch(...))`
async function loadDataFromResponse(response: Response | Promise<Response>) {
return loadDataFromStream((await response).body);
}

function loadDataFromEvent(event: DragEvent | ClipboardEvent | InputEvent) {
const source =
(event as DragEvent).dataTransfer ||
(event as ClipboardEvent).clipboardData ||
(event.target as HTMLInputElement | null);
const file = source?.files?.[0];

if (!file) {
throw new Error('No file found');
}

return loadDataFromBlob(file);
}


Для событий, конечно, нужно ещё добавить обработчики, что-то вроде этого:


fileInput.addEventListener('change', loadDataFromEvent);
container.addEventListener('drop', loadDataFromEvent, true);
document.addEventListener('paste', (event) => {
// Чтобы обрабатывать только вставку файлов и не возникала ошибка
if (event.clipboardData?.files?.length > 0) {
loadDataFromEvent(event);
}
});


Вот и всё: логика обработки локализована в одной функции, можно загружать файл через <input type="file">, drag&drop, из буфера обмена, из fetch(). Конечно, этим всё не ограничивается, и об этом будет дальше.

> Стоит заметить по поводу вставки из буфера. Используется старое (легаси) API, которое предоставляет список файлов, и их можно обрабатывать. Но активируется оно только через контекстное меню браузера или горячие клавиши (`Ctrl+V` или `Cmd+V`); программно это сделать нельзя. Современное Clipboard API позволяет программную активацию (например, по клику на кнопке), но не предоставляет доступа к файлам, если они есть в буфере обмена. Так и живём.
Итак, мы теперь можем потреблять контент из File (`Blob`), Response (`fetch()`) и непосредственно из ReadableStream. Рассмотрим, как добавить к этому другие типы источников.

Получаем `ReadableStream`

Если наше значение является итератором (синхронным или асинхронным), можно использовать ReadableStream.from(), о чём я писал ранее. Пока это доступно только в Node.js, Deno, Bun и Firefox; будем надеяться, что скоро появится в остальных браузерах. Имея генератор, достаточно его "активировать", получив итератор.


loadDataFromStream(ReadableStream.from(iterator));
loadDataFromStream(ReadableStream.from(generator()));


Когда ReadableStream.from() недоступен или значение не совсем итератор (или генератор), можно обернуть его в ReadableStream самостоятельно. При работе со стримами нужно помнить, что есть два основных паттерна. Упрощённо: генерируем до отказа (горшочек варит, несмотря ни на что) либо генерируем по требованию. В большинстве случаев оптимальным является второй вариант, но случаи бывают разными.

Свой аналог ReadableStream.from() можно реализовать следующим образом:

> Реализацию isIterable я приводил ранее


function readableStreamFrom(input) {
return new ReadableStream({
async start() {
const iterator = typeof input === 'function'
? await input() // Generator
: input; // Iterator

if (!isIterable(iterator)) {
throw new TypeError(
'Invalid chunk emitter: Expected an Iterable, AsyncIterable, generator, ' +
'async generator, or a function returning an Iterable or AsyncIterable'
);
}

this.iterator = iterator;
},
async pull(controller) {
const { value, done } = await this.iterator.next();

if (!done) {
controller.enqueue(value);
} else {
controller.close();
}
},
cancel() {
this.iterator = null;
}
});
}


Такая реализация поддерживает синхронные и асинхронные итераторы и генераторы, а также функции, возвращающие итераторы.

Если у нас всего одно простое значение — строка или Uint8Array — и его нужно обернуть в ReadableStream, всё гораздо проще:


new ReadableStream({
start(controller) {
controller.enqueue(chunk);
}
});


В целом, в методе start() можно вызывать controller.enqueue(chunk) любое количество раз — генерируем всё и сразу (тот самый горшочек). Вызов enqueue() ставит значение во внутреннюю очередь, и оно отдаётся, когда потребитель запрашивает следующую порцию данных (чанк). Вариант субоптимальный, но в некоторых сценариях может оказаться полезным.
Используем `Response`

Для преобразования "одиночных" значений в ReadableStream можно также использовать конструктор Response() и прокидывать его в аналог loadDataFromResponse() или передавать его свойство body сразу в loadDataFromStream().


loadDataFromResponse(new Response('...file content...'));
loadDataFromStream(new Response('...file content...').body);


Конструктор Response() принимает широкий набор значений: Blob, ArrayBuffer, TypedArray, DataView, FormData, ReadableStream, URLSearchParams и строки.

Таким образом, Response() может служить достаточно универсальным конвертером одиночных значений в ReadableStream. Однако стоит понимать, что переданное значение обычно не бьётся на чанки (если это не ReadableStream; тогда чанки из стрима передаются как есть, но могут и переразбиваться). Так, если в Response() положить ArrayBuffer на 100 мегабайт, то, скорее всего, он (а вернее, его копия) и выпадет на принимающей стороне единственным чанком. Поэтому реальной пользы от конвертации в стрим, кроме как подгонки под интерфейсы, не будет.

---

Интересно заметить, что Response() можно использовать для конвертации FormData() в строку ("multipart/form-data") и обратно, что я обнаружил, когда писал этот текст. Сам FormData() по какой-то причине такой функциональностью не обладает. Но, манипулируя методами Response(), этого несложно добиться:

> Не думаю, что так было задумано, скорее побочный эффект, от того решение выглядит ещё более интересным. Я даже помучал ChatGPT (4o и o1-preview), и он не знал и не смог вывести решение (чат с o1). Гугл тоже не помог найти что-то подобное. Но я не претендую на оригинальность 😉


const formData = new FormData();
formData.set('value', 123);
formData.set('myfile', new File(['{"hello":"world"}'], 'demo.json', { type: 'application/json' }));

// FormData -> multipart/form-data string
const multipartString = await new Response(formData).text();

console.log(multipartString);
// ------WebKitFormBoundaryQi7NBNu0nAmyAhpU
// Content-Disposition: form-data; name="value"
//
// 123
// ------WebKitFormBoundaryQi7NBNu0nAmyAhpU
// Content-Disposition: form-data; name="myfile"; filename="demo.json"
// Content-Type: application/json
//
// {"hello":"world"}
// ------WebKitFormBoundaryQi7NBNu0nAmyAhpU--



// multipart/form-data string -> FormData
const boundary = multipartString.match(/^\s*--(\S+)/)[1];
const restoredFormData = await new Response(multipartString, {
headers: {
// Без правильного заголовка не распарсится
'Content-Type': 'multipart/form-data;boundary=' + boundary
}
}).formData();

console.log([...restoredFormData]);
// [
// ['value', '123'],
// ['myfile', File { ... }]
// ]
Используем Blob

Для многих, как до недавнего времени и меня, Blob выглядит этаким "загадочным зверем", основная задача которого — получить URL для контента, чтобы подставить его туда, где принимается URL и по какой-то причине нельзя использовать dataURI. То есть ключевой сценарий использования такой:


const imageEl = document.createElement('img');
const imageBlob = new Blob([content], { type: 'image/png' });

imageEl.src = URL.createObjectURL(imageBlob);


Однако Blob гораздо полезнее, чем может казаться. Стоит обратить внимание на первый параметр — это массив. Мне, и думаю, не только мне, это всегда казалось странным, пока я не решил разобраться: "а всё-таки, зачем?" — и полез в документацию.

Оказалось, что первый параметр называется blobParts. Это итерируемый объект (iterable object), который может содержать ArrayBuffer, TypedArray, DataView, Blob или строки в любой комбинации. Эти значения конкатенируются в контент блоба. Строки конвертируются в бинарное представление как UTF8 (чем-то вроде TextEncoder.encode() ). То есть мы получаем эдакий аналог нодовского Buffer.concat(), прямого аналога которого в веб-платформе нет.

Это позволяет просто и быстро склеивать TypedArray и/или ArrayBuffer. Это может пригодиться в реализации loadDataFromStream(), если обработка возможна только при полностью загруженном контенте файла. Так, вместо ручной конкатенации:


function concatBuffers(arrays) {
const totalSize = arrays.reduce((total, array) => total + array.byteLength, 0);
const result = new Uint8Array(totalSize);
let offset = 0;

for (const array of arrays) {
result.set(array, offset);
offset += array.byteLength;
}

return result;
}


Можно задействовать Blob:


async function concatBuffers(arrays) {
return new Uint8Array(await new Blob(arrays).arrayBuffer());

// В Firefox, Safari, Node.js, Bun, Deno (то есть везде, кроме Chromium браузеров)
// можно еще проще:
// return await new Blob(arrays).bytes();
}


Однако существенный минус — эта процедура становится асинхронной.

Также можно не задумываться о ручной конвертации строк в Uint8Array (`ArrayBuffer`), то есть если нужно сконвертировать фрагменты в UTF-8 и склеить. Это сделает Blob автоматически. Кроме того, можно обойти пределы на максимальную длину строки. Обычными способами получить строку больше ~500 МБ в V8 нельзя, но закодированную из фрагментов в TypedArray — можно.


const string300mb1 = '...300Mb...';
const string300mb2 = '...300Mb...';

string300mb1 + string300mb2; // RangeError: Invalid string length
new Blob([string300mb1, string300mb2]); // Ok


> Однако я проверил, насколько это быстро работает, и получил достаточно странные результаты. Работает это довольно медленно. В Chromium и Safari на моём MacBook M1 создание такого Blob занимает около 1.2 секунды, а вот в Firefox в 3 раза быстрее — около 450 мс, что удивительно. Но рано клеймить Blob в медленности, так как оказалось, что медленный не сам Blob, а скорей TextEncoder.encode(), который отрабатывает примерно за то же время. А вот если использовать TextEncoder.encodeInto() в заранее созданный буфер, то всё начинает работать намного быстрее. Судя по всему, в Safari и Chromium есть проблемы с TextEncoder.encode() при аллокации буферов. В общем, это отдельная тема для исследования.
Также вспомним, что blobParts — это итерируемый объект, а значит, можно передать итератор (к сожалению, только синхронный, но и это не мало):


function* helloWorld() {
yield 'hello ';
yield 'world!';
}

console.log(await new Blob(helloWorld()).text());
// "hello world!"


Или реальный пример с json-ext:


import { stringifyChunked } from '@discoveryjs/json-ext';

const jsonAsBlob = new Blob(stringifyChunked(data)); // stringifyChunked — синхронный генератор


Но главная суперсила Blob в текущем контексте в том, что мы можем легко получить из него стрим, вызвав метод stream(). И в отличие от Response(), такой стрим будет бить свой контент на чанки.

> После проверки оказалось, что всё не так гладко. Chromium действительно бьёт на чанки произвольного размера (кратные 64 КБ, в диапазоне от 64 КБ до ~1,5 МБ), и по этому я делал первоначальный вывод. Однако Firefox бьёт контент на очень большие чанки, по 256 МБ. Safari же не бьёт на чанки совсем и выдаёт контент блоба целиком одним чанком. Но у Safari такая проблема не только с Blob, но и с fetch() — он очень сильно буферизирует ответ и отдаёт очень большие чанки, если контент загружается быстро.

Последнее, что стоит отметить: всё описанное для Blob справедливо и для File. И в целом, разница между File и Blob лишь в том, что у первого есть дополнительные поля name и lastModified, которые, в том числе, можно задать при создании:


new File([/* fileParts */], 'filename.ext', { lastModified: Date.now() });
Пока работаю над следующими постами, хотел бы узнать ваше мнение. Мы уже затрагивали тему больших JSON-файлов (правда тогда было непонимание, зачем это нужно, и споры о том, что именно считать "большим"). В последние годы моё восприятие (perception) этой границы постоянно менялось в большую сторону, но мне интересно узнать, как это выглядит у других, и где сейчас проходит средняя планка.

Ответьте, пожалуйста, на вопрос: с какого объёма JSON (или подобный формат) для вас начинает ощущаться "большим"? Речь именно о вашем субъективном ощущении, а не о нормах или стандартах. Это во многом определяется тем, насколько комфортно работать с таким объёмом данных. Если взаимодействие с ним становится затруднительным или некомфортным, то это уже можно отнести к категории "больших".

Важно уточнить: речь не идёт о типичных ответах сервера для стандартных приложений или сайтов. Интересуют профили, снепшоты, логи, дампы и тому подобное — те файлы, которые приходится открывать, анализировать и с ними работать, будь то напрямую или через специализированные инструменты.
Какой размер JSON для вас начинает ощущаться "большим"?
Anonymous Poll
24%
> 1 Mb
28%
> 5 Mb
14%
> 25 Mb
16%
> 100 Mb
2%
> 250 Mb
4%
> 500 Mb
12%
> 1 Gb