Programação Por Equipe DevBras

Capa do artigo: Barra de progresso de upload com XMLHttpRequest que não mente no 100%

Barra de progresso de upload com XMLHttpRequest que não mente no 100%

Mostre a porcentagem real do envio, troque para um estado de processamento quando os bytes acabam, trate falha e cancelamento e evite arquivo duplicado no reenvio.

A barra chega a 100% e a tela fica parada em "Enviando..." por mais 15 segundos. A pessoa acha que travou, recarrega, envia de novo, e o servidor fica com dois vídeos iguais. A barra não mentiu sobre os bytes: eles saíram mesmo. Ela mentiu ao sugerir que tinha acabado. A resposta curta: use o evento progress de xhr.upload para a porcentagem do envio, troque para um estado indeterminado ("conferindo o arquivo") quando o envio termina, e só diga "pronto" quando o servidor responder 2xx. Um identificador por envio evita a duplicata quando a pessoa tenta de novo. Para tratar o caso em que a barra chega a 100% e o arquivo falha, leia também sobre como mostrar o erro de cada upload.

O exemplo deste guia é um portal de imobiliária em que corretores sobem vídeos de visita, de uns 48 MB. Numa conexão com 10 Mbps de upload, o envio leva cerca de 40 segundos (48 × 8 = 384 megabits, divididos por 10). Depois disso, o servidor ainda confere o tipo, gera miniatura e grava no armazenamento, o que leva mais 10 a 15 segundos.

Por que o 100% chega antes do fim

Um upload tem duas fases. Na primeira, o navegador transmite o corpo da requisição, e ele sabe exatamente quantos bytes já mandou e quantos faltam. Na segunda, o servidor recebeu tudo e trabalha: valida, move, processa e só então responde. Dessa segunda fase o navegador não sabe nada até a resposta chegar.

O evento de progresso descreve só a primeira fase. Quando ele chega a 100%, o navegador terminou de enviar, não o servidor de processar. Se a interface usa a mesma barra para as duas fases, ela fica cheia e parada justamente na parte em que a pessoa mais precisa de informação.

Um detalhe sobre o total: o total do evento é o tamanho do corpo inteiro, incluindo os separadores do multipart/form-data. Ele é um pouco maior que o arquivo, o que não muda nada para a barra, mas explica por que o número não bate exatamente com o tamanho no disco.

Por que não fetch

O fetch é o padrão para requisições hoje, mas ainda não expõe progresso de envio de forma padronizada entre os navegadores. Para mostrar a porcentagem do upload, o XMLHttpRequest continua sendo a ferramenta certa, com xhr.upload emitindo os eventos do envio e o próprio xhr emitindo os da resposta.

Como implementar as duas fases


<form id="form-video">
  <label for="video">Vídeo da visita</label>
  <input id="video" name="video" type="file" accept="video/mp4" required>
  <button type="submit" id="enviar">Enviar</button>
  <button type="button" id="cancelar" hidden>Cancelar</button>
</form>
<progress id="barra" max="100" value="0" hidden></progress>
<p id="estado" role="status"></p>

const form = document.getElementById('form-video');
const botaoEnviar = document.getElementById('enviar');
const botaoCancelar = document.getElementById('cancelar');
const barra = document.getElementById('barra');
const estado = document.getElementById('estado');
let idEnvio = null;
let xhrAtual = null;

function enviar(arquivo) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhrAtual = xhr;
    const dados = new FormData();
    dados.append('video', arquivo);

    xhr.open('POST', '/upload-video.php');
    xhr.setRequestHeader('Idempotency-Key', idEnvio);
    xhr.timeout = 10 * 60 * 1000;

    xhr.upload.addEventListener('progress', (e) => {
      if (!e.lengthComputable) return;
      const pct = Math.floor((e.loaded / e.total) * 100);
      barra.value = pct;
      estado.textContent = 'Enviando: ' + pct + '%';
    });
    xhr.upload.addEventListener('load', () => {
      barra.removeAttribute('value');
      estado.textContent = 'Vídeo recebido. Conferindo o arquivo no servidor...';
      botaoCancelar.hidden = true;
    });

    xhr.addEventListener('load', () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        resolve(JSON.parse(xhr.responseText));
      } else {
        reject(new Error('O servidor recusou o envio (HTTP ' + xhr.status + ').'));
      }
    });
    xhr.addEventListener('error', () => reject(new Error('A conexão caiu durante o envio.')));
    xhr.addEventListener('timeout', () => reject(new Error('O envio demorou demais.')));
    xhr.addEventListener('abort', () => reject(new Error('Envio cancelado.')));

    xhr.send(dados);
  });
}

