JavaScript Streams API
Impara la JavaScript Streams API: leggi dati progressivamente con ReadableStream, scrivi con WritableStream, trasforma con TransformStream e collega stream per elaborare grandi quantità di dati in modo efficiente.
La Streams API ti consente di elaborare i dati in piccoli blocchi man mano che arrivano, invece di caricare tutto in memoria in una volta sola. Questo è fondamentale per lavorare con file di grandi dimensioni, risposte di rete lente e dati in tempo reale: puoi iniziare a gestire i primi byte mentre il resto è ancora in transito, senza dover tenere l'intero payload in memoria.
L'API è costruita attorno a tre tipi fondamentali. Un ReadableStream è una sorgente da cui si estraggono i dati. Un WritableStream è un sink in cui si inviano i dati. Un TransformStream si trova nel mezzo, ricevendo blocchi da un'estremità ed emettendo blocchi modificati dall'altra. Una volta compresi questi tre tipi, puoi comporli in pipeline efficienti.
Lettura di uno Stream
Il modo più comune per ottenere uno stream è la Fetch API. Un oggetto Response espone il suo corpo come ReadableStream tramite response.body, permettendoti di consumare il download blocco per blocco invece di attendere l'intera risposta con response.text().
Per leggere manualmente, chiama getReader() per bloccare un reader sullo stream, poi esegui un ciclo su reader.read(). Ogni chiamata si risolve in un object con done e value:
const response = await fetch('/large-file.txt');
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
// value is a Uint8Array chunk of bytes
console.log('Received', value.length, 'bytes');
}Ogni value è un Uint8Array — un blocco di byte grezzi, non una string (vedi Typed arrays). Quando done è true, lo stream è terminato e value è undefined. Per convertire i byte in testo si usa tipicamente un TextDecoder, che può unire i blocchi anche quando un carattere multi-byte è suddiviso tra due letture:
Questo stesso ciclo è il modo in cui si costruiscono gli indicatori di avanzamento del download: somma la lunghezza di ogni blocco e confrontala con l'intestazione Content-Length.
Iterazione Asincrona
Negli ambienti moderni un ReadableStream è async-iterable, quindi puoi sostituire il ciclo manuale con for await...of (vedi iteratori e generatori asincroni):
const response = await fetch('/large-file.txt');
for await (const chunk of response.body) {
// chunk is a Uint8Array
console.log('Received', chunk.length, 'bytes');
}Questo approccio è più pulito perché il ciclo gestisce done per te e rilascia automaticamente il reader. Il limite è il supporto: Node.js gestisce bene questa funzionalità, ma l'iterazione asincrona diretta su response.body è ancora irregolare tra i browser.
Poiché il supporto dei browser per l'iterazione asincrona degli stream è inconsistente, il ciclo con getReader() rimane la forma più portabile. Usa for await...of in Node.js o quando controlli il runtime; torna al reader nel codice che deve funzionare ovunque.
Creare un ReadableStream
Puoi costruire la tua sorgente passando un oggetto underlying source al costruttore di ReadableStream. Può definire tre metodi facoltativi:
start(controller)viene eseguito una volta alla creazione dello stream — utile per l'inizializzazione o per inviare i dati iniziali.pull(controller)viene chiamato ogni volta che il consumer vuole più dati e la coda interna ha spazio disponibile.cancel(reason)viene eseguito se il consumer smette di leggere anticipatamente, permettendoti di fare pulizia.
Invii dati con controller.enqueue(chunk) e segnali la fine con controller.close():
Uno stream può trasportare qualsiasi valore JavaScript, non solo byte — qui emette numeri semplici. Quando la sorgente è lenta o aperta (un WebSocket, un timer, dati di sensori), inserisci la logica in pull() in modo che i blocchi vengano prodotti solo quando il consumer li richiede.
TransformStream
Un TransformStream modifica i blocchi mentre li attraversano. Gli si fornisce una funzione transform(chunk, controller) che riceve ogni blocco in entrata e chiama controller.enqueue() con il risultato trasformato:
const upperCaser = new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk.toUpperCase());
}
});Un transform stream espone un'estremità writable (dove i blocchi entrano) e un'estremità readable (dove escono), il che è esattamente ciò che rende possibile il piping.
La piattaforma include diversi transform già pronti, così raramente devi scrivere logica a livello di byte manualmente:
TextDecoderStream/TextEncoderStreamconvertono tra blocchi di byte e blocchi di testo.CompressionStream/DecompressionStreamapplicano gzip o deflate al volo.
Collegare Stream con Pipe
Invece di connettere reader e writer manualmente, puoi collegare gli stream direttamente. Esistono due metodi:
readable.pipeTo(writable)invia ogni blocco da uno stream readable in uno stream writable e risolve una promise al termine.readable.pipeThrough(transformStream)passa i dati attraverso un transform e restituisce un nuovo stream readable — perfetto per il concatenamento.
Combinare pipeThrough con TextDecoderStream fornisce blocchi di testo direttamente da una risposta di rete, senza gestire manualmente il decoder:
const response = await fetch('/large-file.txt');
const textStream = response.body.pipeThrough(new TextDecoderStream());
for await (const textChunk of textStream) {
console.log(textChunk); // already a string
}Puoi concatenare quanti stadi desideri — ad esempio response.body.pipeThrough(new DecompressionStream('gzip')).pipeThrough(new TextDecoderStream()) per decomprimere e decodificare in un'unica pipeline dichiarativa.
Backpressure
Un vantaggio chiave degli stream rispetto al buffering totale è la backpressure. Quando il consumer è lento, lo stream segnala automaticamente alla sorgente di interrompere la produzione, riprendendo non appena la coda si svuota. Con pipeTo e pipeThrough questo avviene automaticamente — un download veloce non supererà una scrittura su disco lenta e non esaurirà la memoria.
La backpressure è il motivo per cui lo streaming di un file da diversi gigabyte utilizza solo una piccola quantità limitata di memoria. Il produttore non si trova mai più di qualche blocco avanti rispetto al consumer, indipendentemente dalla dimensione totale del payload.
Casi d'Uso
Gli stream eccellono ogni volta che i dati sono grandi, lenti o continui:
- Rendering progressivo — mostra l'inizio di una risposta di grandi dimensioni mentre il resto sta ancora arrivando, invece di fissare uno schermo vuoto.
- Download e upload con avanzamento — misura i byte mentre fluiscono per alimentare una barra di avanzamento.
- Elaborazione di file di grandi dimensioni — gestisci un file blocco per blocco in modo che la memoria rimanga costante anche per file più grandi della RAM.
- Pipeline di compressione — passa attraverso
CompressionStreamoDecompressionStreamper comprimere i dati in gzip durante lo streaming.
Supporto di Browser e Ambienti
ReadableStream, WritableStream e TransformStream sono supportati in tutti i browser moderni e in Node.js (dove sono esposti anche tramite node:stream/web). Gli aspetti da monitorare sono le aggiunte più recenti: l'iterazione asincrona su response.body e CompressionStream sono arrivate più tardi, quindi verifica il supporto o fornisci un fallback con getReader() quando hai bisogno di ampia compatibilità. Gli stream sono strettamente correlati ai Blob — blob.stream() restituisce un ReadableStream, permettendoti di integrare oggetti simili a file in una pipeline di streaming.