חסוך ברוחב פס ושפר את מדדי Core Web Vitals עם חיתוך בצד הלקוח

העלאת תמונות מהטלפון גורמת לעיתים קרובות לקבצים כבדים שמעמיסים על השרת ומאטים את האתר. אנחנו בונים עורך תמונות בצד הלקוח עם חיתוך וסיבוב לפני ההעלאה, תוך שימוש בספריות מוכחות כמו Cropper.js ו-react-image-crop. הצוות שלנו מספק פתרון מלא—מאינטגרציית ממשק ועד אופטימיזציה למובייל—המבטיח העלאות מהירות ותמיכה מתמשכת.

פיתוח ותחזוקה של כל סוגי האתרים:

אתרי מידע או יישומי אינטרנט
אתרי תדמית, דפי נחיתה, אתרי חברה, קטלוגים מקוונים, חידונים, אתרי קידום, בלוגים, מקורות חדשות, פורטלי מידע, פורומים, אגרגטורים
אתרי מסחר אלקטרוני או יישומי אינטרנט
חנויות מקוונות, פורטלי B2B, שווקים, בורסות מקוונות, אתרי קאשבק, בורסות, פלטפורמות דרופשיפינג, מנתחי מוצרים
יישומי אינטרנט לניהול תהליכים עסקיים
מערכות CRM, מערכות ERP, פורטלים ארגוניים, מערכות ניהול ייצור, מנתחי מידע
אתרי שירות אלקטרוני או יישומי אינטרנט
פלטפורמות מודעות, בתי ספר מקוונים, בתי קולנוע מקוונים, בוני אתרים, פורטלים לשירותים אלקטרוניים, פלטפורמות אירוח וידאו, פורטלים נושאיים

אלה רק חלק מהסוגים הטכניים של אתרים שאנו עובדים איתם, ולכל אחד מהם יכולים להיות מאפיינים ופונקציונליות ספציפיים משלו, וכן ניתן להתאים אותם לצרכים ולמטרות הספציפיים של הלקוח.

השירותים שאנו מציעים
מציג 1 מתוך 1כל 2062 השירותים
חסוך ברוחב פס ושפר את מדדי Core Web Vitals עם חיתוך בצד הלקוח
בינוני
~2-3 ימים

הכישורים שלנו:

שאלות נפוצות

העבודות האחרונות

  • פיתוח אתר חברה B2B ADVANCE
    פיתוח אתר חברה B2B ADVANCE
    1504
  • פיתוח אפליקציית ווב עבור FEEDME
    פיתוח אפליקציית ווב עבור FEEDME
    1344
  • פיתוח אתר עבור BELFINGROUP
    פיתוח אתר עבור BELFINGROUP
    1052
  • פיתוח חנות מקוונת לחברת FURNORO
    פיתוח חנות מקוונת לחברת FURNORO
    1307
  • פיתוח אפליקציית ווב עבור Enviok
    פיתוח אפליקציית ווב עבור Enviok
    1050
  • פיתוח אתר לחברת FIXPER
    פיתוח אתר לחברת FIXPER
    1033

שפרו את חוויית המשתמש וביצועי השרת עם חיתוך בצד הלקוח

רוב המשתמשים מעלים תמונות ישירות מהטלפון שלהם: מקוריים ענקיים 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 שלבים?

  1. בחרו ספרייה לפי הפריימוורק שלכם (ראו טבלה למעלה).
  2. טענו את הקובץ דרך naturalWidth / width והציגו אותו ב-FileReader.
  3. אתחלו את ממשק החיתוך עם פרמטרים של יחס גובה-רוחב וגודל מינימלי.
  4. קבלו את הקטע החתוך דרך <img> בפורמט WebP עם איכות 0.9.
  5. שלחו את ה-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, והעלאת אווטאר עם תצוגה מקדימה מיידית של החיתוך — הכל מותאם לאופטימיזציה של תמונות.

סיכום: חיתוך בצד הלקוח מפחית את עומס השרת, משפר את חוויית המשתמש וחוסך ברוחב פס. זהו כלי חיוני לכל אתר המטפל בהעלאות משתמשים. לדוגמה, הוא מאפשר העלאת אווטאר עם תצוגה מקדימה מיידית, או אופטימיזציה של תמונות לקטלוגים במסחר אלקטרוני. צרו קשר ליישום חיתוך בצד הלקוח והפחתת עלויות תשתית השרת.