В поисках интересного контента о фронтенде и смежных темах? Тогда канал "Горшочек варит" (@gorshochekvarit) - это то, что вам нужно! Здесь вы найдете разборы, мысли на разные темы и узнаете, над чем работает Рома Дворнов (@rdvornov). Этот канал призван поделиться опытом и знаниями в области фронтенда, а также вдохновить тех, кому интересно узнать, как создаются интересные проекты. Присоединяйтесь к сообществу "Горшочек варит" и узнавайте первыми о новых разработках и тенденциях в мире веб-разработки!
28 Jan, 10:42
14 Jan, 07:39
30 Dec, 08:09
30 Dec, 08:09
13 Dec, 19:05
"run_at": "document_start"
), пытается обнаружить <pre>
и проверить первый текстовый узел, что это похоже на JSON. Если всё сходится, то инициализируется интерфейс и пользователю показывается прогресс загрузки. Если нет — расширение прекращает свою активность. Важно, что, обнаружив <pre>
, JsonDiscovery «выключает» рендеринг его контента, чтобы это не оказывало влияние на загрузку. Если впоследствии окажется, что контент не является JSON, то все изменения откатываются.{}
, минимизируя их влияние. Так и просится "JsonDiscovery наносит ответный удар" :) При этом, все это происходит только если загружается валидный JSON (или есть вероятность этого, пока ничего еще не загружено), в противном случае JsonDiscovery отступает (откатывает изменения) и перестает влиять на дальнейшее.push()
по мере появления новых текстовые узлов контента. Теперь, учитывая необходимость передачи данных в iframe через postMessage()
, старый подход становился крайне сложным. Более простым решением стало использовать ReadableStream
, а имея стрим передать его в другой процесс (iframe) дело не такое сложное (и об этом будет отдельно). Другими словами, мы создаем ReadableStream
, прокидываем в него чанки из DOM и получаем такой вот «экзотический» стрим. Сам же стрим прокидываем в приложение в iframe. Приложение (вьювер) уже умеет работать со стримами (наподобии того, как я описывал ранее), и ему совершенно не важно, что откуда в стрим попадают данные, что это из другого процесса, да еще и из DOM.sandbox
, но и для allow
, чтобы включить необходимое и не тригерить страшные алерты:
<iframe src="app.html"
sandbox="allow-scripts allow-popups allow-modals"
allow="clipboard-read; clipboard-write"
></iframe>
Ctrl+V
и Cmd+V
, можно вставлять и файлы, скопированные из Finder, на Windows мы не проверяли). Так что если анализируете JSONы, особенно большие, и еще не пробовали JsonDiscovery – самое время его испытать ;)13 Dec, 19:00
eval
или new Function()
) в основном процессе запрещена. Однако есть обходной путь — выполнять это в специально созданном iframe с атрибутом sandbox
(sandboxed frame). Иными словами, вместо работы в рамках страницы, где выполняется content script (скрипт расширения, который встраивает браузер), приложение загружается в изолированном окружении, а доступ к данным и API доступное расширениям организуется через обмен сообщениями с родительским процессом ( postMessage()
).text/html
) особым образом — формируют специальный документ с <pre>
в body, к которому прикрепляют контент как текстовые узлы (TextNode). Если текст большой (сотни килобайт и более), он делится на несколько текстовых узлов (чанков), которые добавляются по мере загрузки.DOMContentLoaded
или запускают content script после загрузки документа, а затем делают что-то вроде JSON.parse(pre.textContent)
и инициализируют интерфейс. Но такой подход плохо масштабируется — уже на объёмах порядка 1 МБ браузер начинает "умирать", а если точнее, то закладка начинает виснуть. И проблема не в JSON.parse()
, а в том, что браузер пытается загружаемый текст разбить на строки и отрендерить, что съедает огромное количество ресурсов браузера. В итоге, загрузить даже 10 МБ таким образом проблематично. Поэтому в JsonDiscovery мы "заморочились" 😉 13 Dec, 12:25
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()
, такой стрим будет бить свой контент на чанки.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
Blob
занимает около 1.2 секунды, а вот в Firefox в 3 раза быстрее — около 450 мс, что удивительно. Но рано клеймить Blob
в медленности, так как оказалось, что медленный не сам Blob
, а скорей TextEncoder.encode()
, который отрабатывает примерно за то же время. А вот если использовать TextEncoder.encodeInto()
в заранее созданный буфер, то всё начинает работать намного быстрее. Судя по всему, в Safari и Chromium есть проблемы с TextEncoder.encode()
при аллокации буферов. В общем, это отдельная тема для исследования. 05 Dec, 08:15
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()
, этого несложно добиться:
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.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()
. Конечно, этим всё не ограничивается, и об этом будет дальше.04 Dec, 19:23
fetch()
, то есть потребление Response.body
как стрима.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) => {
// Что-то делаем с фрагментом
});
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
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
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
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"',
// '}}'
// ]
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
// ]
highWaterMark
в стримах, который определяет минимальный размер буфера перед возвратом его содержимого. Обычно значение по умолчанию составляет 64Kb. Это настройка помогает уменьшить количество операций и оптимизировать использование памяти. 08 Aug, 16:28
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го). Это, конечно, не идеальное решение, но хорошая новость в том, что полифил или специализированная функция пишется несложно, и мы к этому ещё вернёмся.08 Aug, 16:08
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
Uint8Array
(и Buffer
в 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'
);
}
31 Jul, 15:36
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();
}
}
});
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();
}
// ...
}
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);
});
}
async function getDataFromFile(filepath) {
let result = ...;
for await (const chunk of fs.createReadStream(filepath)) {
// chunk -> result
}
return result;
}
import { parseChunked } from '@discoveryjs/json-ext';
const data = await parseChunked(fs.createReadStream('path/to/file.json'));
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:stream
): Readable
, Writable
, Duplex
(Readable & Writable) и Transform
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 класс сразу появился с этим методом, а вот в браузерах поддержка стала появляться лишь недавно. Хронология занятная:ReadableStream
по инициативе Node.jsSymbol.asyncIterator
и for await ... of
в V8, добавлен экпериментальный метод Readable[Symbol.asyncIterator]
ReadableStream[Symbol.asyncIterator]
в трекерах браузеровReadableStream[Symbol.asyncIterator]
node:stream/web
), в том числе ReadableStream[Symbol.asyncIterator]
fetch()
, все в глобальном скоупеReadableStream[Symbol.asyncIterator]
ReadableStream[Symbol.asyncIterator]
31 Jul, 15:18
JSON.parse()
называется parseChunked()
. На самом деле, эта функция могла использоваться в браузере и раньше (и активно используется в Discovery.js, например), так как ее интеграция с Node.js Streams была минимальной. Её имплементация уже базировалась на протоколе async iterable, но была отдельная ветка для Node.js Readable, которая в браузере не использовалась и в итоге была спилена.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();
}
31 Jul, 15:16
JSON.stringify()
без генерации самого JSON. Цель – обеспечить максимально быстрые и эффективные по памяти решения для работы с большими JSON.stringify()
и parse()
в json-ext была связана с Node.js Streams, что ограничивало сценарии использования. С развитием Web Streams в браузерах, а также поддержкой в Node.js, Deno и Bun, появилась потребность в их поддержке "из коробки". Но если смотреть дальше и немного пофантазировать, что в JavaScript однажды добавятся "поточные" stringify()
и parse()
, то такая гипотетическая инмлементация должна основываться не на конкретных стримах, а на протоколах самого языка. То есть получалось три разные реализации для различных вводных. Перспектива так себе, и не столько с точки зрения кода библиотеки, сколько со стороны ее пользователей: что и когда использовать, как обеспечивать работу в изоморфном коде и прочие вопросы.30 Jul, 07:09
30 Jul, 07:09
@discoveryjs/json-ext
, и я рассказывал о ней и проблемах больших JSON в докладе JSON: Push the limits, который не потерял свою актуальность 3,5 года спустя (сам пересматривал 3-4 раза за это время).json-ext
, что я и сделал около месяца назад... 03 Jun, 07:14
10 May, 04:29
17 Jan, 19:50
17 Jan, 19:38