Горшочек варит @gorshochekvarit Channel on Telegram

Горшочек варит

@gorshochekvarit


Про фронтенд и около, над чем работаю, разборы, мысли разные
Пишу для истории и тех, кому интересно как получается то, что у меня получается
// Рома Дворнов (@rdvornov)

Горшочек варит (Russian)

В поисках интересного контента о фронтенде и смежных темах? Тогда канал "Горшочек варит" (@gorshochekvarit) - это то, что вам нужно! Здесь вы найдете разборы, мысли на разные темы и узнаете, над чем работает Рома Дворнов (@rdvornov). Этот канал призван поделиться опытом и знаниями в области фронтенда, а также вдохновить тех, кому интересно узнать, как создаются интересные проекты. Присоединяйтесь к сообществу "Горшочек варит" и узнавайте первыми о новых разработках и тенденциях в мире веб-разработки!

Горшочек варит

30 Dec, 08:09


Не смотря на то, что загрузка на видео выглядит быстрой (даже с учетом, что запись видео немного притормаживает), есть некоторые шороховатости. На 100Mb JSON'а это не сильно заметно, но на бóльших размерах, порядка 500Mb или 1Gb, появляется ощущение, что что-то не так. Заключается оно в том, что прогрессбар появляется не сразу, как должно быть.

В логике загрузки данных Discovery.js прогрессбар появляется не сразу — есть задержка в 300ms, чтобы прогрессбар "не мелькал", если загрузка завершается быстрее. Для файлов до 50Mb на моем ноутбуке так и происходит: загрузка заканчивается быстрее 300ms, и прогрессбар просто не появляется. Другими словами, для таких размеров прогрессбар заметить практически невозможно.

Но при тестировании меня не покидало ощущение, что задержка появления прогрессбара длится явно дольше, чем ожидаемые 300ms. При этом эта задержка увеличивается с увеличением размера файла и проявляется только в случаях, когда файл открывается непосредственно в закладке или загружается через сеть. В плейграунде эта задержка не наблюдается. Всё указывало на то, что при загрузке через DOM что-то происходит иначе. Но как я ни пытался это отладить или замерить, разобраться не удалось. И заметил я это уже после релиза JsonDiscovery, когда готовил материалы для будущих постов. Понять, в чём дело, помогла случайность.

Я работал над второй попыткой добавить TransformStream в пайплайн загрузки данных в Discovery.js, чтобы интегрировать DecompressionStream. Добавить такую интеграцию не так сложно, но мне мало просто добавить функциональность — важно, чтобы она работала оптимально. Пока мои эксперименты не дали хороших результатов: стало ясно, что нужно больше времени, чем у меня было, и я отложил работу. Но в процессе пришло понимание, что для эффективности пайплайна стримов нужно балансировать размер чанков (payload size) на каждом узле пайплайна. Если этого не делать, то на определённых узлах скапливается слишком много данных, что приводит к увеличеному потреблению памяти, либо размер чанков требует больше времени на обработку, что приводит к блокировке main thread, и выливается в то, что пайплайн работает рывками. А должно быть так, чтобы каждый узел пайпдлайна занимал пропорциональное время, и данные протекали плавно и с предсказуемой скоростью. Но об этом ещё будут посты, когда удастся со всем разобраться.

Как бы там ни было, я протестировал экспериментальный пайплайн с TransformStream на JsonDiscovery и обнаружил, что тот самый "экзотический" стрим JsonDiscovery, что читает из DOM, поставляет чанки в приложение не самым эффективным образом. Проблем оказалось две.

Первая — размер чанков в DOM часто составляет 64Kb. Это приводит к тому, что возникает большое количество сообщений (чанков), которые проходят через стримы и передаются между процессами. При передаче данных между процессами размер сообщений играет меньшую роль, чем их количество. Другими словами, передать сообщение размером 64Kb или 1Mb между процессами по затратам времени примерно одинаково, а вот передача скажем 100 сообщений или 1000 – может отличаться существенно. Все дело в паузах между сообщениями. Так, чтобы передать 100Mb по 64Kb необходимо около 1600 сообщений. Даже если пауза между сообщениями составляет всего 1ms, это уже потеря около 1.6 секунды. На практике задержки между сообщениями могут быть как меньше, так и больше. Поэтому обычно буферизируют сообщения и отправляют пачками. Так поступил и я, добавив буферизацию чанков до 1Mb при чтении из DOM, что позволило практически вдвое ускорить открытие файлов. Хотя это не было ускорением вычитывания файлов, а ускорение вычитывания контента из DOM, так как время открытия файла сравнялось со временем открытия файла в плейграунде, где контент получается напрямую из файловой системы, минуя DOM.

