Реалізація мультизавантаження файлів: React, Laravel, S3 під ключ

Користувачі кидають завантаження, коли браузер зависає на десятках файлів, а прогресу немає. Ми розробляємо мультизавантаження файлів на React та Laravel з паралельним відправленням і зрозумілим інтерфейсом. Наша команда реалізує проєкт під ключ — від аудиту до підтримки, забезпечуючи надійну та масштабовану систему.

Розробка та обслуговування будь-яких видів сайтів:

Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Послуги, які ми пропонуємо
Показано 1 з 1Усі 2062 послуг
Реалізація мультизавантаження файлів: React, Laravel, S3 під ключ
Середній
~2-3 дні
Часті запитання

Уявіть: користувач інтернет-магазину намагається завантажити 50 фотографій товару, браузер зависає, файли відправляються по одному, а прогресу немає. Клієнт іде до конкурентів. Ми вирішуємо цю проблему мультизавантаженням з паралельною відправкою та наочним прогресом. Така система називається bulk upload — масове завантаження файлів на сайт. Наші інженери 10+ років проектують такі системи — від простих форм до високонавантажених медіатек.

Чому це складно?

Неправильна реалізація призводить до тайм-аутів, дублікатів та втрати даних. Основні проблеми: відсутність валідації на клієнті (користувач завантажує 2 ГБ відео), N+1 запитів до сервера, блокування UI при завантаженні, вразливості (завантаження PHP-шеллів). Ми використовуємо перевірені патерни: чергу з обмеженням паралельних запитів (concurrency 3), повторні спроби при помилках та валідацію на двох рівнях.

Клієнтська та серверна валідація

На клієнті перевіряємо розмір і MIME-тип через атрибути accept і max, але це лише перша лінія. Серверна валідація обов'язкова: перевірка MIME за вмістом, сканування на шкідливий код, ліміт кількості файлів. В одному проекті ми виявили, що сторонній компонент завантаження React пропускав файли з подвійним розширенням — після впровадження серверної валідації атаки припинилися.

Як влаштований компонент завантаження на React?

В основі — черга файлів з власним статусом кожного. Атрибут multiple в input[type=file] дозволяє вибрати кілька файлів; фільтр по accept — перша лінія, але не захист. Клієнтська валідація перевіряє розмір (макс. 10 МБ) та кількість. Паралельне завантаження з обмеженням: три файли одночасно, інші чекають. Прогрес кожного файлу відображається через XMLHttpRequest. Ось приклад компонента:

// components/BulkUpload.tsx
import { useRef, useState, useCallback } from 'react'

interface UploadFile {
  id: string
  file: File
  status: 'pending' | 'uploading' | 'done' | 'error'
  progress: number
  error?: string
  url?: string // URL after upload
}

interface BulkUploadProps {
  accept?: string
  maxFiles?: number
  maxSizeBytes?: number
  onUploadComplete?: (urls: string[]) => void
}

