Jak zbudować prosty uploader HTML5: drag&drop, podgląd, postęp

Wrzucanie plików powinno być szybkie i czytelne. Coraz więcej osób oczekuje podglądu przed wysłaniem, drag and drop oraz jasnego paska postępu. Dobra wiadomość jest taka, że w HTML5 da się to zrobić prosto i bez ciężkich bibliotek.

W tym artykule zobaczysz, jak zbudować mały uploader krok po kroku. Poznasz strukturę HTML, obsługę drag and drop, podgląd miniatur, walidację, wysyłkę z postępem, a także proste zabezpieczenia po stronie serwera.

1. Co dokładnie zrobimy: prosty uploader z drag&drop i podglądem

Stworzymy strefę przeciągania, podgląd miniatur, walidację oraz wysyłkę z paskiem postępu i anulowaniem.

Uploader przyjmie jeden lub wiele plików. Użytkownik przeciągnie je na strefę lub wybierze z dysku. Zobaczy miniatury obrazów, a podczas wysyłki pasek postępu i komunikaty. Dodamy anulowanie, obsługę błędów i bezpieczny fallback dla starszych przeglądarek.

2. Prosta struktura HTML i miejsca na strefę drag&drop

Potrzebujemy kontenera na strefę zrzutu, ukrytego pola wyboru plików, miejsca na miniatury i paska postępu.

<div id="uploader">
  <div id="dropzone" role="button" tabindex="0">
    Przeciągnij pliki tutaj lub kliknij, aby wybrać
  </div>

  <input id="fileInput" type="file" multiple accept="image/*" style="display:none">

  <div id="preview"></div>

  <div id="progressWrap">
    <div id="progressBar" style="width:0%"></div>
  </div>

  <div id="messages" aria-live="polite"></div>

  <button id="uploadBtn">Wyślij</button>
  <button id="cancelBtn">Anuluj</button>
</div>

Dropzone działa także jako przycisk. Kliknięcie otwiera selektor plików. Atrybut aria-live zapewnia czytelne komunikaty dla czytników ekranu.

3. Obsługa drag&drop: eventy i zapobieganie domyślnym zachowaniom

Należy zablokować domyślne otwieranie plików przez przeglądarkę i reagować na najechanie oraz upuszczenie.

const dropzone = document.getElementById('dropzone')
const input = document.getElementById('fileInput')

dropzone.addEventListener('click', () => input.click())
dropzone.addEventListener('keydown', e => {
  if (e.key === 'Enter' || e.key === ' ') input.click()
})

const prevent = e => {
  e.preventDefault()
  e.stopPropagation()
}

document.addEventListener('dragover', prevent)
document.addEventListener('drop', prevent)

dropzone.addEventListener('dragenter', e => {
  prevent(e)
  dropzone.classList.add('is-hover')
})
dropzone.addEventListener('dragleave', e => {
  prevent(e)
  dropzone.classList.remove('is-hover')
})
dropzone.addEventListener('drop', e => {
  prevent(e)
  dropzone.classList.remove('is-hover')
  handleFiles(e.dataTransfer.files)
})

input.addEventListener('change', e => handleFiles(e.target.files))

Wywołanie preventDefault i stopPropagation zatrzymuje otwieranie plików w oknie. Klasa is-hover pozwala na wizualne podświetlenie strefy.

4. Tworzenie podglądu obrazów i miniatur przed wysłaniem

Do podglądu obrazów użyjemy FileReader, który wczytuje dane jako adres data URL.

const preview = document.getElementById('preview')
let selectedFiles = []

function handleFiles(fileList) {
  selectedFiles = []
  preview.innerHTML = ''
  for (const file of fileList) {
    selectedFiles.push(file)
    if (file.type.startsWith('image/')) {
      const reader = new FileReader()
      reader.onload = e => {
        const img = document.createElement('img')
        img.src = e.target.result
        img.alt = file.name
        img.width = 120
        img.height = 120
        img.loading = 'lazy'
        preview.appendChild(img)
      }
      reader.readAsDataURL(file)
    } else {
      const el = document.createElement('div')
      el.textContent = file.name
      preview.appendChild(el)
    }
  }
}

