Представьте: пользователь интернет-магазина пытается загрузить 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 на бэкенде?
- Клиент запрашивает presigned URL, передавая имя файла, MIME-тип и размер.
- Сервер генерирует URL с ограничением по времени (15 минут) и возвращает его.
- Клиент загружает файл напрямую в S3 через PUT-запрос.
- После загрузки можно выполнить постобработку (например, конвертацию в 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 дня. Также вы можете получить консультацию по вашему проекту.