export function BulkUpload({
  accept = 'image/*',
  maxFiles = 10,
  maxSizeBytes = 10 * 1024 * 1024, // 10 MB
  onUploadComplete,
}: BulkUploadProps) {
  const inputRef = useRef<HTMLInputElement>(null)
  const [files, setFiles] = useState<UploadFile[]>([])

  const addFiles = useCallback((incoming: FileList | File[]) => {
    const arr = Array.from(incoming)
    const validated = arr
      .filter(f => {
        if (f.size > maxSizeBytes) {
          alert(`${f.name}: file too large (max ${maxSizeBytes / 1024 / 1024} MB)`)
          return false
        }
        return true
      })
      .slice(0, maxFiles - files.length)

    setFiles(prev => [
      ...prev,
      ...validated.map(file => ({
        id: crypto.randomUUID(),
        file,
        status: 'pending' as const,
        progress: 0,
      })),
    ])
  }, [files.length, maxFiles, maxSizeBytes])

  const uploadFile = useCallback(async (uploadFile: UploadFile) => {
    const formData = new FormData()
    formData.append('file', uploadFile.file)

    setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, status: 'uploading' } : f ))

    try {
      await new Promise<void>((resolve, reject) => {
        const xhr = new XMLHttpRequest()
        xhr.open('POST', '/api/upload')
        xhr.setRequestHeader('X-CSRF-TOKEN', document.querySelector<HTMLMetaElement>('meta[name=csrf-token]')?.content ?? '')

        xhr.upload.onprogress = (e) => {
          if (e.lengthComputable) {
            const progress = Math.round((e.loaded / e.total) * 100)
            setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, progress } : f ))
          }
        }

        xhr.onload = () => {
          if (xhr.status >= 200 && xhr.status < 300) {
            const { url } = JSON.parse(xhr.responseText)
            setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, status: 'done', progress: 100, url } : f ))
            resolve()
          } else {
            reject(new Error(`HTTP ${xhr.status}`))
          }
        }

        xhr.onerror = () => reject(new Error('Network error'))
        xhr.send(formData)
      })
    } catch (e) {
      setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, status: 'error', error: (e as Error).message } : f ))
    }
  }, [])

  const uploadAll = useCallback(async () => {
    const pending = files.filter(f => f.status === 'pending')
    // Parallel, but max 3 at a time
    const CONCURRENCY = 3
    for (let i = 0; i < pending.length; i += CONCURRENCY) {
      await Promise.all(pending.slice(i, i + CONCURRENCY).map(uploadFile))
    }
    const urls = files.filter(f => f.status === 'done' && f.url).map(f => f.url!)
    onUploadComplete?.(urls)
  }, [files, uploadFile, onUploadComplete])

  const removeFile = (id: string) => {
    setFiles(prev => prev.filter(f => f.id !== id))
  }

  return (
    <div>
      <button onClick={() => inputRef.current?.click()}>
        Choose files
      </button>
      <input
        ref={inputRef}
        type="file"
        multiple
        accept={accept}
        className="hidden"
        onChange={e => e.target.files && addFiles(e.target.files)}
      />
      {files.length > 0 && (
        <ul className="mt-4 space-y-2">
          {files.map(f => (
            <li key={f.id} className="flex items-center gap-3">
              <span className="truncate flex-1">{f.file.name}</span>
              <span className="text-sm text-muted">
                {(f.file.size / 1024).toFixed(1)} KB
              </span>
              {f.status === 'uploading' && (
                <div className="w-24 bg-gray-200 rounded-full h-2">
                  <div
                    className="bg-blue-500 h-2 rounded-full transition-all"
                    style={{ width: `${f.progress}%` }}
                  />
                </div>
              )}
              {f.status === 'done' && <span className="text-green-600">✓</span>}
              {f.status === 'error' && (
                <span className="text-red-600 text-sm">{f.error}</span>
              )}
              <button
                onClick={() => removeFile(f.id)}
                disabled={f.status === 'uploading'}
                aria-label={`Remove ${f.file.name}`}
              >
                ✕
              </button>
            </li>
          ))}
        </ul>
      )}
      {files.some(f => f.status === 'pending') && (
        <button onClick={uploadAll} className="mt-4 btn-primary">
          Upload {files.filter(f => f.status === 'pending').length} files
        </button>
      )}
    </div>
  )
}

Чому presigned URL в 5 разів швидше?

При завантаженні через сервер (multipart) кожен файл проходить через PHP/Laravel — при 50 файлах по 10 МБ сервер перетворюється на вузьке місце. Presigned URL дозволяє клієнту напряму завантажувати файли в S3, минаючи сервер. Це знижує навантаження і прискорює завантаження до 5 разів.

Характеристика Multipart через сервер Presigned URL (пряме завантаження)
Навантаження на сервер Високе (100% трафіку) Мінімальне (тільки генерація URL)
Пропускна здатність Обмежена сервером Обмежена лише каналом клієнта
Завантаження 50 файлів по 10 МБ ~2 хвилини ~25 секунд
Безпека Потрібна валідація на сервері Валідація на клієнті + сервер

Як реалізувати presigned URL на бекенді?

  1. Клієнт запитує presigned URL, передаючи ім'я файлу, MIME-тип і розмір.
  2. Сервер генерує URL з обмеженням за часом (15 хвилин) і повертає його.
  3. Клієнт завантажує файл напряму в S3 через PUT-запит.
  4. Після завантаження можна виконати постобробку (наприклад, конвертацію в WebP).

