پردازش دستهای و خودکار تصاویر محصول با sharp: WebP، AVIF و سرعت فروشگاه
خط پردازش خودکار تصاویر محصول با Node.js و sharp: اندازههای ثابت، تبدیل به WebP و AVIF، حذف متادیتا، صف با محدودیت همزمانی، نامگذاری بر اساس هش و تگ picture برای LCP و CLS بهتر.
در فروشگاه اینترنتی، تصاویر محصول معمولاً سنگینترین بخش صفحهاند و بیشترین اثر را روی سرعت دارند. اگر هر عکس همانطور که از دوربین یا عکاس رسیده آپلود شود، صفحه دستهبندی چند مگابایت میشود و در اینترنت موبایل کند باز میشود. اگر هم آمادهسازی دستی انجام شود، انتشار هر محصول تازه به یک کار وقتگیر تبدیل میشود. در پروژه عینکسیتی، که عکسهای فریم عینک را برای فروشگاه عینکسازی آماده میکرد، این کار را خودکار کردم. این مقاله همان خط پردازش است، با تصمیمهایی که پشت هر مرحله بود.
هدف: یک بار پردازش، چند خروجی
ایده اصلی ساده است: عکس اصلی فقط یک بار آپلود و نگهداری میشود، و همه نسخههایی که سایت لازم دارد بهصورت خودکار از آن ساخته میشوند. چند عرض ثابت (مثلاً برای کارت محصول، صفحه محصول و بزرگنمایی) در دو فرمت مدرن WebP و AVIF، بهعلاوه یک نسخه JPEG برای مرورگرهای خیلی قدیمی.
عرضها را ثابت و محدود نگه دارید. اگر هر صفحه اندازه دلخواه بخواهد، تعداد نسخهها زیاد میشود و کش CDN بیاثر. سه یا چهار اندازه برای بیشتر فروشگاهها کافی است.
نکات کلیدی
- عکس اصلی را دستنخورده نگه دارید؛ هر وقت خواستید نسخهها را دوباره بسازید.
- چند عرض ثابت بهجای اندازه دلخواه.
- AVIF کوچکتر است ولی کندتر ساخته میشود؛ WebP پشتیبان مطمئن است.
خط پردازش با sharp
کتابخانه sharp روی libvips ساخته شده و برای این کار سریع و کممصرف است. ترتیب مراحل مهم است: اول چرخش بر اساس EXIF (وگرنه عکسهای گوشی کج میمانند)، بعد یکدست کردن پسزمینه شفاف، بعد تغییر اندازه، و در آخر خروجی با حذف متادیتا. حذف متادیتا هم حجم را کم میکند و هم اطلاعاتی مثل مدل دوربین و گاهی موقعیت مکانی را از عکس منتشرشده پاک میکند.
برای عکس محصول، fit: 'contain' با پسزمینه یکدست بهتر از برش (cover) است؛ چون برش ممکن است بخشی از محصول (مثلاً دسته عینک) را حذف کند.
import sharp from 'sharp';
import { createHash } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
const WIDTHS = [400, 800, 1600];
export async function processProductImage(input: Buffer, outDir: string) {
const hash = createHash('sha1').update(input).digest('hex').slice(0, 12);
await mkdir(outDir, { recursive: true });
const base = sharp(input, { failOn: 'error' })
.rotate() // بر اساس EXIF
.flatten({ background: '#ffffff' }); // پسزمینه شفاف → سفید
const outputs: string[] = [];
for (const w of WIDTHS) {
const resized = base.clone().resize({ width: w, height: w, fit: 'contain', background: '#ffffff', withoutEnlargement: true });
const webp = await resized.clone().webp({ quality: 80 }).toBuffer();
const avif = await resized.clone().avif({ quality: 55, effort: 4 }).toBuffer();
await writeFile(`${outDir}/${hash}-${w}.webp`, webp);
await writeFile(`${outDir}/${hash}-${w}.avif`, avif);
outputs.push(`${hash}-${w}`);
}
return { hash, outputs }; // متادیتا در خروجیها نیست مگر withMetadata صدا زده شود
}صف و محدودیت همزمانی
اشتباه رایج این است که وقتی کاربر صد عکس آپلود کرد، هر صد پردازش همزمان شروع شوند. ساخت AVIF پردازندهبر است و صد کار همزمان، حافظه سرور را پر میکند و سایت را برای بقیه کاربران کند میکند. پردازش را در صف بگذارید و تعداد کار همزمان را به اندازه تعداد هستههای پردازنده (یا کمتر) محدود کنید.
پردازش هم نباید در درخواست آپلود انجام شود. آپلود فقط فایل اصلی را ذخیره میکند و کار را در صف میگذارد؛ یک ورکر جداگانه پردازش میکند و نتیجه را ثبت میکند. اینطوری رابط کاربری همیشه سریع میماند و اگر پردازشی شکست خورد، میشود دوباره اجرایش کرد بدون اینکه کاربر چیزی را دوباره آپلود کند.
نکات کلیدی
- پردازش در ورکر جدا، نه در درخواست آپلود.
- همزمانی محدود به تعداد هستهها.
- هر کار قابل تکرار باشد (idempotent)؛ اجرای دوباره نباید خروجی تکراری بسازد.
نامگذاری با هش و کش طولانی
نام فایل خروجی از هش محتوای عکس اصلی ساخته میشود. فایدهاش این است که هر نسخه میتواند با کش یکساله و immutable سرو شود؛ اگر عکس عوض شود، هش و در نتیجه آدرس عوض میشود و کاربر هیچوقت عکس قدیمی را از کش نمیبیند. اگر یک عکس دو بار آپلود شود، پردازش دوم لازم نیست.
نام قابلخواندن برای سئو را میشود در مسیر نگه داشت (مثلاً نام محصول در آدرس پوشه) و هش را برای یکتایی کنارش گذاشت. متن جایگزین (alt) هم برای سئوی تصویر از نام فایل مهمتر است: نام محصول، رنگ و برند را در alt بنویسید.
نمایش: picture، srcset و جلوگیری از پرش صفحه
تولید نسخهها نیمی از کار است؛ نیم دیگر این است که مرورگر نسخه درست را انتخاب کند. تگ picture با srcset و sizes به مرورگر میگوید چه فرمتها و اندازههایی هست و خود مرورگر کوچکترین نسخه مناسب را میگیرد.
دو نکته برای Core Web Vitals: اول، width و height را همیشه مشخص کنید تا مرورگر قبل از رسیدن عکس جایش را رزرو کند و صفحه نپرد (CLS). دوم، عکس اصلی بالای صفحه محصول (که معمولاً LCP است) نباید lazy باشد؛ آن را با fetchpriority="high" زودتر بارگذاری کنید و بقیه عکسها را lazy.
<picture>
<source type="image/avif" srcset="/img/abc123-400.avif 400w, /img/abc123-800.avif 800w, /img/abc123-1600.avif 1600w" sizes="(max-width: 768px) 100vw, 50vw">
<source type="image/webp" srcset="/img/abc123-400.webp 400w, /img/abc123-800.webp 800w, /img/abc123-1600.webp 1600w" sizes="(max-width: 768px) 100vw, 50vw">
<img src="/img/abc123-800.webp" width="800" height="800" alt="فریم عینک طبی گربهای مشکی" fetchpriority="high">
</picture>اندازهگیری نتیجه
بعد از راهاندازی، اثر را اندازه بگیرید، نه حدس بزنید. در Lighthouse یا PageSpeed Insights، شاخص LCP صفحه محصول و دستهبندی را قبل و بعد مقایسه کنید و در تب Network مرورگر ببینید هر صفحه دسته چقدر تصویر دانلود میکند. گزارش Core Web Vitals در Search Console هم اثر واقعی روی کاربران را نشان میدهد، با چند هفته تأخیر.
جمعبندی
خط پردازش خودکار تصاویر یعنی عکس اصلی یک بار آپلود شود و نسخههای WebP و AVIF در چند عرض ثابت، با حذف متادیتا و نامگذاری هش، در یک ورکر جدا ساخته شوند. با picture و srcset، ابعاد مشخص و اولویت درست برای عکس اصلی، هم سرعت بهتر میشود و هم صفحه نمیپرد. این کار در عینکسیتی برای فروشگاه عینکسازی انجام شد. اگر فروشگاه شما کند است، بررسی سرعت و سئوی فنی از همینجا شروع میشود.