Горшочек варит

30 Dec, 08:09


Вторая проблема на первый взгляд даже не выглядела проблемой. На старте расширения оказывалось, что все чанки файла уже есть в DOM, то есть в памяти. Но как это возможно, если контент должен грузиться итеративно? Такое поведение было не всегда, и появилось в течении последних месяцев. Дальнейшее расследование показало, что так происходит только в том случае, если контент определяется как JSON (по mime type), в противном случае текстовый контент загружается итеративно, как и раньше. В случае, когда контент считается JSONом, то для него создается специальный внутренний тип документа – JSONDocument. Именно так Chromium браузеры встраивают свои дополнения для просмотра JSON, переопределяя метод CreateDocumentStructure.

Суть последних изменений в том, что теперь весь JSON контент сначала загружается полностью в память, а лишь затем инициализируется документ. Таким образом, до завершения загрузки контента никакой код, включая расширения, не выполняется, так как документ ещё попросту не инициализирован. Это и объясняет задержку с появлением прогрессбара. А также объясняет почему не получалось это отловить, ведь я делал замеры JavaScript'ом, а он выполнялся уже после инициализации документа. Этим же объясняется и разница во времени загрузки файла: если контент JSON, то время не включает время загрузки с диска, а если обычный текст – то включает.

С одной стороны, новое поведение делает работу с JSON контентом быстрее и проще, ведь всё уже в памяти. Но с другой — пользователь видит пустую страницу до окончания загрузки, ведь мы не можем ничего показать. Найти причины этих изменений мне не удалось: поиск по коду и тикетам ничего не дал. Если кто-то знает больше, напишите в комментариях. Лично мне кажется, что это вредное изменение, которое лучше бы откатить.

И это не еднственная подобная история.

Горшочек варит

13 Dec, 19:05


JsonDiscovery стартует ещё на фазе создания документа ("run_at": "document_start"), пытается обнаружить <pre> и проверить первый текстовый узел, что это похоже на JSON. Если всё сходится, то инициализируется интерфейс и пользователю показывается прогресс загрузки. Если нет — расширение прекращает свою активность. Важно, что, обнаружив <pre>, JsonDiscovery «выключает» рендеринг его контента, чтобы это не оказывало влияние на загрузку. Если впоследствии окажется, что контент не является JSON, то все изменения откатываются.

Дополнительной сложностью стало появление в Chromium встроенного дефолтного JSON-viewer’а где-то год назад (причем в Edge своя отдельная вариация). Он примитивен, но создаёт проблемы: меняет структуру DOM и ломает прежние механизмы детекции (чем сломали почти все JSON viewer’ы), и с большими JSON он справляется плохо, виснет или может ломаться. Поэтому JsonDiscovery делает так, чтобы встроенные просмотрщики видели контент как пустой объект {}, минимизируя их влияние. Так и просится "JsonDiscovery наносит ответный удар" :) При этом, все это происходит только если загружается валидный JSON (или есть вероятность этого, пока ничего еще не загружено), в противном случае JsonDiscovery отступает (откатывает изменения) и перестает влиять на дальнейшее.

Отдельно о потреблении данных: раньше мы использовали сложное решение с временным буфером и Promise, передавая фрагменты в приложение через push() по мере появления новых текстовые узлов контента. Теперь, учитывая необходимость передачи данных в iframe через postMessage(), старый подход становился крайне сложным. Более простым решением стало использовать ReadableStream, а имея стрим передать его в другой процесс (iframe) дело не такое сложное (и об этом будет отдельно). Другими словами, мы создаем ReadableStream, прокидываем в него чанки из DOM и получаем такой вот «экзотический» стрим. Сам же стрим прокидываем в приложение в iframe. Приложение (вьювер) уже умеет работать со стримами (наподобии того, как я описывал ранее), и ему совершенно не важно, что откуда в стрим попадают данные, что это из другого процесса, да еще и из DOM.