Генерація presigned URL — пара рядків на Laravel:

// Генерація presigned URL
public function presign(Request $request): JsonResponse
{
    $request->validate([
        'filename' => 'required|string|max:255',
        'mime_type' => 'required|string|in:image/jpeg,image/png,image/webp,application/pdf',
        'size' => 'required|integer|max:' . 10 * 1024 * 1024,
    ]);

    $ext = pathinfo($request->filename, PATHINFO_EXTENSION);
    $key = 'uploads/' . date('Y/m') . '/' . Str::uuid() . '.' . $ext;

    $client = Storage::disk('s3')->getClient();
    $cmd = $client->getCommand('PutObject', [
        'Bucket' => config('filesystems.disks.s3.bucket'),
        'Key' => $key,
        'ContentType' => $request->mime_type,
        'ACL' => 'private',
    ]);

    $presignedUrl = (string) $client->createPresignedRequest($cmd, '+15 minutes')->getUri();

    return response()->json([
        'upload_url' => $presignedUrl,
        'public_url' => Storage::url($key),
        'key' => $key,
    ]);
}

На фронтенді завантаження в S3 виглядає так:

// Фронтенд: завантаження напряму в S3
async function uploadToS3(file: File): Promise<string> {
  // 1. Отримати presigned URL від нашого сервера
  const { upload_url, public_url } = await fetch('/api/upload/presign', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      filename: file.name,
      mime_type: file.type,
      size: file.size,
    }),
  }).then(r => r.json())

  // 2. Завантажити файл напряму в S3
  await fetch(upload_url, {
    method: 'PUT',
    body: file,
    headers: {
      'Content-Type': file.type,
    },
  })

  return public_url
}

Як налаштувати чергу з повторними спробами?

Для стійкості до мережевих помилок реалізуємо retry-логіку. Кожен файл може бути перезавантажений до 3 разів з експоненційною затримкою. Це економить час користувача і знижує кількість невдалих завантажень. Використовуємо Laravel Queues з налаштуваннями retry: до 3 спроб із затримкою 1, 5, 15 секунд.

Деталі реалізації retry-логіки

У компоненті React кожне невдале завантаження переводить файл у статус 'error'. Користувач може натиснути кнопку повторного завантаження. На серверній стороні можна налаштувати чергу завдань для постобробки – при збої завдання автоматично поміщається назад у чергу. Це дозволяє пережити тимчасові збої S3 або мережі.

Що включає реалізація під ключ?

  • React-компонент з чергою, прогресом і підтримкою скасування.
  • Серверний API на Laravel 11 з валідацією, обробкою зображень (WebP) та інтеграцією з S3.
  • Автоматичне створення presigned URL для прямого завантаження.
  • Обробку помилок з повторними спробами (retry до 3 разів).
  • Документацію по API та компоненту.
  • 10+ років досвіду та понад 50 проектів з інтеграції завантаження файлів гарантують стабільність.

Терміни реалізації

Етап Термін
React-компонент + черга + прогрес 2 дні
Серверний endpoint на Laravel 1 день
Інтеграція S3 presigned URL 1 день
WebP-конвертація, throttle, retry 1 день
Разом (під ключ) 2–4 дні

Зв'яжіться з нами для оцінки вашого проекту. Замовте реалізацію під ключ — отримайте стабільне мультизавантаження за 2–4 дні. Також ви можете отримати консультацію по вашому проекту.

Наші компетенції:

Останні роботи

  • Розробка сайту компанії B2B ADVANCE
    Розробка сайту компанії B2B ADVANCE
    1542
  • Розробка веб-додатків для компанії FEEDME
    Розробка веб-додатків для компанії FEEDME
    1394
  • Розробка веб-сайту для компанії БЕЛФІНГРУП
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    1081
  • Розробка інтернет магазину для компанії FURNORO
    Розробка інтернет магазину для компанії FURNORO
    1368
  • Розробка веб-додатків для компанії Enviok
    Розробка веб-додатків для компанії Enviok
    1140
  • Розробка веб-сайту для компанії ФІКСПЕР
    Розробка веб-сайту для компанії ФІКСПЕР
    1060