form.addEventListener('submit', async (e) => {
  e.preventDefault();
  const arquivo = document.getElementById('video').files[0];
  if (!arquivo) return;
  if (!idEnvio) idEnvio = crypto.randomUUID();

  botaoEnviar.disabled = true;
  botaoCancelar.hidden = false;
  barra.hidden = false;
  barra.value = 0;
  try {
    const r = await enviar(arquivo);
    estado.textContent = 'Pronto. Vídeo salvo com o código ' + r.id + '.';
    idEnvio = null;
  } catch (erro) {
    estado.textContent = erro.message + ' Você pode tentar de novo.';
  } finally {
    botaoEnviar.disabled = false;
    botaoCancelar.hidden = true;
  }
});

botaoCancelar.addEventListener('click', () => {
  if (xhrAtual) xhrAtual.abort();
});

Tirar o atributo value de um progress o deixa indeterminado: o navegador mostra uma animação de "trabalhando" em vez de uma barra cheia parada. O role="status" faz o leitor de tela anunciar as mudanças de texto. O idEnvio só é zerado no sucesso; se a pessoa tentar de novo depois de uma falha, o mesmo identificador vai junto.

O botão de cancelar some na segunda fase de propósito. Abortar ali só fecha a conexão do lado do navegador, e o servidor pode terminar de gravar o vídeo mesmo assim. Prometer cancelamento que não cancela é outra forma de a barra mentir.

O servidor reconhece a segunda tentativa

<?php
// upload-video.php (trecho)
$chave = $_SERVER['HTTP_IDEMPOTENCY_KEY'] ?? '';
if (!preg_match('/^[0-9a-f-]{36}$/', $chave)) {
    http_response_code(400);
    exit;
}

$stmt = $pdo->prepare('SELECT video_id FROM envios WHERE chave = ?');
$stmt->execute([$chave]);
$existente = $stmt->fetchColumn();
if ($existente !== false) {
    header('Content-Type: application/json');
    echo json_encode(['id' => (int) $existente, 'repetido' => true]);
    exit;
}

// ... valida $_FILES['video'], move o arquivo, grava o vídeo e obtém $videoId ...

$pdo->prepare('INSERT INTO envios (chave, video_id) VALUES (?, ?)')->execute([$chave, $videoId]);
header('Content-Type: application/json');
echo json_encode(['id' => $videoId]);

Com a coluna chave como UNIQUE, duas requisições simultâneas com o mesmo identificador não criam dois registros. É a mesma ideia de idempotência no pagamento, aplicada a arquivo. E o upload que caiu no meio? O PHP descarta o arquivo temporário de um envio incompleto ao fim da requisição; o risco de lixo aparece quando você implementa envio em partes por conta própria e precisa limpar as partes órfãs.

Se o vídeo passa do limite configurado, a requisição falha antes de chegar ao seu código, e a mensagem precisa refletir isso. Os vários limites envolvidos estão em limite de upload de verdade.

Erros comuns


Checklist


Se o formulário aceita vários arquivos, veja também como deixar a pessoa revisar a fila antes do envio. Agora suba um arquivo grande no seu sistema, corte o Wi-Fi quando a barra passar de 90% e olhe a tela: ela diz que falhou, ou diz "enviado"?

Avalie este artigo

Referência: http://pt.stackoverflow.com/questions/660/como-pegar-o-valor-da-porcentagem-atual-de-um-upload