Так мы переработали и другие функции, учитывая новую парадигму (работа в iframe) и новые ограничения, часть перевели на современные API, сделав их эффективнее. А чтобы все работало на iframe потребовалось выставить правильные значения не только для атрибута sandbox, но и для allow, чтобы включить необходимое и не тригерить страшные алерты:


<iframe src="app.html"
sandbox="allow-scripts allow-popups allow-modals"
allow="clipboard-read; clipboard-write"
></iframe>


Кроме того, в новую версию JsonDiscovery вошли улучшения в части загрузки и обработки JSON, сделанные в Discovery.js и json-ext за последние месяцы. В итоге, загрузка и работа с большими JSON стали быстрее и комфортнее.

Переход на iframe и сопутствующие изменения наконец позволили добавить JsonDiscovery в качестве самостоятельного приложения, открываемого в отдельной вкладке. В нём можно выбрать файл с диска, закинуть файл перетаскиванием (drag & drop) или вставить JSON из буфера обмена (причем через Ctrl+V и Cmd+V, можно вставлять и файлы, скопированные из Finder, на Windows мы не проверяли). Так что если анализируете JSONы, особенно большие, и еще не пробовали JsonDiscovery – самое время его испытать ;)

А вот так выглядит загрузка 100 МБ JSON-файла (с полным парсингом) в JsonDiscovery на MacBook M1 8Gb 2020:

Горшочек варит

13 Dec, 19:00


Пару недель назад мы с Денисом Колесниковым обновили JsonDiscovery (полгода назад я писал о нём подробнее и о докладе Дениса) — браузерное расширение для просмотра и анализа JSON, которое стало ещё лучше 🙂 Хотя основные улучшения произошли «под капотом».

Главным вызовом стало переведение расширения на Manifest V3. Google на протяжении последних двух лет активно продвигает переход на новый манифест, а с лета начал делать это более агрессивно: для расширений на Manifest V2 повсюду отображаются предупреждения о скором прекращении их работы. Проблема в том, что Manifest V3 — это не просто новый формат манифеста, но и иной режим работы расширения: меняется доступное API, часть функций становится недоступной, добавляются новые ограничения. Всё во имя безопасности и приватности, как водится. С некоторыми изменениями можно согласиться, часть из них спорная, но в любом случае они создают головную боль для разработчиков, и многие стараются оттянуть миграцию. Поэтому пока Google не может просто взять и окончательно «обрубить» поддержку Manifest V2, хотя случиться это может в любой момент (они же предупреждали).

В случае с JsonDiscovery ключевой проблемой стало обеспечить возможность компиляции кода. Эта функциональность необходима, поскольку JsonDiscovery позволяет делать запросы к JSON, а эти запросы компилируются из строки в JavaScript. Как можно догадаться, в Manifest V3 компиляция кода (с помощью eval или new Function()) в основном процессе запрещена. Однако есть обходной путь — выполнять это в специально созданном iframe с атрибутом sandbox (sandboxed frame). Иными словами, вместо работы в рамках страницы, где выполняется content script (скрипт расширения, который встраивает браузер), приложение загружается в изолированном окружении, а доступ к данным и API доступное расширениям организуется через обмен сообщениями с родительским процессом ( postMessage() ).

К счастью, в Discovery.js, на котором основан JsonDiscovery, такой механизм был добавлен ещё в начале прошлого года. Мы используем эту возможность для интеграции приложений и дашбордов, построенных на Discovery.js, в другие приложения и окружения. Но такого механизма не значит, что все заработает по принципу "просто добавь воды" – потребовалось переработать почти все функции взаимодействия с браузерными API и источником данных.

В частности, был изменён механизм «чтения» данных (input consumption). Браузеры загружают текстовый контент (если это не text/html) особым образом — формируют специальный документ с <pre> в body, к которому прикрепляют контент как текстовые узлы (TextNode). Если текст большой (сотни килобайт и более), он делится на несколько текстовых узлов (чанков), которые добавляются по мере загрузки.

