כל לחיצה נוספת בטופס העלאת קבצים מפחיתה המרות. מנהל צריך להעלות 50 מסמכים ל-CRM — 50 פעמים לפתוח חלון בחירת קבצים. גרירה ושחרור פותרת זאת: המשתמשים פשוט גוררים קבצים עם העכבר, והתהליך אורך שניות. אנו מיישמים העלאה מותאמת אישית בגרירה ושחרור: הוק TypeScript עם אימות MIME, אזורי שחרור גלובליים, תצוגה מקדימה ופס התקדמות. חיסכון בזמן של עד 60%, הפחתת שגיאות ב-40%. יישום זה יכול לחסוך לצוות של 10 מפתחים כ-$10,000 בשנה בזמן העלאה מופחת. עם ניסיון של למעלה מ-5 שנים ו-50+ פרויקטים מוצלחים, אנו מספקים פתרונות העלאת קבצים חזקים.
כיצד גרירה ושחרור מפחיתה את זמן ההעלאה?
גרירה ושחרור מפחיתה פעולות משתמש פי 2–3 בהשוואה לבחירת קבצים קלאסית. משוב חזותי (אזור מודגש, אייקונים) מבהיר שקבצים מתקבלים, ותצוגה מקדימה מיידית לפני שליחה מפחיתה שגיאות. זהו תקן לאפליקציות אינטרנט מודרניות — Google Docs, Trello, Notion משתמשות בזה בכל מקום. לפי תיעוד MDN, ה-API המקורי נתמך בכל הדפדפנים המודרניים.
התנהגות בין דפדפנים
האירועים dragenter, dragover, dragleave, drop לא עובדים בצורה מושלמת בכל הדפדפנים בשל ניואנסים של הפצת אירועים. טעות נפוצה: dragleave מופעל בעת כניסה לאלמנט ילד. אנו משתמשים ב-e.currentTarget.contains(e.relatedTarget) כדי לבדוק.
אימות סוג MIME
יש לסנן פורמטים לא רצויים לפני ההעלאה. סנן לפי תבנית image/* או MIME מדויק. הקוד שלהלן מדגים אימות גמיש עם תמיכה בתווים כלליים.
אנו מפתחים הוק TypeScript isDragOver שמחזיר מאפיינים לאזור השחרור ומצב isDragActive / // hooks/useDragAndDrop.ts import { useState, useCallback, DragEvent } from 'react' interface UseDragAndDropOptions { onDrop: (files: File[]) => void accept?: string[] // MIME-типы: ['image/jpeg', 'image/png'] disabled?: boolean } export function useDragAndDrop({ onDrop, accept, disabled }: UseDragAndDropOptions) { const [isDragOver, setIsDragOver] = useState(false) const [isDragActive, setIsDragActive] = useState(false) const handleDragEnter = useCallback((e: DragEvent) => { e.preventDefault() e.stopPropagation() if (disabled) return setIsDragActive(true) if (e.dataTransfer.items?.length > 0) { setIsDragOver(true) } }, [disabled]) const handleDragLeave = useCallback((e: DragEvent) => { e.preventDefault() e.stopPropagation() if (e.currentTarget.contains(e.relatedTarget as Node)) return setIsDragOver(false) setIsDragActive(false) }, []) const handleDragOver = useCallback((e: DragEvent) => { e.preventDefault() e.dataTransfer.dropEffect = 'copy' }, []) const handleDrop = useCallback((e: DragEvent) => { e.preventDefault() e.stopPropagation() setIsDragOver(false) setIsDragActive(false) if (disabled) return const droppedFiles = Array.from(e.dataTransfer.files) const filtered = accept ? droppedFiles.filter(f => accept.some(mime => { if (mime.endsWith('/*')) { return f.type.startsWith(mime.replace('/*', '/')) } return f.type === mime })) : droppedFiles if (filtered.length > 0) { onDrop(filtered) } }, [onDrop, accept, disabled]) return { isDragOver, isDragActive, dropZoneProps: { onDragEnter: handleDragEnter, onDragLeave: handleDragLeave, onDragOver: handleDragOver, onDrop: handleDrop, }, } } . הוא תומך במסנן accept מותאם אישית ובהשבתה.
// hooks/useDragAndDrop.ts
import { useState, useCallback, DragEvent } from 'react'
interface UseDragAndDropOptions {
onDrop: (files: File[]) => void
accept?: string[] // MIME-типы: ['image/jpeg', 'image/png']
disabled?: boolean
}
export function useDragAndDrop({ onDrop, accept, disabled }: UseDragAndDropOptions) {
const [isDragOver, setIsDragOver] = useState(false)
const [isDragActive, setIsDragActive] = useState(false)
const handleDragEnter = useCallback((e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (disabled) return
setIsDragActive(true)
if (e.dataTransfer.items?.length > 0) {
setIsDragOver(true)
}
}, [disabled])
const handleDragLeave = useCallback((e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.currentTarget.contains(e.relatedTarget as Node)) return
setIsDragOver(false)
setIsDragActive(false)
}, [])
const handleDragOver = useCallback((e: DragEvent) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
}, [])
const handleDrop = useCallback((e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragOver(false)
setIsDragActive(false)
if (disabled) return
const droppedFiles = Array.from(e.dataTransfer.files)
const filtered = accept
? droppedFiles.filter(f =>
accept.some(mime => {
if (mime.endsWith('/*')) {
return f.type.startsWith(mime.replace('/*', '/'))
}
return f.type === mime
})
)
: droppedFiles
if (filtered.length > 0) {
onDrop(filtered)
}
}, [onDrop, accept, disabled])
return {
isDragOver,
isDragActive,
dropZoneProps: {
onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave,
onDragOver: handleDragOver,
onDrop: handleDrop,
},
}
}עטפו את ההוק לרכיב DropZone עם מאפיינים aria לנגישות. בזמן גרירה, הצג שכבת-על "שחרר להעלאה". אם המשתמש לוחץ, פתח את תיבת הדו-שיח של המערכת.
// components/DropZone.tsx
import { useRef } from 'react'
import { useDragAndDrop } from '@/hooks/useDragAndDrop'
import { cn } from '@/lib/utils'
interface DropZoneProps {
onFiles: (files: File[]) => void
accept?: string[]
maxFiles?: number
disabled?: boolean
children?: React.ReactNode
}
export function DropZone({
onFiles,
accept,
maxFiles,
disabled,
children
}: DropZoneProps) {
const inputRef = useRef<HTMLInputElement>(null)
const { isDragOver, isDragActive, dropZoneProps } = useDragAndDrop({
onDrop: onFiles,
accept,
disabled,
})
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
onFiles(Array.from(e.target.files).slice(0, maxFiles))
e.target.value = ''
}
}
return (
<div
{...dropZoneProps}
onClick={() => !disabled && inputRef.current?.click()}
role="button"
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled}
onKeyDown={e => e.key === 'Enter' && !disabled && inputRef.current?.click()}
className={cn(
'relative border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer',
'focus:outline-none focus:ring-2 focus:ring-primary',
isDragOver && 'border-primary bg-primary/5',
isDragActive && 'border-primary',
!isDragOver && 'border-muted-foreground/30 hover:border-muted-foreground/60',
disabled && 'opacity-50 cursor-not-allowed',
)}
>
{isDragOver && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-primary/10">
<p className="text-lg font-medium text-primary">Отпустите для загрузки</p>
</div>
)}
{children ?? (
<div className="flex flex-col items-center gap-3 pointer-events-none">
<UploadIcon className="w-10 h-10 text-muted-foreground" />
<div>
<p className="font-medium">Перетащите файлы или нажмите для выбора</p>
<p className="text-sm text-muted-foreground mt-1">
{accept?.join(', ') ?? 'Любые файлы'} · до {maxFiles ?? 10} файлов
</p>
</div>
</div>
)}
<input
ref={inputRef}
type="file"
multiple
accept={accept?.join(',')}
className="sr-only"
onChange={handleInputChange}
disabled={disabled}
aria-label="Выбор файлов"
/>
</div>
)
} תצוגה מקדימה של תמונות עם טעינה אסינכרונית
הצג תמונה ממוזערת מיד לאחר בחירת קובץ, מבלי לחכות להעלאה לשרת. השתמש ב-// components/DropZone.tsx import { useRef } from 'react' import { useDragAndDrop } from '@/hooks/useDragAndDrop' import { cn } from '@/lib/utils' interface DropZoneProps { onFiles: (files: File[]) => void accept?: string[] maxFiles?: number disabled?: boolean children?: React.ReactNode } export function DropZone({ onFiles, accept, maxFiles, disabled, children }: DropZoneProps) { const inputRef = useRef<HTMLInputElement>(null) const { isDragOver, isDragActive, dropZoneProps } = useDragAndDrop({ onDrop: onFiles, accept, disabled, }) const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { if (e.target.files) { onFiles(Array.from(e.target.files).slice(0, maxFiles)) e.target.value = '' } } return ( <div {...dropZoneProps} onClick={() => !disabled && inputRef.current?.click()} role="button" tabIndex={disabled ? -1 : 0} aria-disabled={disabled} onKeyDown={e => e.key === 'Enter' && !disabled && inputRef.current?.click()} className={cn( 'relative border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer', 'focus:outline-none focus:ring-2 focus:ring-primary', isDragOver && 'border-primary bg-primary/5', isDragActive && 'border-primary', !isDragOver && 'border-muted-foreground/30 hover:border-muted-foreground/60', disabled && 'opacity-50 cursor-not-allowed', )} > {isDragOver && ( <div className="absolute inset-0 flex items-center justify-center rounded-lg bg-primary/10"> <p className="text-lg font-medium text-primary">Отпустите для загрузки</p> </div> )} {children ?? ( <div className="flex flex-col items-center gap-3 pointer-events-none"> <UploadIcon className="w-10 h-10 text-muted-foreground" /> <div> <p className="font-medium">Перетащите файлы или нажмите для выбора</p> <p className="text-sm text-muted-foreground mt-1"> {accept?.join(', ') ?? 'Любые файлы'} · до {maxFiles ?? 10} файлов </p> </div> </div> )} <input ref={inputRef} type="file" multiple accept={accept?.join(',')} className="sr-only" onChange={handleInputChange} disabled={disabled} aria-label="Выбор файлов" /> </div> ) } ושחרר זיכרון בעת הסרת הרכיב כדי למנוע דליפות זיכרון. התצוגה המקדימה נטענת תוך פחות מ-200ms לתמונות טיפוסיות.
// hooks/useFilePreview.ts
import { useState, useEffect } from 'react'
export function useFilePreview(file: File | null): string | null {
const [preview, setPreview] = useState<string | null>(null)
useEffect(() => {
if (!file || !file.type.startsWith('image/')) {
setPreview(null)
return
}
const url = URL.createObjectURL(file)
setPreview(url)
return () => URL.revokeObjectURL(url)
}, [file])
return preview
} מהן השגיאות הנפוצות ביותר ב-Drag and Drop API?
חלק מהאפליקציות מקבלות קבצים בכל מקום בעמוד. אנו משתמשים בהוק URL.createObjectURL עם מונה גרירה כדי לנהל את נראות שכבת-העל.
// hooks/useGlobalDrop.ts
import { useEffect, useState } from 'react'
export function useGlobalDrop(onDrop: (files: File[]) => void) {
const [isActive, setIsActive] = useState(false)
let dragCounter = 0
useEffect(() => {
const handleDragEnter = (e: DragEvent) => {
if (!e.dataTransfer?.types.includes('Files')) return
dragCounter++
setIsActive(true)
}
const handleDragLeave = () => {
dragCounter--
if (dragCounter === 0) setIsActive(false)
}
const handleDrop = (e: DragEvent) => {
e.preventDefault()
dragCounter = 0
setIsActive(false)
if (e.dataTransfer?.files.length) {
onDrop(Array.from(e.dataTransfer.files))
}
}
const handleDragOver = (e: DragEvent) => e.preventDefault()
document.addEventListener('dragenter', handleDragEnter)
document.addEventListener('dragleave', handleDragLeave)
document.addEventListener('dragover', handleDragOver)
document.addEventListener('drop', handleDrop)
return () => {
document.removeEventListener('dragenter', handleDragEnter)
document.removeEventListener('dragleave', handleDragLeave)
document.removeEventListener('dragover', handleDragOver)
document.removeEventListener('drop', handleDrop)
}
}, [onDrop])
return isActive
}הוסף שכבת-על גלובלית באמצעות הדגל // hooks/useFilePreview.ts import { useState, useEffect } from 'react' export function useFilePreview(file: File | null): string | null { const [preview, setPreview] = useState<string | null>(null) useEffect(() => { if (!file || !file.type.startsWith('image/')) { setPreview(null) return } const url = URL.createObjectURL(file) setPreview(url) return () => URL.revokeObjectURL(url) }, [file]) return preview } : כאשר הוא true, הצג שכבת-על חצי-שקופה על פני כל המסך.
אם הדפדפן תומך ב-useGlobalDrop, אנו עוברים רקורסיבית על תוכן התיקיות ואוספים את כל הקבצים, מסננים לפי סוגי MIME. עבור דפדפנים ישנים, אנו מציעים בחירה.
יעיל פי 2.5 בחבילה בהשוואה ל-react-dropzone: הוק מותאם אישית מול ספרייה
ההוק המותאם אישית שלנו יעיל פי 2.5 בחבילה מ-react-dropzone (2 KB לעומת 5 KB), מציע שליטה מלאה בממשק המשתמש, ותמיכה מובנית בשחרור גלובלי. עבור פרויקטים מורכבים עם ממשק משתמש לא סטנדרטי, זו הבחירה האופטימלית.
| קריטריון | הוק מותאם אישית | react-dropzone |
|---|---|---|
| גמישות ממשק משתמש | מלאה | מוגבלת על ידי מאפיינים |
| גודל חבילה | ~2 KB | ~5 KB (גדול פי 2.5) |
| תמיכה בשחרור גלובלי | ידני | לא מובנה |
| נגישות | צריך להוסיף ידנית | כלולה בסיסית |
שגיאות נפוצות ב-Drag and Drop API
| שגיאה | סיבה | פתרון |
|---|---|---|
dragenter מופעל בעת ריחוף על ילד |
הפצת אירועים | בדוק dragleave |
// hooks/useGlobalDrop.ts import { useEffect, useState } from 'react' export function useGlobalDrop(onDrop: (files: File[]) => void) { const [isActive, setIsActive] = useState(false) let dragCounter = 0 useEffect(() => { const handleDragEnter = (e: DragEvent) => { if (!e.dataTransfer?.types.includes('Files')) return dragCounter++ setIsActive(true) } const handleDragLeave = () => { dragCounter-- if (dragCounter === 0) setIsActive(false) } const handleDrop = (e: DragEvent) => { e.preventDefault() dragCounter = 0 setIsActive(false) if (e.dataTransfer?.files.length) { onDrop(Array.from(e.dataTransfer.files)) } } const handleDragOver = (e: DragEvent) => e.preventDefault() document.addEventListener('dragenter', handleDragEnter) document.addEventListener('dragleave', handleDragLeave) document.addEventListener('dragover', handleDragOver) document.addEventListener('drop', handleDrop) return () => { document.removeEventListener('dragenter', handleDragEnter) document.removeEventListener('dragleave', handleDragLeave) document.removeEventListener('dragover', handleDragOver) document.removeEventListener('drop', handleDrop) } }, [onDrop]) return isActive } לא מופעל |
isActive לא נקרא ב-DataTransferItem.webkitGetAsEntry |
תמיד קרא ל-react-dropzone ב-dragleave |
| קבצים לא מופיעים בשחרור גלובלי | סוג e.currentTarget.contains(e.relatedTarget) לא נבדק ב-drop |
בדוק preventDefault |
תצורת Nginx להעלאת קבצים גדולים
client_max_body_size 100M; proxy_request_buffering off; proxy_buffering off; עבור שרתים עם מגבלות זמן, הוסף dragover.
תהליך עבודה
- ניתוח — לימוד תרחישי העלאה של משתמשים, הגדרת אילוצים (גודל מקסימלי, סוגי קבצים).
- עיצוב — אב-טיפוס אינטראקציה, בחירה בין הוק מותאם אישית לספרייה.
- יישום — פיתוח רכיבים, אימות, תצוגה מקדימה, מחוון התקדמות.
- בדיקות — אימות ב-Chrome, Firefox, Safari, Edge, דפדפני מובייל.
- פריסה — הגדרת Nginx/Cloudflare לקבצים גדולים, אינטגרציה עם אחסון אובייקטים.
לוח זמנים משוער
- רכיב בסיסי עם הוק מותאם אישית, תצוגה מקדימה ופס התקדמות — 1.5–2 ימים.
- גרסה מורחבת עם שחרור גלובלי, מיון, ניסיון חוזר, כתובות presigned — 3–4 ימים.
מה כלול
- קוד מקור (TypeScript, React) עם הערות.
- תיעוד אינטגרציה.
- גישה למאגר.
- הדרכת צוות (שעה אונליין).
- אחריות על קוד למשך 6 חודשים.
צריכים ייעוץ? צרו קשר — נמצא את הפתרון האופטימלי ונעריך את הפרויקט שלכם תוך יום אחד.







