שפרו את חוויית המשתמש וביצועי השרת עם חיתוך בצד הלקוח
רוב המשתמשים מעלים תמונות ישירות מהטלפון שלהם: מקוריים ענקיים 4000×3000 ו-8 מגה-בייט. השרת חייב לקבל, לעבד ולשמור אותם — עומסי I/O ו-CPU מזנקים. חיתוך בצד הלקוח פותר זאת לפני השליחה: אנו חותכים את הקטע הדרוש, מסובבים, מתקנים בהירות — השרת מקבל קובץ שכבר מותאם. אנו משתמשים בספריות מוכחות Cropper.js ו-react-image-crop לכל ממשק. למה לדחוף מגה-בייט כשאפשר לשלוח רק מה שצריך? הניסיון שלנו מאשר: גישה זו מפחיתה את עומס השרת פי 3-5 ומאיצה את טעינת הדף. בנוסף, עיבוד בצד הלקוח יכול לחסוך עד 70% מעלויות האחסון ולהפחית את הוצאות רוחב הפס פי 5. עבור אתר עם 10,000 העלאות בחודש, זה מתורגם לחיסכון שנתי של מעל 10,000 דולר על CDN ואחסון. זה גם משפיע לטובה על Core Web Vitals — במיוחד LCP, מכיוון שהדפדפן לא צריך לחכות לעיבוד בצד השרת.
אילו בעיות פותר חיתוך תמונות בצד הלקוח?
- פרופורציות שגויות: משתמשים מעלים תמונות אופקיות עבור אווטארים אנכיים — נדרש חיתוך ידני.
- גודל קובץ עצום: מקורי של 10 מגה-בייט הופך ל-200 קילו-בייט לאחר חיתוך ל-WebP 0.9.
- סיבוב EXIF: תמונות ניידות מופיעות הפוכות עד לתיקון המטא-דאטה.
- אלמנטים לא רצויים: באנרים פרסומיים, סימני מים — הכל מוסר לפני ההעלאה.
- העלאות בחיבור איטי: משתמשים ב-3G עלולים לחכות דקות, אך לאחר חיתוך נשלחים רק 200 קילו-בייט.
איך לבחור ספריית חיתוך?
| ספרייה | גודל (gzip) | תכונות | מקרה שימוש |
|---|---|---|---|
| Cropper.js | ~34 קילו-בייט | סיבוב, זום, חיתוך, יחס גובה-רוחב | Vanilla JS, jQuery, כל ערימה |
| react-image-crop | ~6 קילו-בייט | חיתוך עם יחס, ממשק מותאם אישית | React 18+, Next.js |
| vue-cropper | ~15 קילו-בייט | חיתוך, סיבוב, זום | Vue 2/3, Nuxt |
| Fabric.js | ~200 קילו-בייט | עורך מלא, שכבות, פילטרים, טקסט | כאשר נדרש עורך מלא עם שכבת טקסט/פילטר |
Cropper.js נטען פי 2 מהר יותר מ-Fabric.js עבור חיתוך פשוט. אם המשימה היא רק חיתוך ביחס קבוע, react-image-crop קל פי 5.
יישום בסיסי ב-React
import { useState, useRef, useCallback } from 'react'
import ReactCrop, { Crop, PixelCrop, centerCrop, makeAspectCrop } from 'react-image-crop'
import 'react-image-crop/dist/ReactCrop.css'
function centerAspectCrop(width: number, height: number, aspect: number): Crop {
return centerCrop(
makeAspectCrop(
{ unit: '%', width: 90 },
aspect,
width,
height
),
width,
height
)
}
export function ImageCropUploader({ aspect = 1, onUpload }: { aspect?: number; onUpload: (blob: Blob) => Promise<void> }) {
const [imgSrc, setImgSrc] = useState('')
const [crop, setCrop] = useState<Crop>()
const [completedCrop, setCompletedCrop] = useState<PixelCrop>()
const imgRef = useRef<HTMLImageElement>(null)
function onSelectFile(e: React.ChangeEvent<HTMLInputElement>) {
if (e.target.files?.[0]) {
const reader = new FileReader()
reader.addEventListener('load', () => setImgSrc(reader.result?.toString() ?? ''))
reader.readAsDataURL(e.target.files[0])
}
}
function onImageLoad(e: React.SyntheticEvent<HTMLImageElement>) {
const { width, height } = e.currentTarget
setCrop(centerAspectCrop(width, height, aspect))
}
const getCroppedBlob = useCallback((): Promise<Blob> => {
const image = imgRef.current
if (!image || !completedCrop) throw new Error('No crop data')
const canvas = document.createElement('canvas')
const scaleX = image.naturalWidth / image.width
const scaleY = image.naturalHeight / image.height
canvas.width = completedCrop.width * scaleX
canvas.height = completedCrop.height * scaleY
const ctx = canvas.getContext('2d')!
ctx.drawImage(
image,
completedCrop.x * scaleX,
completedCrop.y * scaleY,
completedCrop.width * scaleX,
completedCrop.height * scaleY,
0,
0,
canvas.width,
canvas.height
)
return new Promise((resolve, reject) => {
canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('Canvas empty')), 'image/webp', 0.9)
})
}, [completedCrop])
async function handleSubmit() {
const blob = await getCroppedBlob()
await onUpload(blob)
}
return (
<div>
<input type="file" accept="image/*" onChange={onSelectFile} />
{imgSrc && (
<ReactCrop
crop={crop}
onChange={setCrop}
onComplete={setCompletedCrop}
aspect={aspect}
minWidth={100}
>
<img ref={imgRef} src={imgSrc} onLoad={onImageLoad} />
</ReactCrop>
)}
{completedCrop && (
<button onClick={handleSubmit}>Загрузить</button>
)}
</div>
)
}נקודה מרכזית — קנה מידה דרך import { useState, useRef, useCallback } from 'react' import ReactCrop, { Crop, PixelCrop, centerCrop, makeAspectCrop } from 'react-image-crop' import 'react-image-crop/dist/ReactCrop.css' function centerAspectCrop(width: number, height: number, aspect: number): Crop { return centerCrop( makeAspectCrop({ unit: '%', width: 90 }, aspect, width, height), width, height ) } export function ImageCropUploader({ aspect = 1, onUpload }: { aspect?: number onUpload: (blob: Blob) => Promise<void> }) { const [imgSrc, setImgSrc] = useState('') const [crop, setCrop] = useState<Crop>() const [completedCrop, setCompletedCrop] = useState<PixelCrop>() const imgRef = useRef<HTMLImageElement>(null) function onSelectFile(e: React.ChangeEvent<HTMLInputElement>) { if (e.target.files?.[0]) { const reader = new FileReader() reader.addEventListener('load', () => setImgSrc(reader.result?.toString() ?? '')) reader.readAsDataURL(e.target.files[0]) } } function onImageLoad(e: React.SyntheticEvent<HTMLImageElement>) { const { width, height } = e.currentTarget setCrop(centerAspectCrop(width, height, aspect)) } const getCroppedBlob = useCallback((): Promise<Blob> => { const image = imgRef.current if (!image || !completedCrop) throw new Error('No crop data') const canvas = document.createElement('canvas') const scaleX = image.naturalWidth / image.width const scaleY = image.naturalHeight / image.height canvas.width = completedCrop.width * scaleX canvas.height = completedCrop.height * scaleY const ctx = canvas.getContext('2d')! ctx.drawImage( image, completedCrop.x * scaleX, completedCrop.y * scaleY, completedCrop.width * scaleX, completedCrop.height * scaleY, 0, 0, canvas.width, canvas.height ) return new Promise((resolve, reject) => { canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('Canvas empty')), 'image/webp', 0.9) }) }, [completedCrop]) async function handleSubmit() { const blob = await getCroppedBlob() await onUpload(blob) } return ( <div> <input type="file" accept="image/*" onChange={onSelectFile} /> {imgSrc && ( <ReactCrop crop={crop} onChange={setCrop} onComplete={setCompletedCrop} aspect={aspect} minWidth={100} > <img ref={imgRef} src={imgSrc} onLoad={onImageLoad} /> </ReactCrop> )} {completedCrop && ( <button onClick={handleSubmit}>Загрузить</button> )} </div> ) } . הדפדפן מציג את התמונה בגודל קטן יותר, אך החיתוך חייב להתבצע מהמקור.
איך ליישם חיתוך ב-5 שלבים?
- בחרו ספרייה לפי הפריימוורק שלכם (ראו טבלה למעלה).
- טענו את הקובץ דרך
naturalWidth / widthוהציגו אותו ב-FileReader. - אתחלו את ממשק החיתוך עם פרמטרים של יחס גובה-רוחב וגודל מינימלי.
- קבלו את הקטע החתוך דרך
<img>בפורמט WebP עם איכות 0.9. - שלחו את ה-Blob דרך
canvas.toBlob()לשרת.
בנוסף: בדיקת גודל קובץ לפני חיתוך (מגבלה של 10 מגה-בייט), בדיקת כיוון EXIF, ותצוגה מקדימה חיה של תוצאת החיתוך.
Cropper.js: סיבוב, זום והעלאה
<div>
<img id="image" src="/original.jpg">
<button onclick="cropper.rotate(90)">Повернуть</button>
<button onclick="cropper.zoom(0.1)">+</button>
<button onclick="cropper.zoom(-0.1)">-</button>
<button onclick="uploadCropped()">Загрузить</button>
</div>
<script>
const cropper = new Cropper(document.getElementById('image'), {
aspectRatio: 16 / 9,
viewMode: 2, // не выходить за границы изображения
dragMode: 'move',
autoCropArea: 0.8,
restore: false,
guides: true,
center: true,
highlight: false,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: false,
})
async function uploadCropped() {
cropper.getCroppedCanvas({
maxWidth: 1920,
maxHeight: 1080,
imageSmoothingEnabled: true,
imageSmoothingQuality: 'high',
}).toBlob(async (blob) => {
const fd = new FormData()
fd.append('image', blob, 'cover.webp')
await fetch('/api/upload', {
method: 'POST',
body: fd
})
}, 'image/webp', 0.85)
}
</script> מה לגבי כיוון EXIF?
תמונות ניידות מסובבות לעיתים קרובות עקב מטא-דאטה של EXIF. עד לאחרונה, דפדפנים לא התחשבו בכך אוטומטית. הנה איך לתקן זאת:
img { image-orientation: from-image; /* поддерживается в современных браузерах */ } לאמינות, אנו משתמשים בספריית FormData:
import { parse } from 'exifr'
async function fixOrientation(file: File): Promise<string> {
const exif = await parse(file, { orientation: true })
return new Promise(resolve => {
const reader = new FileReader()
reader.onload = (e) => {
const img = new Image()
img.onload = () => {
const canvas = document.createElement('canvas')
const rotation = exif?.Orientation ?? 1
// применяем трансформацию по таблице EXIF
applyExifRotation(canvas, img, rotation)
resolve(canvas.toDataURL())
}
img.src = e.target!.result as string
}
reader.readAsDataURL(file)
})
}ראו תיעוד MDN על <div> <img id="image" src="/original.jpg"> <button onclick="cropper.rotate(90)">Повернуть</button> <button onclick="cropper.zoom(0.1)">+</button> <button onclick="cropper.zoom(-0.1)">-</button> <button onclick="uploadCropped()">Загрузить</button> </div> <script> const cropper = new Cropper(document.getElementById('image'), { aspectRatio: 16 / 9, viewMode: 2, // не выходить за границы изображения dragMode: 'move', autoCropArea: 0.8, restore: false, guides: true, center: true, highlight: false, cropBoxMovable: true, cropBoxResizable: true, toggleDragModeOnDblclick: false, }) async function uploadCropped() { cropper.getCroppedCanvas({ maxWidth: 1920, maxHeight: 1080, imageSmoothingEnabled: true, imageSmoothingQuality: 'high', }).toBlob(async (blob) => { const fd = new FormData() fd.append('image', blob, 'cover.webp') await fetch('/api/upload', { method: 'POST', body: fd }) }, 'image/webp', 0.85) } </script> לפרטים נוספים.
ולידציה ומגבלות
העלאת מקורי של 20 מגה-בייט רק כדי לחתוך אזור 200×200 היא בזבוז. אנו מבצעים ולידציה לפני הצגת החיתוך:
const MAX_SIZE_MB = 10
const MAX_DIMENSION = 4096
function validateImage(file: File): string | null {
if (file.size > MAX_SIZE_MB * 1024 * 1024) {
return `Файл слишком большой. Максимум ${MAX_SIZE_MB} МБ`
}
return null
}
// Для проверки размеров — только после загрузки в img
function checkDimensions(img: HTMLImageElement): string | null {
if (img.naturalWidth > MAX_DIMENSION || img.naturalHeight > MAX_DIMENSION) {
return `Изображение слишком большое. Максимум ${MAX_DIMENSION}px`
}
return null
}אם יש צורך לקבל קבצים גדולים — בצעו שינוי גודל למידות סבירות לפני החיתוך דרך img { image-orientation: from-image; /* поддерживается в современных браузерах */ } עם exifr מופחת.
מה כלול בעבודה
| רכיב | תיאור |
|---|---|
| בחירת ספרייה | בחירת הפתרון האופטימלי לערימה שלכם (React, Vue, vanilla) |
| שילוב חיתוך | הגדרת רכיב עם תמיכה ביחס גובה-רוחב, סיבוב, זום |
| תיקון EXIF | תיקון כיוון תמונות ניידות |
| ולידציה | בדיקת גודל קובץ, רזולוציה וסוג לפני עיבוד |
| אופטימיזציה | דחיסה ל-WebP עם איכות ניתנת לשליטה |
| תצוגה מקדימה | תצוגה מקדימה חיה של החיתוך לפני ההעלאה |
| תיעוד | הוראות שילוב ותחזוקה |
הפקידו משימה זו בידי אנשי מקצוע
הניסיון שלנו: 7+ שנים בפיתוח web, 50+ פרויקטים עם עיבוד תמונות בצד הלקוח. אנו מבטיחים שילוב פונקציונלי מלא עם תיעוד מקיף ותמיכה מקצועית. המפתחים המוסמכים שלנו מספקים שילוב חיתוך תמונות מוכן לשימוש תוך 2-5 ימים בלבד. כל הקוד מכוסה בבדיקות ומגיע עם תיעוד מלא. עלויות היישום נעות בדרך כלל בין 500 ל-1500 דולר, ומציעות החזר מהיר. צרו קשר להערכת פרויקט — זה לוקח 30 דקות לדון בצרכים שלכם. תיהנו מעריכת תמונות לפני העלאה, עורך בצד הלקוח שמטפל בחיתוך תמונות, סיבוב תמונות, כיוון EXIF, דחיסת WebP, והעלאת אווטאר עם תצוגה מקדימה מיידית של החיתוך — הכל מותאם לאופטימיזציה של תמונות.
סיכום: חיתוך בצד הלקוח מפחית את עומס השרת, משפר את חוויית המשתמש וחוסך ברוחב פס. זהו כלי חיוני לכל אתר המטפל בהעלאות משתמשים. לדוגמה, הוא מאפשר העלאת אווטאר עם תצוגה מקדימה מיידית, או אופטימיזציה של תמונות לקטלוגים במסחר אלקטרוני. צרו קשר ליישום חיתוך בצד הלקוח והפחתת עלויות תשתית השרת.