Такое представление данных неудобно для потребления. Любое расширение, работающее с текстовыми документами (включая JSON-viewer’ы, вроде JsonDiscovery), вынуждено использовать DOM API с учётом множества нюансов, особенно если обрабатывают контент по мере загрузки.Но обычно мало кто заморачивается, и просто ждут DOMContentLoaded или запускают content script после загрузки документа, а затем делают что-то вроде JSON.parse(pre.textContent) и инициализируют интерфейс. Но такой подход плохо масштабируется — уже на объёмах порядка 1 МБ браузер начинает "умирать", а если точнее, то закладка начинает виснуть. И проблема не в JSON.parse(), а в том, что браузер пытается загружаемый текст разбить на строки и отрендерить, что съедает огромное количество ресурсов браузера. В итоге, загрузить даже 10 МБ таким образом проблематично. Поэтому в JsonDiscovery мы "заморочились" 😉

Горшочек варит

13 Dec, 12:25


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

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

Важно уточнить: речь не идёт о типичных ответах сервера для стандартных приложений или сайтов. Интересуют профили, снепшоты, логи, дампы и тому подобное — те файлы, которые приходится открывать, анализировать и с ними работать, будь то напрямую или через специализированные инструменты.

Горшочек варит

05 Dec, 08:18


Также вспомним, что 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() });

Горшочек варит

05 Dec, 08:17


Используем 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() при аллокации буферов. В общем, это отдельная тема для исследования.

Горшочек варит

05 Dec, 08:15


