Реализация мультизагрузки файлов: 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 раз. В одном из проектов клиент экономил 2500 BYN ежемесячно на трафике за счёт этой техники.

Характеристика 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
    1393
  • Разработка веб-сайта для компании БЕЛФИНГРУПП
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    1081
  • Разработка интернет магазина для компании FURNORO
    Разработка интернет магазина для компании FURNORO
    1368
  • Разработка веб-приложения для компании Enviok
    Разработка веб-приложения для компании Enviok
    1139
  • Разработка веб-сайта для компании ФИКСПЕР
    Разработка веб-сайта для компании ФИКСПЕР
    1060