הקדמה
יוצא לא פעם שאנחנו נתקלים במקומות שונים ברשת באתרים עם אפשרות של העלאת תמונה. בין אם זה בהעלאת פוסט, מאמר, לפרסם באינסטגרם את האוכל שאכלנו במסעדה בפעם הראשונה או אפילו לשלוח לחבר הודעה עם התמונה של הסרט החדש שראינו בקולנוע. אז בואו נכנס לעובי הקורה ונראה איך מיישמים העלאת תמונות בריאקט. במאמר זה עוסק בצד לקוח בלבד מהסיבה שלמרות שזה נשמע טריוויאלי יש הרבה נקודות שחשוב לשים לב אליהם, נתחיל מהבסיס ונצלול לנושאים יותר מתקדמים.
הערה: לצורך הנוחות נדבר על פיתוח צד לקוח בעזרת ריאקט כמובן שייתכן שניתן להעלות תמונות גם בפריימוורקים אחרים.
שלב ראשון – מגדירים את ה-element:
לאחר מחשבה ארוכה החלטנו ליצור אתר המתמקד בנושא שקרוב לליבנו, והוא אמור לכלול אפשרות של העלאת תמונות. אז מאיפה מתחילים?
שימו לב: את input element ו label שאנחנו עומדים לדבר עליהם אפשר להוסיף בתוך קומפוננטה שנקרא לה לדוגמא ImageInput.
קודם נתחיל מלהגדיר את השדה ה-input שיתאים לקלוט את התמונות שהמשתמש יעלה:
<input
accept="image/*"
hidden
id={inputId}
multiple={multiple}
name={inputName}
type="file"
onChange={onChange}
/>
בואו נפרט:
type="file"
מגדירים שהאלמנט input יקבל ערך שהוא קובץ.
accept="image/*"
עושים סינון שרק קבצים שהם מסוג תמונה יוכלו להיקלט.
multiple={multiple}
מגדירים שהשדה יהיה מסוגל לקלוט מספר תמונות, לפי הערך הבוליאני שמעדכנים נוכל לבחור אם נרצה אופציה שכזאת או שלא.
hidden
ייתכן ונרצה לשנות את ה-UI למשהו יותר נחמד עם אייקון מתאים. לכן נסתיר את האלמנט הדיפולטיבי שנוצר מהשדה של ה-input שהגדרנו ונוכל לעשות זאת בעזרת הוספת הערך הזה.
עכשיו נגדיר את ה-label המתאים ל-input:
import { FcAddImage } from "react-icons/fc";
...
<label htmlFor={inputId}>
<FcAddImage/>
<p>{!fileName ? "Upload Image" : fileName.slice(0,20)}</p>
</label>
...
ברגע שלוחצים על ה-label גם אם מסתירים את ה-input עדין יפתח החלון לבחור תמונה, זה קורה בעיקר בגלל שמקשרים את ה-label ל-input לפי ה-id של אותו ה-input שאותו מגדירים ב- htmlFor. ה-property הזה הוא המקביל של for' content property' ב-JSX. בעזרת התכונה הזאת נוכל לשנות את הנראות של ה-input מהמצב הדילופטיבי:
לנראות הזאת (עם התאמת הסטיילים):
שלב שני – מקבלים את התמונות לאחר ביצוע ה-events:
שימו לב: ניצור useUploadImages hook שבו נוסיף את ה-events, ונייצא אותם כדי להשתמש במקומות הדרושים בקומפוננטות אחרות.
לאחר שהגדרנו את האלמנט של ה-input עם הלייבל המתאים נקבל את התמונות לאחר שהמשתמש: בחר בתמונה אחרת, שחרר את התמונה לאחר שגרר אותה מהמחשב, הדביק את התמונה לאחר שהעתיק אותה.
יש מספר אירועים להאזין להם:
onImageChange
const handleChange = (e) =>{
const imageFiles = e.target.files;
...
};
זה מה שמתרחש כאשר משנים את התמונה בצורה של לחיצה על האייקון ובחירת תמונה במסך שקפץ ושמירת הבחירה.
נקבל את רשימת הקבצים במקרה הזה מדובר בתמונות, מ-property של event.target (האלמנט של ה-input שהגדרנו מקודם):
e.target.files
לאחר שיצרנו את הפונקציה המופעלת כאשר משנים את התמונה – בוחרים תמונה אחרת, מייצאים אותה בשם onImageChange ומצמידים אותה לאלמנט. האלמנט הוא ה- input (שהוזכר מקודם) ב- onChange event (המעבר יהיה דרך קומפוננטת ביניים).
onChange={ onImageChange }
onImageDrop
const handleDrop = (e) =>{
e.preventDefault();
const imageFiles = e.dataTransfer.files || [];
...
};
זה מתרחש כאשר משחררים את הלחיצה לאחר גרירת תמונה מכל מקום במחשב לאתר ולדייק לקומפוננטה שמקשיבה לאירוע הזה.
נקבל את התמונות מאובייקט שמחזיק את כל הקבצים ששוחררו באתר לאחר הגרירה שנוצר על ידי HTML Drag and Drop API שהוא Web API מובנה:
e.dataTransfer.files
את הפונקציה של handleDrop מייצאים בשם onImageDrop. ניתן להצמיד אותה ל-input או אפילו ל-container שמכיל את ה-input ככה יהיה יותר מרחב למשתמש להניח את התמונה. מצמידים ב- onDrop event של האלמנט.
onDrop={ onImageDrop }
onImagePaste
const handlePaste = (e) =>{
const pasteItems = e.clipboardData.files;
...
};
מהשם ניתן להבין שמדובר בפעולה של הדבקת תמונות. לא ניתן להעתיק ולהדביק מספר תמונות באותו זמן בכל הדפדפנים. יש דפדפנים שניתן כל פעם להעתיק ולהדביק תמונה אחת בלבד, לדוגמה ב-Firefox.
מקבלים את הקבצים מהאובייקט של clipboardData שנוצר על ידי Clipboard API שגם הוא Web API מובנה:
e.clipboardData.files
מייצאים את הפונקציה בשם onImagePaste אך חשוב לשים לב – כדי ש-onPaste event יקרה צריך focus על האלמנט ואז ביצוע פעולת הדבקה (אפשרי כמובן גם עם המקלדת). לכן לא מצמידים את הפונקציה לאלמנט ה-input האחראי על העלאת תמונות או ה-label שלו כי זה לא יעבוד, מהסיבה שברגע שלוחצים עליו נפתח המסך של לבחור תמונה חדשה. כתוצאה מכך עדיף להצמיד על אחד ה-containers או בשדות input / textarea אחרים שנמצאים באותה הקומפוננטה שבא משתמשים בפונקציונליות של העלאת תמונות. נשמע יותר הגיוני לעשות זאת דווקא על שדה input / textarea כי ככה המשתמשים יוכלו להדביק דברים כשהם רואים קורסור של text מאשר במקום כללי. בכל מקרה מצמידים ב- onPaste event של האלמנט.
onPaste={ onImagePaste }
שלב שלישי – ולידציה לקבצים שהתקבלו:
שימו לב: אנחנו יכולים להוסיף את פונקציית ולידציה גם ב-useUploadImages hook.
הפעולות שדוברו לא בהכרח מבטיחות שהקבצים שמקבלים מתאימים – הן מבחינת סוג הקובץ והן מבחינת הגודל שלו. לכן צריך להוסיף פונקציה שתבדוק את זה.
const units = ["bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"];
const niceBytes = (size) => {
let unitNameIndex = 0;
let number = parseInt(size, 10) || 0;
while (number >= 1024 && ++unitNameIndex) {
number = number / 1024;
}
return number.toFixed(number < 10 && unitNameIndex > 0 ? 1 : 0) + " " + units[unitNameIndex];
};
const handleValidate = (image, maxSizeByte) => {
if (!image) return;
const isImageValid =
!image.type.startsWith("image/") && !["image/png", "image/jpeg", "image/jpg"].includes(image.type);
if (isImageValid) {
toast.error("Invalid image type. Only png, jpeg and jpg are allowed.");
return;
}
if (image.size > maxSizeByte) {
const maxSizeLog = niceBytes(maxSizeByte);
toast.error(`Image size should be less than ${maxSizeLog}`);
return;
}
return image;
};
בודקים את סוג הקובץ בעזרת המטוד startsWith(). לוודא שסוג הקובץ הוא תמונה ואז בודקים האם הפורמט מתאים לאחד הסוגים הבאים:
לאחר מכן בודקים את גודל הקובץ על ידי property שיש לקובץ שנקרא size. במידה והגודל לא מתאים נראה למשתמש הודעה מתאימה עם גודל תמונה מירבי שניתן להעלות, עדיף גם להשתמש בפונקציית עזר כדי לכתוב את הגודל בפורמט יותר נוח לעין.
שלב רביעי אפשרות א – הקטנת תמונה שתוכל להתאים לגודל הנדרש:
עשינו ולידציה והתברר לנו שהתמונה גדולה יותר ממה שנדרש, אז מה עכשיו? האם אנחנו נזרוק את המשתמש שלנו לחפש ברשת איך הוא יכול להקטין את גודל התמונה, בהנחה שהוא רוצה להשתמש במיוחד בתמונה הזאת? או שנוכל לסייע לו בכך שנעשה את הפעולה במקומו כי מה לא נעשה בשביל המשתמשים שלנו?
יש מספר דרכים לגשת לזה אפשר להוריד חבילה שעושה את הפעולה של דחיסת תמונה בשבילנו, מספר דוגמאות מחיפוש בגוגל: compressorjs, browser-image-compression.
לפני שרצים להשתמש בחבילה החדשה כדי להקל על העבודה בואו ננסה למצוא פתרון שהוא כבר חלק מ-Browser APIs. הפתרון הוא כמובן כולל שימוש ב-canvas.
חכו עם יישום הפתרון! לפני שממשיכים, אנחנו מודעים להגבלות של JS שהיא single threaded כלומר במידה ויקח זמן לדחיסת התמונה להתבצע – במיוחד תמונות בגודל גדול מהרגיל, יבצר מצב בו כל הדף יתקע עד שזה קורה, מה שיכול לגרום למשתמש לתחושה מוזרה. לכן נרצה להשתמש פה ב-Web Worker שזה JS process שרץ מאחורי הקלעים והוא לא שייך ל-thread המרכזי.
יצירת הקובץ שישמש כ- Web Worker:
ניצור קובץ חדש בשם imageUploadWorker.js (השם כמובן אינדיבידואלי) ששם תתבצע הפעולה ב-thread נפרד.
הגדרת ה-Web Worker בקומפוננטה שבה אנחנו רוצים להשתמש בו:
שימו לב: נשתמש ב- Web Worker גם כחלק מ- useUploadImages hook.
const useUploadImages = ({...}) => {
...
const [workerAction, setWorkerAction] = useState(null);
useEffect(() => {
const imageUploadWorker = new Worker("/src/utils/workers/imageUploadWorker.js");
...
setWorkerAction(imageUploadWorker);
return () => {
imageUploadWorker.terminate();
};
}, []);
...
}
ברינדור הראשון של הקומפוננטה אנחנו יוצרים instance חדש של Worker ונותנים את ה-path לקובץ שיצרנו בשבילו. לאחר מכן שומרים אותו ב-state כדי להשתמש אחרי זה. לא שוכחים להוסיף התנתקות מ-imageUploadWorker בעזרת המתודה terminate(), במקום הדרוש.
שליחת הודעת ביצוע פעולה לWeb Worker:
const handleValidate = (image, maxSizeByte, multiple) => {
...
if (image.size > maxSizeByte) {
const maxSizeLog = niceBytes(maxSizeByte);
toast.warning(`Image size should be less than ${maxSizeLog}. We will compress this image size.`);
workerAction.postMessage({
image,
requiredImageWidth,
requiredImageHeight,
multiple,
});
return;
}
return image;
};
כמו שצויין מקודם, מטרתו לבצע את פעולת הדחיסה לכן נוסיף בתוך פונקציית הולידציה, שדיברנו עליה בשלב 3, את שליחת הבקשה ל-Web Worker ברגע שגודל התמונה גדול מהרצוי. עדיין נרצה להראות למשתמש הודעה מתאימה לכך שהתמונה היא גדולה מדי כדי שיבין למה הפעולה מתעכבת אבל נשנה את זה מסוג של error ל-warning מהסיבה שזה כבר לא בגדר של שגיאה.
שולחים את הבקשה ל-Web Worker בעזרת שימוש במתודה של postMessage של workerAction, ה- worker ששמרנו ב-state מקודם. יחד עם הבקשה נעביר אובייקט עם נתונים רצויים.
קבלת הודעת ביצוע פעולה והביצוע שלה בפועל בעזרת ה- Web Worker:
onmessage = async (event) => {
const { image, requiredImageWidth, requiredImageHeight, multiple } = event.data;
const bitmap = await createImageBitmap(image);
const canvas = new OffscreenCanvas(256, 256);
const ctx = canvas.getContext("2d");
const aspectRatio = requiredImageWidth / bitmap.width;
canvas.width = requiredImageWidth;
canvas.height = requiredImageHeight > 0 ? requiredImageHeight : bitmap.height * aspectRatio;
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
const blobImage = await canvas.convertToBlob();
postMessage({ blobImage, multiple });
};
הקוד הזה נמצא בקובץ imageUploadWorker.js –
קודם מאזינים לכל ההודעות מ-thread אחר בעזרת ה-onmessage. ברגע שהתקבלה הודעה שכזאת לביצוע הפעולה אנחנו נעשה פה את דחיסת התמונה.
נסביר על חלק מהשלבים העיקריים המתרחשים ב Web Worker:
const { image, requiredImageWidth, requiredImageHeight, multiple } = event.data;
מקבלים את הנתונים ששלחנו מ-event.data.
const bitmap = await createImageBitmap(image);
יוצרים bitmap – מבנה נתונים ששומר את רשת הפיקסלים, כל פיקסל מוגדר על ידי סדרת bits ומה שנשמר שם זה הצבע של כל פיקסל. יוצרים את המבנה נתונים bitmap מהקובץ תמונה שקיבלנו. בשונה אם היינו משתמשים ב -instance של Image obj ששם היינו יכולים לערוך את הקובץ ולא היה צריך להשתמש במבנה הנתונים הנוכחי. אך לא ניתן לעשות זאת כי אין אפשרות ליצור אלמנטים ב-Web Worker.
const canvas = new OffscreenCanvas(256, 256);
משתמשים ב- OffscreenCanvas, שמטרתו להשתמש ב-canvas ללא צורך ליצור אלמנט canvas ב-DOM מה שלא ניתן לעשות גם ככה כי ל-Web Worker אין גישה לזיכרון של ה-main thread ובכך הוא לא יכול לגשת ל-DOM או אובייקטים גלובליים אחרים כמו document, window וכן הלאה. מגדירים לאובייקט רוחב ואורך של ה-canvas לא כל כך חשוב כרגע כי אנחנו נשנה את הנתונים לאחר מכן.
const ctx = canvas.getContext("2d");
לאחר מכן אנחנו יוצרים את הסוג האפשרות ציור שלנו ומגדירים שזה יהיה שתי ממדים.
const aspectRatio = requiredImageWidth / bitmap.width;
canvas.width = requiredImageWidth;
canvas.height = requiredImageHeight > 0 ? requiredImageHeight : bitmap.height * aspectRatio;
את רוחב התמונה אנחנו מקבלים כפרמטר (באופן דיפולטיבי שמתי 400px אפשר כמובן לשנות בהתאם לצורך).
לגבי האורך ניתן להשתמש ב-aspect ratio כדי לשמור על ממדי התמונה או לשנות לפי הצורך.
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
פה אנחנו מבצעים את פעולת הציור בפועל, הערכים שאנחנו נותנים הם בהתאמה: ה-bitmap של התמונה, מיקום מהנקודה השמאלית העליונה בציר X, מיקום מהנקודה השמאלית העליונה בציר Y, רוחב, אורך.
const blobImage = await canvas.convertToBlob();
כדי להשתמש בנתונים מהתמונה יש צורך לשנות אותה מסוג Bitmap – רשת הפיקסלים לסוג Blob – אוסף של נתונים בינאריים המייצגים את התמונה.
postMessage({ blobImage, multiple });
בסיום שולחים הודעה ל-main thread עם הנתונים המעודכנים.
קבלת נתונים מ-Web Worker לאחר ביצוע הפעולה של הדחיסה:
const useUploadImages = ({...}) => {
...
useEffect(() => {
const imageUploadWorker = new Worker("/src/utils/workers/imageUploadWorker.js");
imageUploadWorker.onmessage = (event) => {
const newImageBlob = event.data.blobImage;
const fileName = `${pageName}-${Date.now()}.${newImageBlob.type.split("/")[1]}`;
const imageFileWithUpdatedName =
new File([newImageBlob], fileName, { type: newImageBlob.type });
handleAddImagesToState([imageFileWithUpdatedName], [fileName], event.data.multiple);
};
setWorkerAction(imageUploadWorker);
return () => {
imageUploadWorker.terminate();
};
}, []);
...
};
באותו ()useEffect שאנחנו יוצרים את ה-instance של ה-Worker אנחנו גם צריכים להאזין להודעות שאנחנו יכולים לקבל ממנו על ידי שימוש במתודה onmessage. ברגע שנקבל את הנתונים אנחנו יוצרים שם לתמונה, כפי שבוצע בקטע הקוד. לאחר מכן אנחנו יוצרים מזה קובץ File ובשלב לאחר מכן שולחים את הנתונים הלאה, במקרה שלנו זו פונקציה שמעדכנת את ה-state של הקומפוננטה.
שלב רביעי אפשרות ב – קריאת הנתונים של תמונות שלא צריך לשנות את הגודל שלהם:
שימו לב: הפונקציה הזאת גם קשורה ל- useUploadImages hook שדיברנו עליו מקודם.
במידה והקובץ הוא תמונה וגם גודל התמונה מתאים למה שהגדרנו, לא נבזבז משאבים (זמן ריצה) כדי לשנות את הגודל ונסתפק בקבלת הנתונים הדרושים מהתמונה. כשהמשתמש ישלח את הטופס גם התמונה אמורה להישלח, לכן צריכים לדעת מה השם, וגם לקבל את התמונה כאובייקט עם הנתונים שלה כדי שאחרי זה נוכל להשתמש בו להצגת התמונה מחדש, נרצה שזה יהיה אובייקט של File.
const handleUploadImage = (image, multiple = false) => {
... // validate the image type and size with util function
const imageName = `${pageName}-${Date.now()}.${image?.type.split("/")[1]}`;
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
resolve({
image: new File([reader.result], imageName, { type: image?.type }),
imageName,
});
};
reader.onerror = (error) => reject(error);
reader.readAsArrayBuffer(image);
});
};
קודם יוצרים את השם של התמונה, לאחר מכן יוצרים אינסטנס של FileReader ומגדירים שיתחיל לקרוא את הנתונים של קובץ התמונה שלנו. ברגע שהוא סיים אנחנו יכולים ליצור קובץ File חדש עם השם וסוג הקובץ שאנחנו מעוניינים בו. את הפעולה הזאת עוטפים ב-Promise כדי להשתמש בתוצר במקום אחר באפליקציה ולא בתוך הפונקציה הזאת (ניתן להשתמש ב-Callback Function במקום המעטפת של Promise). בנוסף מוסיפים error handling במקרה וטעינת התמונה לא תתבצע ויפעל ה-event של error.
בסיום שלב רביעי לא חשוב איזה מהאפשרויות תתבצע (האפשרות שכוללת דחיסה או שלא), הסטייט של ההוק מתעדכן עם קבצי התמונות וגם השמות שלהם.
שלב חמישי – בואו נראה תצוגה מקדימה של התמונה:
לאחר העלאת התמונה, אנחנו רוצים גם לתת למשתמש אפשרות לראות אותה בתצוגה מקדימה, וגם להסיר את התמונה אם המשתמש רוצה בכך.
בשביל זאת ניצור קומפוננטה ייחודית שנקרא לה ImagePreview, הדוגמה לא כוללת סטיילים שאפשר להוסיף כרצוננו.
... // imports
const ImagePreview = ({ files, onRemove }) => {
const closeIcon = <>✕</>;
return (
<div>
{files?.map((file, index) => (
<div key={file.name}>
<img src={URL.createObjectURL(file)} alt={`Uploaded ${index + 1}`} />
<button onClick={() => onRemove(index)}>{closeIcon}</button>
</div>
))}
</div>
);
};
export default ImagePreview;
מספר דגשים:
URL.createObjectURL(file)
יצירת כתובת url לקובץ תמונה שלנו וככה אפשר להראות את התמונה.
onRemove(index)
אחת מהפונקציות שאנחנו מגדירים בהוק של useUploadImages כדי להסיר את התמונה מהסטייט המקומי.
שלב שישי – שליחת קובץ התמונה לשרת או לאחסון אחר:
שימו לב: הפונקציות שנדבר עליהם גם קשורות ל- useUploadImages hook.
הגענו לרגע המיוחל שבו המשתמש העלה את התמונה וגם ראה את התצוגה המקדימה שלה, בנוסף מילא נתונים נוספים בטופס ואז לוחץ על שלח טופס.
עכשיו אנחנו נראה איך לשלוח את התמונה, או מספר תמונות במידה ואפשרנו את זה.
const handleSubmit = async (file) => {
const formData = new FormData();
formData.append("image", file);
await axios.post(API_URL, formData);
};
const handleSubmitManyImages = async (files) => {
const promises = files.map((file) => handleSubmit(file));
await Promise.all(promises);
};
כאשר מדובר בתמונה אחת אנחנו פשוט יוצרים אינסטנס של FormData, לו אנחנו מוסיפים ערך שזה הקובץ של התמונה עם key מתאים ופשוט שולחים ל-endpoint המתאים (ניתן להשתמש ב-fetch כמובן).
כאשר יש מספר תמונות הסדר של ביצוע השליחה לא משפיע אחד על השני לכן אנחנו פשוט נחכה עם Promise.all עד שכל השליחות יבוצעו.
חשוב לציין הסיבה שמשתמשים ב-FormData זה כי אנחנו לא יכולים לשלוח קבצי תמונות כ-json ומומלץ לשלוח לפי סוג של Content-Type: multipart/form-data.
לסיכום
המדריך הנוכחי מטרתו לסייע לכולנו בפעם הבאה שנוסיף פונקציונליות של העלאת תמונות. בנושא הזה יש עוד מספר צדדים שכולל את צד שרת: אחסון התמונה, שינוי הקובץ לסוג יותר אופטימלי כמו WebP. בנוסף בצד לקוח יש גם את החלק השני של טעינת תמונות שהתקבלו מהשרת והוספה של lazy loading כדי לשפר את זמני הטעינה וחווית המשתמש.
נ.ב- מוזמנים להציץ בפרוייקט דמו שכתבתי לצורך ההדגמה:
https://upload-images-in-react-demo.onrender.com
https://github.com/ArkadiK94/upload_images_in_react_demo
אם אהבתם את המדריך הזה אתם מוזמנים להיכנס גם לעמוד המדריכים שלנו.