Используем `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 { ... }]
// ]

Горшочек варит

05 Dec, 08:09


Итак, мы теперь можем потреблять контент из 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() ставит значение во внутреннюю очередь, и оно отдаётся, когда потребитель запрашивает следующую порцию данных (чанк). Вариант субоптимальный, но в некоторых сценариях может оказаться полезным.

Горшочек варит

04 Dec, 19:23


Работа с 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 позволяет программную активацию (например, по клику на кнопке), но не предоставляет доступа к файлам, если они есть в буфере обмена. Так и живём.

Горшочек варит

04 Dec, 19:23


В предыдущих постах я немало написал про то, как получать стримы, и главным образом 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)
}


Совсем другое дело!

Горшочек варит

09 Aug, 17:23


Еще один вариант, который может помочь, если упираемся в размер кучи (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'));

Горшочек варит

09 Aug, 14:10


Новая версия 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). Весь минизированный код представлен на сриншоте выше. Впечатляет как столько сложной логики умещается в таком небольшом количестве символов. И даже начинает казаться, что всё не так уж и сложно.

Горшочек варит

09 Aug, 11:30


Наиболее производительным подходом является использование конструкции, напоминающей конечный автомат, и цикла с единственной точкой возврата значения. На каждой итерации цикла выполняется небольшая операция в соответствии с текущим состоянием, проверяется состояние буфера и, если это необходимо, возвращается значение (в нашем случае – фрагмент 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() { /* ... */ }
}

Горшочек варит

09 Aug, 11:22


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


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 раза по сравнению с другим наиболее оптимальным подходом, который получилось найти на тот момент. Возможно, я еще вернусь к экспериментам с рекурсивными генераторами, но пока сомневаюсь, что такой подход может конкурировать по скорости работы.

Горшочек варит

09 Aug, 11:13


Генератор для сериализации

Многие разработчики редко сталкиваются с необходимостью написания собственного генератора. На первый взгляд, это может показаться несложной задачей. Генераторы напоминают обычные функции, но отличаются синтаксисом (звездочкой в объявлении) и использованием оператора 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. Это настройка помогает уменьшить количество операций и оптимизировать использование памяти.

Горшочек варит

08 Aug, 16:28


[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. Как мы уже знаем, для этой задачи отлично подходят генераторы. Таким образом, задача сводится к написанию генератора, который потом используем как есть или преобразуем в нужный класс стрима.

Горшочек варит

08 Aug, 16:08


Генерация по частям

Генерация 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. Думал будет сложно, но оказалось всё уже придумано...

Горшочек варит

31 Jul, 15:57


Последнее о чем стоит позаботиться это от "стрельбы по ногам". Передаваемые 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.

Горшочек варит

31 Jul, 15:36


Кажется, что дело сделано, однако осталось еще две проблемы. Первая, самая важная, что делать, если имеется асинхронное апи без реализованного 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(). В будущем необходимость в ней отпадет, но пока так.

Горшочек варит

31 Jul, 15:28


Как бы то ни было, сегодня 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() дожидается полной загрузки данных, а затем парсит за один присест со всеми вытекающими проблемами. Больше деталей и нюансов (например, как сделать не залипающий прогрессбар) в докладе, что я упоминал выше.

Горшочек варит

31 Jul, 15:23


Для начала перечислим основные классы стримов:

- 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 протокола как такого, судя по всему с этим были некоторые сложности.

Горшочек варит

31 Jul, 15:18


Парсинг по частям

В json-ext "потоковый" аналог JSON.parse() называется parseChunked(). На самом деле, эта функция могла использоваться в браузере и раньше (и активно используется в Discovery.js, например), так как ее интеграция с Node.js Streams была минимальной. Её имплементация уже базировалась на протоколе async iterable, но была отдельная ветка для Node.js Readable, которая в браузере не использовалась и в итоге была спилена.

Сегодня все так или иначе используют iterable protocol, но не всегда знают об этом. Не смотря на то, что это относительно простое и элегантное решение, не всегда очевидно как все устроенно, да я сам не так давно во всем разобрался. По сути все сводится к созданию итераторов используя методы Symbol.iterator или Symbol.asyncIterator. Разница в том, что для вызова методов второго нужно использовать await. Как следствие, для Symbol.iterator используется for ... of, для второго for await ... of. Прекрасная новость, что for await ... of также работает и для синхронного итератора: конструкция делает предпочтение в пользу Symbol.asyncIterator, но если такой метод не определен, то подойдет и Symbol.iterator. Таким образом, чтобы проитерировать чанки для парсинга достаточно следующего кода:


async function parseChunked(iterable) {
let parser = new JsonChunkParser();

for await (const chunk of iterable) {
parser.push(chunk);
}

return parser.finish();
}


Возникает вопрос: как к этому прикрутить стримы? Удивительно, но самом деле все уже работает, по крайней мере в Node.js, Deno, Bun и с недавнего времени в браузерах, за исключением Safari. Посмотрим как так получилось.

Горшочек варит

31 Jul, 15:16


Библиотека json-ext задумавалась для того, чтобы предоставить возможности работы с JSON, которых не хватает в стандартной среде выполнения. В настоящее время она предлагает "поточные" генерацию и парсинг JSON, а также функцию вычисления размера результата JSON.stringify() без генерации самого JSON. Цель – обеспечить максимально быстрые и эффективные по памяти решения для работы с большими JSON.

Изначально реализация поточных stringify() и parse() в json-ext была связана с Node.js Streams, что ограничивало сценарии использования. С развитием Web Streams в браузерах, а также поддержкой в Node.js, Deno и Bun, появилась потребность в их поддержке "из коробки". Но если смотреть дальше и немного пофантазировать, что в JavaScript однажды добавятся "поточные" stringify() и parse(), то такая гипотетическая инмлементация должна основываться не на конкретных стримах, а на протоколах самого языка. То есть получалось три разные реализации для различных вводных. Перспектива так себе, и не столько с точки зрения кода библиотеки, сколько со стороны ее пользователей: что и когда использовать, как обеспечивать работу в изоморфном коде и прочие вопросы.

Пазл был не простой, но в итоге он сложился. Идея состояла в том, чтобы создать реализацию на протоколах JavaScript, поверх которой можно было бы сделать обертку для разных типов стримов. Последнее, впрочем, не потребовалось (почти), но обо всем по порядку.

Горшочек варит

30 Jul, 07:09


Для задач, с которыми я часто сталкиваюсь, одной из центральных проблем является размер JSON. За последние 5-6 лет удалось нивелировать эту проблему и даже предложить решения в open source. Тем не менее, по-прежнему встречаются попытки решать проблему по-своему, что приводит к различным обходным путям, ограничениям и усложнениям. Поэтому проблема остается актуальной и стоит о ней поговорить. Существует парадокс: когда я спрашиваю других, есть ли у них проблемы с большими JSON, мне обычно отвечают, что таких проблем нет. Хотя наверняка в их рабочем процессе есть несколько таких мест, где проблема актуальна, но их почему-то не замечают, и проблемы вроде как не существует.

Когда поднимается тема больших JSON, часто спрашивают, в каких задачах это может возникать. Часто даются общие советы: большое количество данных – это плохо, декомпозируйте данные на части. То есть решение проблемы заключается в избегании проблемы. Иногда предлагают использовать базы данных или что-то подобное, но на практике это усложняет проблему, добавляя распределенность и все, что с этим связано. Это объясняется тем, что при обсуждении данных представляют обычные приложения или сайты (непосредственно продукты), и в таком случае рекомендации вполне уместны. Однако данные - это не только сами продукты, но и множество артефактов, связанных с их созданием и развитием.

Большие объемы данных обычно возникают в инструментах разработки и различных частях инфраструктуры. Например, описание результата сборки (stats.json в webpack), результаты выполнения тестов, различные профили (cpu/performance profile), снепшоты (memory heap snapshot) и т.д. Для малых и средних проектов размер данных может быть умеренным, но на больших проектах данные могут достигать сотен мегабайт и гигабайтов в представлении JSON. Например, в нашем основном репозитории 800 тысяч файлов вместе с файлами в node_modules. Только лишь массив строк путей к файлам относительно корня проекта занимает 73Мб в JSON. Добавляя атрибуты к файлам, размер JSON быстро переваливает за первую сотню мегабайт. Это весьма большое количество файлов, и это нужно оптимизировать, чем мы и занимаемся. Но для оптимизации, выявления аномалий и перекосов, нужен анализ, а для анализа необходимы дополнительные данные, что приводит к сотням мегабайт даже на такой относительно простой задаче.

Уменьшение количества деталей для уменьшения размера - это потеря возможностей или точности анализа, разбиение на части лишь усложняет манипуляции с данными. Кроме того, такие опции доступны не всегда. Например, при захвате Heap Snapshot в браузерных devtools можно выбрать включать ли числовые значения в снепшот или нет, что, конечно, дает экономию в размере, но не радикальную (около 10% размера, в зависимости от приложения). Запись профиля на закладке Performance в тех же devtools имеет чуть больше опций, но этого явно недостаточно и приводит к ограничениям. В дев версии нашего продукта есть специальное меню для записи профиля длительностью 15, 30 и 45 секунд, и мне всегда было интересно, почему нельзя записывать более длинные сессии, хотя такая потребность есть. Недавно узнал, что профили для более длинных сессий с высокой вероятностью крэшат devtools при открытии, а раз нельзя открыть, то нет смысла и записывать. Сама по себе запись более длинных сессий тоже проблема: даже если удалось записать сессию без креша, то она может не записать в файл, если итоговый JSON больше 512Mb (ниже будет пояснение), а если все же получится записать в файл, то его нельзя будет открыть в devtools по той же причине (512Мб) или из-за нехватка памяти. С новыми версиями Chromium devtools в профиль добавляется больше событий, сокращая максимальную длительность сессии. В общем, проблемы с большим JSON у обычных разработчиков вроде как нет, пока не нужно отлаживать что-то действительно большое.

Горшочек варит

30 Jul, 07:09


JSON не оптимальный формат для больших наборов данных. Однако он прост и широко поддерживается в индустрии, встроен в некоторые среды исполнения, как JSON.parse()/stringify() в JavaScript. К тому же это своего рода протокол, регламентирующий правила сериализации и десериализации данных. Альтернативные форматы возможны, но они, на мой взгляд, должны следовать протоколу JSON, и их преимущество должно быть настолько существенным, чтобы оправдать усложнение и затраты на внедрение. Разница в размере относительно JSON должна быть в разы, а лучше в десятки раз, что вряд ли возможно добиться текстовым представлением, поэтому стоит ожидать бинарного кодирования в качестве альтернативы (и это основное усложнение). Однако доступные в экосистеме JavaScript бинарные кодировки (MessagePack, BSON, CBOR, Node.js v8.serialize; protobuf и ему подобные не учитываются, так как требует схемы, что является другим классом решений) дают в лучшем случае -20% к размеру относительно JSON. Именно с этим я связываю не столь широкое распространение бинарных альтернатив JSON, каким оно могло бы быть. Нужен другой подход, и мы еще однажды вернемся к этой теме. Пока стоит принять как данность, что JSON является основным форматом, и его размер может достигать сотен мегабайт и гигабайтов.

Основные проблемы, связанные с большим размером JSON:

- Ограничение JavaScript движков на максимальную длину строки, что определяет максимальный размер JSON для движка (JSON это строка); для V8 это около 512Мб, у других движков лимиты выше (от 1Гб), но все равно недостаточны.
- Большое потребление памяти в один момент времени; после генерации или парсинга JSON строка находится в памяти (в куче), что может приводить к превышению физического лимита памяти (например, на контейнерах с малыми бюджетами) или размера кучи (обычно 2-4Гб), что в случае V8 приводит к крэшу процесса.
- Блокирование движка для других операций; JSON.parse()/stringify() выполняются синхронно, и для больших JSON могут работать несколько секунд и даже минут. Это не большая проблема для скриптов, чья единственная задача сгенерировать или обработать JSON. Но проблема для скриптов, которые выполняют несколько операций паралельно или обрабатывают запросы, а так же для браузеров, так как эти операции замораживают закладку, лишая пользователя возможности взаимодействовать со страницей.

Решением этих проблем может быть использование поточной (stream) обработки JSON (генерация и парсинг). Встроенных в runtime решений для JSON пока нет. Библиотек тоже не так чтобы много, и они обычно неэффективны, главным образом по времени. Четыре года назад разрыв с нативными JSON.parse()/stringify() был во много десятков раз. В то время я начал оптимизировать одно из таких решений и мне удалось добиться разрыва в 1.5-3 раза относительно нативных синхронных решений, что было более приемлемо. Так появилась библиотека @discoveryjs/json-ext, и я рассказывал о ней и проблемах больших JSON в докладе JSON: Push the limits, который не потерял свою актуальность 3,5 года спустя (сам пересматривал 3-4 раза за это время).

Однако, на этом история только начинается. Чтобы двигаться дальше, нужно было переработать имплементацию json-ext, что я и сделал около месяца назад...

Горшочек варит

03 Jun, 07:14


JsonDiscovery: JSON viewer done right

На прошлой неделе Денис Колесников выступил с докладом про JsonDiscovery на митапе PragueJS. Доклад получился динамичным, много живых демо, шикарные слайды (Денис мастер анимаций) и подача – уровень хорошей конференции. И вот стала доступна запись выступления, крайне рекомендую ознакомиться.

JsonDiscovery это расширение для браузеров (Chromium и Firefox), которое позволяет отображать загружаемый по урлу (или из файла) JSON в более удобном виде. Но это не просто вьювер для JSON, которых предостаточно в сторах. JsonDiscovery предлагает непревзойденную скорость в загрузке больших JSON (в докладе есть таблица с замерами, спойлер: минимум в 10 раз быстрее остальных), способен справляться с действительно большими JSON (спойлер: сотни мегабайт и даже гигабайты, другие расширения сдаются уже на 100Мб), и предлагает широкий спектр функциональности для анализа данных. Все это раскрывает и хорошо демонстрирует Денис в своем докладе. Там еще и не преднамеренная демонстрация cpupro случилась на секции вопросов, кривенько, но зато... хотя хватит спойлеров, лучше посмотрите сами 🙂

У меня в системе дефолтный вьювер для JSON это Chrome, в котором включен JsonDiscovery, и я совершенно не переживаю за размер JSON файла, когда их открываю – файл всегда откроется, и откроется быстро. Это один из ключевых инструментов, которым я пользуюсь практически каждый день, и мне уже сложно представить свою работу без JsonDiscovery. Все больше коллег, которые его тоже используют, и как я не пройду мимо их мониторов – у них всегда открыт какой нибудь файл в JsonDiscovery. Что интересно, способы и кейсы использования при этом у всех разные.

JsonDiscovery это обертка над Discovery.js (на нем же сделан и cpupro, который я анонсировал в предыдущем посте), которая немного расширяет его базовые возможности и интегрирует в браузер. Кажется просто и ничего интересного, но на деле интеграция это очень сложно, и это то, над чем Денис бьется уже несколько лет (а я немного помогаю). Основные вызовы это минимизация влияния расширения на загрузку страниц (сейчас это <2ms), поиск решений для преодоления ограничений для расширений, "дружба" с CSP и прочими security policy, а главное лавирование среди багов браузеров (то что работает для обычных страниц - часто забаговано для расширений, тикеты висят годами). Если коротко – нужно чтобы расширение имело влияние на браузер близкое к нулю и работало в максимальном количестве случаев. И часто это из серии "миссия невыполнима". Про эти тонкости в докладе нет, так как эти проблемы интересны и полезны малому кругу инженеров. Но если держать это в голове, когда смотришь как все работает в докладе, или когда пользуешься JsonDiscovery, то это добавляет немного градуса к восприятию. По крайней мере у меня так. Я сам иногда забываю, насколько большой путь был проделан, чтобы получить тот опыт, которые есть сейчас при работе с JSON, когда используешь JsonDiscovery. Но самое интересное, что это еще не конечная и многое еще впереди.

В общем, посмотрите, попробуйте, выскажите свое мнение в комментариях 🖖

Видео на Youtube
Слайды (PDF, 30Mb)

Горшочек варит

10 May, 04:29


Не смотря на то, что профилирование JavaScript является мощным инструментом, мне всегда не хватало возможностей инструментов. Так что последние годы я почти перестал пользоваться этим методом. Для меня это просто не работало: для небольших скриптов – слишком мало деталей, для долго работающих (например, сборка webpack или выполнение unit-тестов) – сплошная каша. Оказывалось, что инструментирование с помощью console.log() работает куда эффективнее, чем ковыряние в CPU профилях. В тоже время, мне казалось это неправильным.

2,5 года назад, очередной раз разбираясь в тормозящей webpack сборке, и делая аналитику что и как работает, представил как это могло бы выглядеть в инструментах. Пришла идея кластеризации функций по модулям, пакетам, категориям. Это казалось очевидным решением, странно, что никто еще не сделал. "Если никто не сделал, наверное это не будет работать" – думал я тогда. Но "не попробуешь не узнаешь", и решил попробовать реализовать – вдруг заработает.

Сначала я начал разбираться с тем как устроен формат ".cpuprofile". Было ничего непонятно, и казалось очень сложным. Но на деле вышло, что ничего сложного в этом нет, сложно только разобраться. Сложность в том, что материалов про то как устроен формат и про логику его работы, и вообще профилирование с точки зрения аналитики, обнаружить не удалось. Обычно ограничиваются базовыми туториалами по профилированию. Информацию приходилось собирать по крупицам: по ишуям, по комментариям, по коду v8 и т.д. И еще много экспериментировать.

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

Время от времени я понемногу допиливал CPUpro, продолжал экспериментировать. А некоторое время назад пришло озарение, на фоне размышлении о том, как сделать сравнение профилей "до" и "после" без сопоставления двух флеймчартов глазками. Сравнение профилей пока не реализовано, но многое для этого уже сделано (высокая производительность, низкое потребление памяти), и вообще стало получаться что-то приличное. Теперь я ковыряюсь в CPU профилях каждый день, и все больше коллег делают тоже самое.

В общем, инструмент приобрел состояние, когда его не стыдно показать 🙂 Еще много что предстоит сделать, но он полезен уже сейчас, нам уже не раз помог в работе. Особенно если нужно проанализировать профайл на десятки или сотни мегабайт.

Release notes (там больше деталей, бенчмарки и скриншоты)
Твит анонса (спасибо за лайк, ретвит)
CPUpro (GitHub)

Горшочек варит

17 Jan, 19:50


Кстати, есть достаточно старый бенчмарк CSS минификаторов, которому лет десять не меньше. Видимо на фоне анонса Parcel CSS из него наконец-то убрали мертвые проекты и добавили туда esbuild и Parcel CSS. Время в табличке похоже на время для холодного кода – когда я добавил разогрев, у всех время значительно опустилось.

Горшочек варит

17 Jan, 19:38


Пара графиков для наглядности. На графике слева показано отношение времени между холодным и горячим кодом (график обрезан по 200ms, у cssnano еще 1,5 синиго бара вверх).
На втором графике, как меняется время между вызовами (шкала логарифмическая). Можно увидеть, что код начинает быстрее работать начиная со второго вызова. У csso и clean-css уменьшение времени не такое резкое, как у других – предполагаю там имеют место деоптимизации, которые невелируются к 3-4 вызову. Всплески на графиках связаны со срабатываютщим GC, это как раз про эффекты друг на друга.