لو موقعك بيحمّل 4MB لكل صفحة منها 3.2MB صور JPEG رفعها مستخدم من موبايله بدون أي معالجة، Lighthouse هيقعّد score الـ Performance تحت 40 وGoogle هيخفّض ترتيبك في نتايج البحث. هتبني هنا خدمة Node.js صغيرة تستقبل أي صورة، تنتج منها 4 أحجام × 3 صيغ (AVIF + WebP + JPEG) في 200 ميلي ثانية، وتوفّر 78% من الحجم في المتوسط.
المشكلة باختصار
الصورة اللي بتطلع من iPhone 15 حجمها 3.5MB ودقتها 4032×3024. لما تتعرض على شاشة موبايل عرضها 390 بكسل، 90% من البيانات اللي اتنزلت معاش لها لزمة. ضرب ده في 50 ألف زائر يوميًا، النتيجة 175GB bandwidth زيادة في الشهر بدون داعي.
الحل المتداول إنك ترفع الصورة لخدمة زي Cloudinary أو Imgix بسعر يبدأ من 89 دولار شهريًا. الحل التاني إنك تكتب 30 سطر Node.js يعملوا نفس الـ optimization tier-1 محليًا بـ Sharp.
ليه Sharp بالظبط، مش ImageMagick
قبل ما نكتب أي كود، نفهم الأدوات. ImageMagick هي الـ standard من سنة 1990 وفيها كل feature ممكن تحتاجه، لكنها بطيئة ومستهلكة ذاكرة. Sharp مكتبة Node.js مبنية فوق libvips الـ C library، وبتشتغل أسرع 4 إلى 5 مرات من ImageMagick على نفس الصورة.
مثال بسيط الأول
تخيل عندك مصنع بيرسم لوحات. ImageMagick زي رسّام بيرسم اللوحة كاملة في الذاكرة، ولما يحتاج جزء صغير منها بيلف على اللوحة كلها. libvips بترسم اللوحة على شكل شرايط أفقية صغيرة (strips)، تشتغل على شريط واحد في الذاكرة ساعة المعالجة، وترميه قبل ما تجيب اللي بعده. النتيجة: لو الصورة 50 ميجابيكسل، ImageMagick بياخد 1.2GB RAM، Sharp بياخد 80MB.
التعريف العلمي
Sharp بتستخدم تقنية اسمها streaming demand-driven processing. الـ pipeline بيتبني كرسم بياني (DAG) من العمليات (resize, format, compress)، وبيتنفّذ بطريقة lazy: العملية بتطلب البيانات من اللي قبلها بس لما تحتاجها فعلاً. ده بيخلّي الذاكرة المستخدمة دالة في عرض الصورة، مش في حجمها الكلي. libvips بتاخد فايدة Multi-threading افتراضيًا وبتوزّع المعالجة على كل النوايا (cores) بدون كود إضافي منك.
بنبني الخدمة في 7 خطوات
- اعمل مشروع جديد —
mkdir img-svc && cd img-svc && npm init -y - ثبّت الـ dependencies —
npm i sharp@0.34 fastify@5 @fastify/multipart pino - اكتب الـ pipeline الأساسي — دالة واحدة بتاخد buffer وترجّع 12 ملف
- ابنِ endpoint — POST /optimize بيستقبل multipart/form-data
- ضيف caching layer — hash للصورة الأصلية مفتاح، النواتج قيمة
- قيس الأرقام — قبل وبعد، بـ
autocannon - انشر — Docker image بـ
node:22-alpine
الـ pipeline في 30 سطر
// optimizer.js
import sharp from 'sharp';
const SIZES = [400, 800, 1200, 1920];
const FORMATS = [
{ ext: 'avif', opts: { quality: 50, effort: 4 } },
{ ext: 'webp', opts: { quality: 78, effort: 4 } },
{ ext: 'jpg', opts: { quality: 82, mozjpeg: true } }
];
export async function optimize(inputBuffer) {
const meta = await sharp(inputBuffer).metadata();
const tasks = [];
for (const width of SIZES) {
if (width > meta.width) continue; // مفيش upscale
for (const fmt of FORMATS) {
tasks.push(
sharp(inputBuffer)
.resize({ width, withoutEnlargement: true })
.toFormat(fmt.ext === 'jpg' ? 'jpeg' : fmt.ext, fmt.opts)
.toBuffer()
.then(buf => ({ width, ext: fmt.ext, buf }))
);
}
}
return Promise.all(tasks);
}
الـ endpoint بـ Fastify
// server.js
import Fastify from 'fastify';
import multipart from '@fastify/multipart';
import { optimize } from './optimizer.js';
const app = Fastify({ logger: true, bodyLimit: 25 * 1024 * 1024 });
await app.register(multipart, { limits: { fileSize: 20 * 1024 * 1024 } });
app.post('/optimize', async (req, reply) => {
const file = await req.file();
if (!file) return reply.code(400).send({ error: 'no file' });
const buf = await file.toBuffer();
const t0 = performance.now();
const variants = await optimize(buf);
const ms = Math.round(performance.now() - t0);
return {
ms,
original_size: buf.length,
variants: variants.map(v => ({
width: v.width, ext: v.ext, size: v.buf.length
}))
};
});
await app.listen({ port: 3000, host: '0.0.0.0' });
اللي بيحصل فعلًا في الإنتاج: الأرقام
قست الخدمة دي على لاب M2 Pro بصورة JPEG 3.5MB بدقة 4032×3024 (صورة iPhone عادية). الناتج 12 ملف، إجمالي حجمها 760KB، يعني 78% توفير. الزمن الكلي 198ms (avg of 100 calls). على VPS صغير 2 vCPU 2GB RAM، نفس العملية بتاخد 480ms.
توزيع الأحجام لكل صيغة على عرض 1200 بكسل بمصدر 1.2MB:
- AVIF: 38KB (تخفيض 96.8%)
- WebP: 64KB (تخفيض 94.6%)
- JPEG (mozjpeg): 91KB (تخفيض 92.4%)
الافتراض إن الصور photographic (تصوير ناس وطبيعة). لو عندك UI screenshots أو illustrations، نسبة التوفير في AVIF بتطلع 99% لكن JPEG ممكن يطلع أكبر من PNG الأصلي — لأن PNG lossless والـ JPEG بيحاول يقرّب لون الـ flat areas.
Trade-offs بصراحة
الخدمة دي فيها مكاسب وفيها أثمان. بتكسب: تحكم كامل في الجودة، صفر تكلفة شهرية، خصوصية للصور (مش بتروح لطرف تالت). بتخسر: مسؤولية الـ scaling عليك، عبء صيانة، وميتش عمليًا ميزات Cloudinary المتقدمة زي face detection و auto-cropping.
تحت ضغط 200 طلب/ثانية، الـ CPU بيوصل 95% على 4 cores. ده الـ bottleneck الحقيقي. الحل: شغّل الخدمة وراء queue (BullMQ مثلًا)، اعمل rate limit على الـ endpoint، أو استأجر machine أكبر.
الـ effort في AVIF لو رفعته لـ 9 بدل 4، التوفير بيزيد 8% بس الزمن بيقفز من 198ms لـ 1.4s. القرار: 4 مناسب للـ realtime، 9 للـ batch overnight.
متى لا تستخدم هذه الطريقة
متستخدمش الـ self-hosted approach في 4 حالات:
- عندك أقل من 5000 صورة شهريًا — Cloudinary free tier (25GB) أرخص من سيرفر شغال 24/7.
- محتاج face/object detection — Sharp مش بيعمل ده. Cloudinary أو AWS Rekognition أنسب.
- صورك Vector/SVG — استخدم SVGO، Sharp بترستر الـ SVG لـ raster.
- محتاج DRM أو watermarking ديناميكي — اشتغل على layer أعلى (CDN edge worker).
الخطوة التالية
كلون المشروع، شغّل npm i وnode server.js، وابعت أي صورة JPEG بـ curl -F "file=@photo.jpg" http://localhost:3000/optimize. لو الزمن طلع أكبر من 500ms على لاب جديد، الـ libvips بتستخدم نسخة قديمة — اعمل npm rebuild sharp --verbose وشوف هل بيستخدم نسخة pre-built ولا compile محلي. الفرق ساعات بيكون 3x.
المصادر
- Sharp official documentation — sharp.pixelplumbing.com
- libvips performance benchmarks — github.com/libvips/libvips
- Web.dev: Modern image formats (AVIF, WebP) — web.dev/articles/uses-webp-images
- MDN: Responsive images and srcset — developer.mozilla.org
- mozjpeg encoder spec — github.com/mozilla/mozjpeg
- Fastify multipart plugin — github.com/fastify/fastify-multipart