Indicatore di avanzamento del download di un file con JavaScript

In questo articolo vedremo come implementare un indicatore di avanzamento del download di un file sfruttando le caratteristiche delle Fetch API.

Di solito siamo abituati ad usare fetch() per ottenere i dati JSON dalle REST API. Tuttavia, l'oggetto Response restituito da fetch ci permette di leggere gli header HTTP della risposta e la proprietà body dispone del metodo getReader() in quanto si tratta di un oggetto di tipo ReadableStream che ci consente di monitorare lo stream (flusso) di byte in ingresso.

Come prima cosa, andiamo a definire due funzioni di utilty per formattare le dimensioni del file da scaricare e i tempi del download.

function humanBytes(n) {
    const u = ['B', 'KB', 'MB', 'GB', 'TB'];
    let i = 0,
      v = n;
    while (v >= 1024 && i < u.length - 1) {
      v /= 1024;
      i++;
    }
    return `${v.toFixed(v >= 10 ? 0 : 1)} ${u[i]}`;
  }

  function humanTime(sec) {
    if (!isFinite(sec) || sec < 0) return '—';
    const h = Math.floor(sec / 3600);
    const m = Math.floor((sec % 3600) / 60);
    const s = Math.floor(sec % 60);
    return [h, m, s]
      .map((v, i) => (i === 0 ? v : String(v).padStart(2, '0')))
      .join(':');
  }

Usiamo 1024 come base per rappresentare in byte 1Kb. Per il tempo trascorso calcoliamo invece le ore, i minuti e i secondi aggiungendo uno zero iniziale dove richiesto.

La funzione principale dovrà essere strutturata in tre sezioni distinte:

  1. Inizializzazione dell'interfaccia e del download.
  2. Loop sullo stream in ingresso, aggiornando la barra di avanzamento con la percentuale di byte ricevuti e il messaggio nella barra di stato.
  3. Creazione del link di download per far mostrare al browser la finestra di download del file.
async function downloadFile(url, suggestedName) {
  // 1. Inizializzazione
    pct.textContent = '0%';
    bar.classList.remove('indeterminate');
    bar.style.width = '0%';
    meta.textContent = 'Connessione…';

    const res = await fetch(url);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);

    
    const total = Number(res.headers.get('Content-Length')) || 0;
    const disposition = res.headers.get('Content-Disposition') || '';
    const nameFromHeader = /filename\*?=(?:UTF-8''|")?([^\";]+)/i
      .exec(disposition)?.[1]
      ?.replace(/["']/g, '');
    const filename =
      suggestedName ||
      nameFromHeader ||
      new URL(url).pathname.split('/').pop() ||
      'download';

    const reader = res.body.getReader();
    const chunks = [];
    let received = 0;
    let t0 = performance.now(),
      lastUpdate = t0;

    if (!total) {
      bar.classList.add('indeterminate');
      pct.textContent = '—';
    }

     // 2. Loop sullo stream
    for (;;) {
      const { done, value } = await reader.read();
      if (done) break;
      chunks.push(value);
      received += value.byteLength;

      const now = performance.now();
      if (now - lastUpdate > 100) {
        lastUpdate = now;
        if (total) {
          const ratio = received / total;
          // Aggiornamento degli indicatori
          bar.style.width = `${(ratio * 100).toFixed(2)}%`;
          pct.textContent = `${Math.floor(ratio * 100)}%`;
          const elapsed = (now - t0) / 1000;
          const speed = received / elapsed; // B/s
          const eta = (total - received) / (speed || 1);
          meta.textContent = `${humanBytes(received)} / ${humanBytes(
            total
          )} • ${humanBytes(speed)}/s • ETA ${humanTime(eta)}`;
        } else {
          meta.textContent = `${humanBytes(
            received
          )} scaricati… (dimensione totale sconosciuta)`;
        }
      }
    }

    // 3. Creazione del link di download
    bar.classList.remove('indeterminate');
    bar.style.width = '100%';
    pct.textContent = '100%';
    meta.textContent = `Completato: ${humanBytes(received)} • Salvataggio…`;

    const blob = new Blob(chunks);
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = filename;
    a.style.display = 'none';
    document.body.appendChild(a);
    a.click();
    URL.revokeObjectURL(a.href);
    a.remove();

    meta.textContent = `Fatto! Salvato come “${filename}” (${humanBytes(
      received
    )}).`;

Possiamo quindi invocare questa funzione al click sul pulsante di download:

downloadBtn.addEventListener('click', async () => {
    const url = downloadBtn.dataset.url;
    downloadBtn.disabled = true;
    try {
      await downloadFile(url, url);
    } catch (err) {
      console.error(err);
      meta.textContent = `Errore: ${err.message}`;
    } finally {
      downloadBtn.disabled = false;
    }
  });

Demo

JavaScript Download Progress

Conclusione

Grazie alle Fetch API e all'interfaccia ReadableStream risulta relativamente semplice implementare un indicatore di avanzamento del download di un file. Si tratta di un notevole progresso nell'evoluzione degli standard del Web rispetto alle precedenti implementazioni con l'oggetto XMLHttpRequest.

Torna su