Miniatury obrazów przyspieszają decyzję o wysyłce. Dla innych typów pokażemy po prostu nazwę pliku.

5. Wysyłanie plików z pokazaniem postępu przez XHR i fetch

Pasek postępu najprościej uzyskać przez XMLHttpRequest. Fetch działa prosto, lecz bez natywnego postępu wysyłki.

const progressBar = document.getElementById('progressBar')
const messages = document.getElementById('messages')
const uploadBtn = document.getElementById('uploadBtn')
const cancelBtn = document.getElementById('cancelBtn')

let xhr

uploadBtn.addEventListener('click', () => {
  if (!selectedFiles.length) {
    showMessage('Nie wybrano plików')
    return
  }
  const form = new FormData()
  for (const file of selectedFiles) form.append('files', file)
  xhr = new XMLHttpRequest()
  xhr.open('POST', '/upload')
  xhr.upload.onprogress = e => {
    if (e.lengthComputable) {
      const percent = Math.round(e.loaded * 100 / e.total)
      progressBar.style.width = percent + '%'
    }
  }
  xhr.onload = () => {
    if (xhr.status === 200) showMessage('Wysłano pliki')
    else showMessage('Błąd wysyłki')
  }
  xhr.onerror = () => showMessage('Błąd sieci')
  xhr.send(form)
})

cancelBtn.addEventListener('click', () => {
  if (xhr) xhr.abort()
  progressBar.style.width = '0%'
  showMessage('Wysłanie anulowane')
})

function showMessage(text) {
  messages.textContent = text
}

Alternatywnie można użyć fetch i AbortController. Postęp wysyłki nie jest powszechnie dostępny, ale anulowanie działa.

const controller = new AbortController()
const form = new FormData()
for (const file of selectedFiles) form.append('files', file)

fetch('/upload', {
  method: 'POST',
  body: form,
  signal: controller.signal
}).then(r => {
  if (r.ok) showMessage('Wysłano pliki')
  else showMessage('Błąd wysyłki')
}).catch(() => showMessage('Błąd sieci'))

// Anulowanie
controller.abort()

Gdy kluczowy jest postęp, lepszy będzie XMLHttpRequest. Gdy potrzebna jest prostota i strumienie, można rozważyć fetch.

6. Walidacja typów i rozmiarów plików po stronie klienta

Wstępna walidacja oszczędza transfer i poprawia doświadczenie użytkownika.

  • Akceptowane typy za pomocą atrybutu accept i sprawdzenia MIME.
  • Limit rozmiaru pojedynczego pliku i łączny limit paczki.
  • Liczba plików w jednym wysłaniu.
const MAX_FILE = 5 * 1024 * 1024
const MAX_TOTAL = 20 * 1024 * 1024

function validate(files) {
  let total = 0
  for (const f of files) {
    if (!f.type || !f.type.startsWith('image/')) {
      showMessage('Dozwolone są tylko obrazy')
      return false
    }
    if (f.size > MAX_FILE) {
      showMessage('Plik jest zbyt duży')
      return false
    }
    total += f.size
  }
  if (total > MAX_TOTAL) {
    showMessage('Łączny rozmiar jest zbyt duży')
    return false
  }
  return true
}

// użycie w handleFiles
function handleFiles(fileList) {
  if (!validate(fileList)) return
  // dalej tworzenie podglądu
}

Walidacja po stronie klienta nie zastępuje weryfikacji po stronie serwera. Traktuj ją jako wygodę i filtr wstępny.

7. Obsługa błędów, anulowanie i responsywne komunikaty dla użytkownika

Jasne komunikaty obniżają frustrację i skracają czas działania.

  • Komunikaty w aria-live informują także użytkowników czytników ekranu.
  • Pokazuj stany: gotowy, w trakcie, anulowany, błąd, sukces.
  • Wykrywaj offline i proponuj ponowienie po powrocie sieci.
  • Po błędzie zostaw podgląd, aby nie tracić wybranych plików.
