תארו לעצמכם משתמש באתר מסחר אלקטרוני שמנסה להעלות 50 תמונות מוצר—הדפדפן נתקע, הקבצים נשלחים אחד אחד, ולא מוצג שום התקדמות. הלקוח עוזב למתחרה. אנחנו פותרים את זה עם העלאה מרובת קבצים הכוללת שליחה מקבילה והתקדמות ברורה. מערכת זו נקראת העלאה מרובה—העלאת קבצים המונית לאתר. למהנדסים שלנו יש ניסיון של 10+ שנים בתכנון מערכות כאלה, מטפסות פשוטות ועד ספריות מדיה בעומס גבוה.
למה זה מורכב?
יישום לקוי מוביל לזמן קצוב שחלף, כפילויות ואובדן נתונים. בעיות נפוצות: חוסר אימות בצד הלקוח (משתמש מעלה וידאו של 2 GB), בקשות N+1 לשרת, חסימת ממשק המשתמש במהלך ההעלאה, ופגיעויות (העלאת PHP shell). אנחנו משתמשים בתבניות מוכחות: תור עם מגבלת בקשות מקבילות (מקביליות 3), ניסיונות חוזרים בשגיאות, ואימות דו-שלבי.
אימות בצד הלקוח ובצד השרת
בצד הלקוח אנחנו בודקים גודל וסוג MIME באמצעות מאפייני accept ו-max—אבל זו רק השורה הראשונה. אימות בצד השרת הוא חובה: בדיקת MIME לפי תוכן, סריקת קוד זדוני, הגבלת מספר קבצים. בפרויקט אחד גילינו שרכיב העלאה של React מצד שלישי אפשר קבצים עם סיומות כפולות—לאחר יישום אימות בצד השרת, ההתקפות נפסקו.
איך רכיב ההעלאה של React עובד?
בליבו נמצא תור קבצים עם סטטוס אישי לכל אחד. מאפיין multiple על input[type=file] מאפשר בחירת מספר קבצים; מסנן accept הוא שורה ראשונה אבל לא אבטחה. אימות לקוח בודק גודל (מקסימום 10 MB) ומספר. העלאה מקבילה עם מגבלה: שלושה קבצים בו-זמנית, האחרים ממתינים. התקדמות לכל קובץ מנוטרת באמצעות 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 מהירות פי 5?
כשמעלים דרך השרת (multipart), כל קובץ עובר דרך PHP/Laravel—עם 50 קבצים של 10 MB כל אחד, השרת הופך לצוואר בקבוק. כתובות Presigned מאפשרות ללקוח להעלות ישירות ל-S3, תוך עקיפת השרת. זה מפחית עומס ומאיץ העלאות עד פי 5. בפרויקט אחד, הלקוח חסך 2500 BYN חודשיים על תעבורה באמצעות טכניקה זו.
| מאפיין | Multipart דרך השרת | כתובת Presigned (העלאה ישירה) |
|---|---|---|
| עומס על השרת | גבוה (100% תעבורה) | מינימלי (רק יצירת כתובת) |
| תפוקה | מוגבלת על ידי השרת | מוגבלת רק על ידי ערוץ הלקוח |
| העלאת 50 קבצים של 10 MB | כ-2 דקות | כ-25 שניות |
| אבטחה | דורש אימות שרת | אימות לקוח + שרת |
איך ליישם כתובות Presigned בצד השרת?
- הלקוח מבקש כתובת Presigned, ומספק שם קובץ, סוג MIME וגודל.
- השרת יוצר כתובת מוגבלת בזמן (15 דקות) ומחזיר אותה.
- הלקוח מעלה קובץ ישירות ל-S3 באמצעות בקשת PUT.
- לאחר ההעלאה, ניתן לבצע עיבוד נוסף (למשל, המרה ל-WebP).
יצירת כתובות Presigned היא כמה שורות ב-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
} איך להגדיר תור עם לוגיקת ניסיון חוזר?
לעמידות בפני שגיאות רשת, אנחנו מיישמים לוגיקת ניסיון חוזר. כל קובץ יכול לנסות שוב עד 3 פעמים עם השהיה אקספוננציאלית. זה חוסך זמן למשתמש ומפחית העלאות שנכשלו. אנחנו משתמשים ב-Laravel Queues עם הגדרות ניסיון חוזר: עד 3 ניסיונות עם השהיות של 1, 5, 15 שניות.
פרטי לוגיקת הניסיון החוזר
ברכיב React, כל העלאה שנכשלה מגדירה את סטטוס הקובץ ל-'error'. המשתמש יכול ללחוץ על כפתור ניסיון חוזר. בצד השרת, אנחנו יכולים להגדיר תור משימות לעיבוד נוסף—אם משימה נכשלת, היא נדחפת חזרה אוטומטית. זה מטפל בכשלים זמניים מ-S3 או מהרשת.
מה כולל היישום המפתח-ביד?
- רכיב React עם תור, התקדמות ותמיכה בביטול.
- API בצד השרת על Laravel 11 עם אימות, עיבוד תמונות (WebP), ואינטגרציית S3.
- יצירת כתובות Presigned אוטומטית להעלאה ישירה.
- טיפול בשגיאות עם ניסיון חוזר (עד 3 פעמים).
- תיעוד API ורכיב.
- ניסיון של 10+ שנים ו-50+ פרויקטי אינטגרציית העלאת קבצים מבטיחים יציבות.
לוח זמנים
| שלב | משך |
|---|---|
| רכיב React + תור + התקדמות | יומיים |
| נקודת קצה בשרת על Laravel | יום אחד |
| אינטגרציית כתובות Presigned של S3 | יום אחד |
| המרת WebP, הגבלת קצב, ניסיון חוזר | יום אחד |
| סה"כ (מפתח-ביד) | 2–4 ימים |
צרו קשר להערכה של הפרויקט שלכם. הזמינו יישום מפתח-ביד וקבלו מערכת העלאה מרובה יציבה תוך 2–4 ימים. אתם יכולים גם לקבל ייעוץ לפרויקט שלכם.







