Przejdź do treści
HexaTransfer
Wróć do bloga
Zagadnienia techniczne

Progressive upload plików: przewodnik UX i techniczny

Buduj progresywne doświadczenia uploadu plików z drag-and-drop, paskami postępu i eleganckim obsługiwaniem błędów dla lepszego UX.

CSIRT NASK odnotował, że użytkownicy porzucający narzędzia do transferu plików w połowie procesu często sięgają po mniej bezpieczne alternatywy — czyli dobry UX to nie tylko wygoda, to też bezpieczeństwo. Progresywny upload plików daje użytkownikom natychmiastową, wiarygodną informację zwrotną na każdym etapie: w chwili upuszczenia pliku — potwierdzenie wyboru; podczas uploadu — płynny pasek postępu z realistycznym ETA; przy błędzie — konkretne opcje ponawiania; po zakończeniu — jasny stan sukcesu z następnymi akcjami. Składniki techniczne to drag-and-drop przez HTML5 DataTransfer API, fragmentowane uploady z możliwością wznawiania, strumieniowanie fetch z ReadableStream do dokładnego śledzenia bajtów i zarządzanie stanem przeżywające przeładowania kart przez IndexedDB.

Co "progresywny" naprawdę tu oznacza

Progresywny ma dwa znaczenia w tym kontekście. Pierwsze: progresywne ulepszanie, żeby upload działał jako zwykłe <input type="file"> POST w przeglądarce z 2012, i zyskiwał drag-and-drop, fragmentowanie i ponawiania gdy JavaScript jest dostępny. Drugie: progresywne odkrywanie, gdzie interfejs ujawnia złożoność tylko gdy potrzeba — pokazuje procent podczas uploadu, ale szczegóły ponawiania tylko przy błędzie. Oba znaczenia wskazują na tę samą zasadę: użytkownik nigdy nie powinien trafić w ślepy zaułek i nigdy nie czekać bez informacji.

Błąd, którego unikać, to "kręcące się koło zagłady" — generyczna animacja ładowania, która nie podaje żadnych informacji o postępie, ETA ani czy coś poszło nie tak. Użytkownicy anulują uploady, którym nie ufają.

Drag-and-drop, który nie walczy z przeglądarką

HTML5 drag-and-drop API jest znane z błędów. Kilka zasad, które to czyni tolerowanym:

const dropzone = document.querySelector('.dropzone');
dropzone.addEventListener('dragover', (e) => {
  e.preventDefault();
  dropzone.classList.add('dragging');
});
dropzone.addEventListener('dragleave', () => {
  dropzone.classList.remove('dragging');
});
dropzone.addEventListener('drop', (e) => {
  e.preventDefault();
  dropzone.classList.remove('dragging');
  handleFiles([...e.dataTransfer.files]);
});

Wywołaj preventDefault na dragover inaczej cel upuszczenia nie przyjmie upuszczenia. Używaj e.dataTransfer.items zamiast files jeśli potrzebujesz akceptować foldery przez webkitGetAsEntry() — to jedyny sposób na rekurencyjne przechwytywanie zawartości katalogów w Chrome i Firefox.

Spraw też by fallback był używalny: widoczny <label> owijający stylizowany <input type="file" multiple> działa dla 100% użytkowników, łącznie z nawigacją klawiaturą i czytnikami ekranu.

Pokazywanie postępu, któremu użytkownicy wierzą

Paski postępu skaczą z trzech powodów: nierówne rozmiary fragmentów, TCP slow-start i buforowanie w stosie sieciowym. Wygładź je 2-sekundową średnią ruchomą:

const samples = [];
function recordSample(bytes) {
  const now = performance.now();
  samples.push({ time: now, bytes });
  while (samples.length > 1 && now - samples[0].time > 2000) samples.shift();
}
function throughput() {
  if (samples.length < 2) return 0;
  const delta = samples[samples.length - 1];
  const base = samples[0];
  return (delta.bytes - base.bytes) / ((delta.time - base.time) / 1000);
}

Obliczaj ETA jako (totalBytes - uploadedBytes) / throughput(), ogranicz wyświetlanie do co najmniej 5 sekund i formatuj po ludzku: "około 2 minuty" nie "124,3 sekundy". Pokazuj zarówno procent, jak i licznik bajtów ("340 MB z 2,1 GB") — użytkownicy krzyżowo sprawdzają te dwie wartości, gdy coś wydaje się nie tak.

Stany błędów z możliwymi do podjęcia działaniami

Generyczne komunikaty "upload nie powiódł się" niszczą zaufanie. Klasyfikuj błędy do pięciu grup i wyświetlaj każdą osobno:

  • Zerwanie sieci (zdarzenie offline, reset TCP): "Łączę ponownie..." z automatycznym ponowieniem
  • Serwer 5xx: "Błąd serwera, ponawianie za 5s" z przyciskiem ręcznego ponowienia
  • Serwer 4xx (413 zbyt duży, 415 zły typ): "Plik odrzucony: zbyt duży" z możliwością zamiany pliku
  • Sesja wygasła (401, 403): "Sesja wygasła, zaloguj się, żeby kontynuować"
  • Crash klienta (błąd JS, przeglądarka zabiła kartę): Odtwarzanie z IndexedDB przy przeładowaniu