window.addEventListener('offline', () => showMessage('Brak sieci'))
window.addEventListener('online', () => showMessage('Sieć dostępna'))

Warto dodać automatyczne ponawianie z krótkim opóźnieniem i limit prób. Użytkownik powinien widzieć licznik prób i mieć dostęp do przycisku wyślij ponownie.

8. Proste zabezpieczenia i ograniczenia po stronie serwera

Serwer musi zaufać tylko własnym kontrolom. Walidacja po stronie klienta jest pomocna, lecz niewystarczająca.

  • Wymuś typ i rozmiar na poziomie serwera. Zwracaj spójne kody i wiadomości.
  • Odrzucaj pliki po nagłówkach i po zawartości. Sprawdzaj początkowe bajty pliku.
  • Ustal limit liczby plików i łącznego rozmiaru jednego żądania.
  • Przy przesyłaniu multipart zapisuj pod bezpieczną, generowaną nazwą. Bezpieczny katalog poza katalogiem publicznym.
  • Ustaw maksymalny rozmiar ciała żądania. Zwracaj kod 413 dla zbyt dużych plików.
  • Włącz kontrolę uprawnień, token anty-CSRF i uwierzytelnianie, jeśli to zasób chroniony.
  • Serwuj po HTTPS. Dodaj skanowanie antywirusowe, jeśli przechowujesz pliki użytkowników.
  • Waliduj rozszerzenia dopiero po weryfikacji faktycznego typu pliku.

Odpowiedź JSON ułatwia spójne komunikaty po stronie klienta. Logi powinny zawierać identyfikator żądania, rozmiar i wynik walidacji.

9. Czy można obsłużyć przesyłanie wielu plików jednocześnie?

Tak, można wysyłać wiele plików równolegle lub w kolejce z ograniczoną współbieżnością.

  • Równolegle skróci czas, ale obciąży łącze i serwer.
  • Kolejka jest stabilna. Możesz wysyłać kilka na raz, resztę czeka.
async function uploadOne(file) {
  return new Promise((resolve, reject) => {
    const form = new FormData()
    form.append('files', file)
    const x = new XMLHttpRequest()
    x.open('POST', '/upload')
    x.onload = () => x.status === 200 ? resolve() : reject()
    x.onerror = () => reject()
    x.send(form)
  })
}

async function uploadQueue(files) {
  for (const file of files) {
    try {
      await uploadOne(file)
      showMessage('Wysłano ' + file.name)
    } catch (e) {
      showMessage('Błąd pliku ' + file.name)
    }
  }
}

Wersja równoległa to pojedyncze wywołania w pętli i zbieranie wyników. Współbieżność można ograniczyć prostą pulą pracowników.

10. Jak zapewnić zgodność z przeglądarkami mobilnymi i starszymi?

Wdrożymy progresywne ulepszanie. Drag and drop to dodatek. Podstawą jest przycisk wyboru plików.

  • Na urządzeniach mobilnych drag and drop bywa ograniczony. Klikalna strefa i input to bezpieczny fundament.
  • Duże przyciski i kontrast. Dobre etykiety i focus klawiatury.
  • Oszczędzaj dane. Kompresuj obrazy po stronie klienta tylko za zgodą użytkownika.
  • Utrzymuj czytelny pasek postępu i prosty fallback. Gdy brak wsparcia XHR, użyj zwykłego formularza.
  • Testuj w trybie oszczędzania danych i przy słabym zasięgu. Zadbaj o działanie offline po wznowieniu sieci.

Dzięki takiemu podejściu każdy użytkownik może wysłać plik. Ci ze wsparciem HTML5 mają wygodne dodatki, reszta korzysta z solidnego podstawowego przepływu.

Prosty uploader z podglądem i postępem podnosi komfort i zmniejsza liczbę błędów, a dzięki walidacji i zabezpieczeniom działa stabilnie w różnych warunkach i na różnych urządzeniach.

Chcesz wdrożyć taki uploader w swoim serwisie i dostosować go do potrzeb projektu? Napisz i umów krótką konsultację.