Połącz komunikat z jednym działaniem, które go rozwiązuje. Jeśli użytkownik jest offline, pokaż stan online/offline monitorowany przez navigator.onLine i zdarzenie online.

Śledzenie bajtów przez strumienie Fetch

XMLHttpRequest.upload.onprogress był tradycyjnym sposobem śledzenia postępu uploadu, ale jest zawodny na HTTP/3 i traci bajty zbuforowane w buforze nadawania. Nowoczesne podejście używa ReadableStream do liczenia bajtów w miarę ich produkcji:

function trackedStream(blob, onBytes) {
  let sent = 0;
  return new ReadableStream({
    async pull(controller) {
      const reader = blob.stream().getReader();
      while (true) {
        const { done, value } = await reader.read();
        if (done) { controller.close(); return; }
        sent += value.byteLength;
        onBytes(sent);
        controller.enqueue(value);
      }
    }
  });
}

Przekaż strumień jako body do fetch z duplex: 'half'. Obsługa żądania strumieniowego w Safari ląduje w 17.4 — przed tym wróć do XMLHttpRequest.

Pauza, wznawianie i anulowanie

Użytkownicy oczekują przycisku pauzy dla wszystkiego, co trwa dłużej niż minutę. Przy fragmentowanych uploadach pauza to po prostu "przestań wysyłać nowe fragmenty", a wznowienie podejmuje skąd skończyła się kolejka pracy. Anulowanie używa AbortController:

const ctrl = new AbortController();
cancelButton.onclick = () => ctrl.abort();
await fetch(url, { method: 'PUT', body: blob, signal: ctrl.signal });

Po przerwaniu posprzątaj: usuń sesję uploadu na serwerze, żeby storage nie wyciekał, wyczyść wpis IndexedDB, wróć do stanu początkowego. Pauza powinna zachowywać stan; anulowanie powinno go niszczyć. Uwidocznij rozróżnienie w interfejsie.

Przeżywanie przeładowań kart

Utrwalaj stan uploadu po każdym udanym fragmencie:

await idb.put('uploads', {
  sessionId, fileFingerprint, fileName, fileSize,
  completedChunks: [...done], updatedAt: Date.now()
}, sessionId);

Odcisk cyfrowy to SHA-256 pierwszego 1 MB pliku plus rozmiar i lastModified — wystarczy do ponownej identyfikacji pliku gdy użytkownik wybierze go ponownie po przeładowaniu. Przy ładowaniu strony sprawdź IndexedDB dla sesji sprzed mniej niż godziny i zaproponuj wznowienie: "Masz upload w toku sprzed 12 minut. Wznowić?" Nie wznawiaj automatycznie bez zgody — użytkownicy czasem przeładowują właśnie po to, żeby anulować.

Dostępne i przyjazne dla klawiatury interakcje

Strefa upuszczania reagująca tylko na przeciąganie myszą zawodzi użytkowników czytników ekranu i klawiatury. Dodaj:

  • role="button" i tabindex="0" na strefie upuszczania
  • Obsługę klawisza Enter/Spacja klikającą input pliku
  • aria-live="polite" na regionie postępu, żeby czytniki ekranu ogłaszały kamienie milowe
  • Widoczne style fokusa, nie tylko hover
  • Jasne etykiety — "Prześlij plik" bije "Przeglądaj" które bije gołą ikonę

Testowanie klawiaturą jest szybkie: odłącz mysz na 10 minut i spróbuj ukończyć upload. Jeśli nie możesz, nie może też część twoich użytkowników.

HexaTransfer używa dokładnie tego progresywnego wzorca — fallback zwykłego formularza, ulepszenie drag-and-drop, strumieniowanie fetch, wznowienie oparte na IndexedDB i konkretne odtwarzanie błędów. Wypróbuj na https://hexatransfer.com — za darmo, bez konta, maks. 10 GB.

Szczegóły, które użytkownicy faktycznie zauważają

Polska, która odróżnia zapomniane uploaderу od świetnych, żyje w małych momentach: animacja upuszczenia potwierdzająca przechwycenie pliku, pasek postępu wypełniający się płynnie zamiast skakania, ETA stający się dokładniejszym z czasem zamiast chaotycznego skakania, konkretne komunikaty błędów mówiące co zrobić dalej, monit wznowienia po przypadkowym odświeżeniu, stan ukończenia trwający wystarczająco długo by skopiować link udostępniania i zachowanie anulowania, które faktycznie natychmiast zatrzymuje upload. Każdy z nich to kilka linii kodu. Wyślij je wszystkie a twój uploader poczuje się rzędem wielkości lepiej niż domyślne traktowanie <input type="file">.

Wysyłaj duże pliki bezpiecznie z szyfrowaniem end-to-end

Przesyłaj pliki do 10 GB za darmo z szyfrowaniem end-to-end. Bez rejestracji. Twoje pliki są szyfrowane w przeglądarce przed przesłaniem — nikt inny nie może ich odczytać.

Wyślij plik