أول مرة فتحت فيها Chrome DevTools عشان أفحص أداء واجهة المتجر على شبكة 4G موبايل مخففة، تقييم Lighthouse طلع 41 من 100!
مقياس الـ Largest Contentful Paint (LCP) كان واصل قريب الـ 5 ثواني. الصورة الرئيسية كانت تاخذ وقت طويل جداً عشان تظهر، التنقل بين الأقسام كان فيه بطء ملحوظ، وأول ما تنزل لتحت بالصفحة كان التصميم يقفز قفزات عشوائية (Layout Jumps) تخلي الزرار تفلت من تحت إيدك.
المشكلة ما كانت إنه Laravel بطيء، ولا إنه اختيار React 19 كان غلط. المشكلة كانت تراكم أخطاء صغيرة ومتكررة بتصير بأي مشروع متجر إلكتروني بيمشي بسرعة: صور منتجات مرفوعة مباشرة من كاميرات التلفونات بحجم 3 ميجابايت، إعدادات حزم Bundling افتراضية عم تسحب مكتبات لوحة التحكم الثقيلة لداخل كود الزبون، واستعلامات داتابيز بتعمل Joins مكررة مع كل ضغطة فلتر.
هاي الخطوات العملية اللي عملتها عشان نوصل النتيجة لفوق الـ 95 على الموبايل، شو اللي زبط معنا، وشو الشغلات اللي جربناها وما نفعت.
وين كانت الثواني عم تضيع بالزبط؟
قبل ما أعدل أي سطر كود، عملت 5 فحوصات متتالية على Lighthouse وسجلت Performance Trace كامل في Chrome. المشاكل انحصرت بـ 3 نقاط رئيسية:
- تلوث حزمة الكود الأولية (Bundle Contamination): واجهة الزبون ولوحة التحكم للإدارة كانوا بنفس المشروع (Monorepo). وبسبب مسارات الـ Imports الافتراضية، مكتبات الرسوم البيانية (
recharts) ومحرر النصوص (react-quill) دخلوا مباشرة في الكود اللي بحمله الزبون أول ما يفتح المتجر! يعني زائر داخل بس يشتري قميص، عم يحمّل كود شارتات وتقارير مبيعات هو مش محتاجها أصلاً. - صور خام وبدون تحديد أبعاد: البائعين كانوا يرفعوا صور المنتجات بدقة 3MB بصيغة JPEG مباشرة. لما المتجر يعرض شبكة المنتجات على الموبايل بعمودين، كان يحمل صورة بحجم شاشة كمبيوتر داخل مربع عرضه 180 بكسل فقط، وبدون تحديد نسبة أبعاد (Aspect Ratio)، مما رفع مؤشر الـ CLS لـ 0.28.
- استعلامات قاعدة بيانات مكررة مع كل تنقل: كل ضغطة على فلتر أو تصنيف كانت تبعث طلب للـ API يجيب كل تصنيفات المنتجات وخصائصها والألوان والأحجام بدون أي تخزين مؤقت (In-Memory Caching). مؤشر TTFB كان حوالي 1.2 ثانية.
1. فصل حزم الكود في Vite بدقة جراحية
النظام مبني كـ Single-Page App باستخدام Vite + React 19 على الفرونت إند، وLaravel 11 REST API في الباك إند:
[ زبون على شبكة 4G موبايل ]
│
▼
[ واجهة المتجر: Vite + React 19 ]
├── Zustand: إدارة سلة الشراء وحالة الواجهة
├── TanStack Query: كاشينج ذكي لبيانات الـ API
├── فصل دقيق للحزم (manualChunks): عزل مكتبات الأدمن بالكامل
└── عناصر صور محددة الأبعاد لمنع قفزات التصميم
│
▼ (REST API)
[ الباك إند: Laravel 11 ]
├── تحويل وتصغير فوري للصور إلى WebP (مع كاش على القرص)
├── كاش سريع لبيانات الفلاتر والتصنيفات (Redis)
└── تفريغ الكاش تلقائياً عند تعديل أي منتج
الغلطة اللي وقعت فيها بفصل الحزم
أول ما فتحت vite.config.ts، فكرتي كانت إني أقسم كل مكتبة لحزمة منفصلة (Chunk)، كنت متوقع إنه كل ما زادت الحزم بصير الكاش أفضل.
حتى مكتبة zustand عملتلها حزمة لحالها:
// شو جربت بالبداية (ما تعمل هيك):
if (id.includes("zustand")) return "vendor-zustand";
هذا كان قرار غلط. فصل Zustand لحزمة مستقلة عمل طلب شبكة إضافي (Round-trip) لملف حجمه بالكاد 2.4 كيلوبايت. وعلى شبكات الموبايل ذات الـ Latency العالي، هذا الطلب الإضافي أخر تشغيل الكود بدل ما يسرعه.
رجعت لغيت هالتقسيم ودمجت المكتبات الخفيفة مع كود React الأساسي. اللي كان عن جد مهم هو عزل المكتبات الضخمة اللي الزبون ما إله أي علاقة فيها:
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import viteCompression from "vite-plugin-compression";
export default defineConfig({
plugins: [
react(),
viteCompression({ algorithm: "gzip", ext: ".gz" }),
],
esbuild: {
// مسح logs التطوير في بيئة الإنتاج
drop: ["console"],
},
build: {
rollupOptions: {
output: {
manualChunks(id) {
// 1. كود React الأساسي مدمج مع المكتبات الضرورية الخفيفة
if (
id.includes("/node_modules/react/") ||
id.includes("/node_modules/react-dom/") ||
id.includes("/node_modules/scheduler/") ||
id.includes("use-sync-external-store") ||
id.includes("zustand")
) {
return "vendor-react";
}
// 2. مكتبات الأدمن الثقيلة: معزولة تماماً عن مسار الزبون
if (id.includes("recharts")) return "vendor-admin-charts";
if (id.includes("react-quill")) return "vendor-admin-editor";
// 3. إدارة البيانات والأيقونات
if (id.includes("@tanstack")) return "vendor-query";
if (id.includes("lucide-react")) return "vendor-icons";
if (id.includes("react-hook-form") || id.includes("@hookform") || id.includes("/node_modules/zod/")) {
return "vendor-forms";
}
},
},
},
},
});
بمجرد ما عزلنا recharts وreact-quill في حزم خاصة بلوحة التحكم، نزل حجم كود الجافاسكريبت الأولي اللي بحمله الزبون بأكثر من 180 كيلوبايت (مضغوطة).
2. تحويل وتصغير الصور لصيغة WebP فورياً مع منع قفزات التصميم
الصور كانت مسؤولة عن أكثر من 70% من إجمالي حجم البيانات المنقولة في صفحات المنتجات. كان لازم نلتزم بقاعدتين:
- ما تخلي المستخدم يحمل صورة بعرض 1800 بكسل على شاشة موبايل عرضها 390 بكسل.
- ما تسمح للصور إنها تحرك عناصر الصفحة أثناء التحميل.
بناء نظام التصغير الفوري في Laravel
كتبت Controller بسيط في Laravel عشان يولد نسخ مصغرة بصيغة WebP حسب الطلب. بس أول كود كتبته كان فيه مشكلة: ما كنت أحفظ الصور المصغرة على القرص. فلما تفتح صفحة فيها 20 منتج، السيرفر يشغل مكتبة GD في PHP عشرين مرة بنفس اللحظة، الميموري تضرب، ووقت الاستجابة يطير في السماء.
الحل كان واضح ومباشر: حفظ النسخة الناتجة مباشرة داخل storage/app/public/responsive/{width}/{path}. بعد أول مرة تتولد فيها الصورة بأي مقاس، كل الطلبات اللي بعدها بتخدم مباشرة من القرص مع ترويسات كاش طويلة الأمد (Immutable Cache):
// ResponsiveImageController.php
public function show(Request $request)
{
$path = $request->query('path');
$width = (int) $request->query('width', 800);
// التحقق من المقاسات المسموحة لمنع استغلال السيرفر
$allowedWidths = [400, 640, 800, 1200, 1600];
if (!in_array($width, $allowedWidths, true)) {
return response('Invalid width requested', 400);
}
$cachePath = "responsive/{$width}/" . ltrim($path, '/');
if (Storage::disk('public')->exists($cachePath)) {
return response(Storage::disk('public')->get($cachePath), 200, [
'Content-Type' => 'image/webp',
'Cache-Control' => 'public, max-age=31536000, immutable',
]);
}
// تحميل الصورة الأصلية، تصغيرها وحفظها في الكاش
$rawImage = Storage::disk('public')->get($path);
$source = imagecreatefromstring($rawImage);
$origWidth = imagesx($source);
$origHeight = imagesy($source);
$height = (int) round(($origHeight / $origWidth) * $width);
$target = imagecreatetruecolor($width, $height);
imagealphablending($target, false);
imagesavealpha($target, true);
imagecopyresampled($target, $source, 0, 0, 0, 0, $width, $height, $origWidth, $origHeight);
ob_start();
imagewebp($target, null, 82);
$webpData = ob_get_clean();
imagedestroy($source);
imagedestroy($target);
Storage::disk('public')->put($cachePath, $webpData);
return response($webpData, 200, [
'Content-Type' => 'image/webp',
'Cache-Control' => 'public, max-age=31536000, immutable',
]);
}
جانب الفرونت إند: استخدام srcset وحجز الأبعاد بدقة
على جهة React، عملت مكون مخصص بحدد مسارات الصور عبر المقاسات المناسبة وبحجز مساحة محددة بنسبة أبعاد ثابتة:
// storefront/src/components/shared/ProductImage.tsx
interface ProductImageProps {
path: string;
alt: string;
priority?: boolean;
}
const WIDTHS = [400, 640, 800, 1200];
export function ProductImage({ path, alt, priority = false }: ProductImageProps) {
const srcSet = WIDTHS
.map((w) => `/api/v1/media/responsive?path=${encodeURIComponent(path)}&width=${w} ${w}w`)
.join(", ");
return (
<div className="relative aspect-square w-full overflow-hidden bg-slate-800 rounded-lg">
<img
src={`/api/v1/media/responsive?path=${encodeURIComponent(path)}&width=640`}
srcSet={srcSet}
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
alt={alt}
loading={priority ? "eager" : "lazy"}
decoding={priority ? "sync" : "async"}
fetchPriority={priority ? "high" : "auto"}
className="h-full w-full object-cover"
/>
</div>
);
}
نقطتين هون حلوا مشاكل كبيرة في التقييم:
- كلاس
aspect-squareبحجز الارتفاع الدقيق للمربع أثناء تحميل الصورة، وهالخطوة لحالها نزلت الـ CLS من 0.28 لـ 0.005. - تفعيل خاصية
priorityعلى الصورة الأولى الرئيسية بالصفحة بتعطيهاfetchPriority="high"وdecoding="sync"، فالمتصفح بعطيها أولوية قصوى وما بأخرها وراء خطوط أو ملفات CSS غير ضرورية.
3. التخزين المؤقت لبيانات الفلاتر والتصنيفات
كل مرة الزائر كان يتنقل فيها بين الفلاتر، الـ API كان يعيد استعلامات معقدة لربط التاجز، الأقسام، وأسعار الخيارات. هاي البيانات نادراً ما تتغير خلال تصفح الزبون، ومع ذلك كانت تضيف 300-400ms تأخير غير مبرر في كل صفحة.
خزنا بيانات الفلاتر الأساسية في Redis:
// TaxonomyResolver.php
public function getFilterableAttributes(): array
{
return Cache::remember('shop.filter_meta.base', 3600, function () {
return [
'categories' => Category::select('id', 'slug', 'name')->get(),
'sizes' => AttributeOption::where('attribute_id', 1)->pluck('value'),
'colors' => AttributeOption::where('attribute_id', 2)->pluck('value'),
];
});
}
ولضمان عدم وجود بيانات قديمة عند تعديل أي منتج من لوحة التحكم، ربطنا تفريغ الكاش مباشرة بدورة حياة الموديل في Eloquent:
// Product.php
protected static function booted(): void
{
static::saved(function ($product) {
Cache::forget('shop.filter_meta.base');
Cache::forget("seo.product.{$product->id}");
});
static::deleted(function ($product) {
Cache::forget('shop.filter_meta.base');
Cache::forget("seo.product.{$product->id}");
});
}
وعلى الفرونت إند، TanStack Query بيعمل كاش للاستجابات في الذاكرة مع مدة stale time قدرها 5 دقائق. فإذا المستخدم فتح صفحة منتج ورجع لقائمة القسم، القائمة بتظهر بلحظتها من الكاش بدون أي وميض أو Skeleton loaders.
كيف صارت الأرقام بعد التحسينات؟
تقييمات Lighthouse طبيعي تختلف شوي حسب ضغط المعالج وسرعة الشبكة. فحص مرة وحدة وادعاء رقم “97” مش مقياس دقيق، عشان هيك عملت 5 فحوصات متتالية قبل وبعد، مع محاكاة شبكة 4G موبايل وتقليل سرعة الـ CPU بمقدار 4x:
| المقياس | قبل التحسين (متوسط 5 مرات) | بعد التحسين (متوسط 5 مرات) | ملاحظات |
|---|---|---|---|
| تقييم الموبايل الكلي | 41 (بين 38–43) | 95 (بين 93–97) | لون أخضر ثابت بكل الفحوصات |
| Largest Contentful Paint | 4.8 ثواني | 1.2 ثانية | بفضل صور WebP وfetchpriority |
| First Contentful Paint | 2.8 ثواني | 0.7 ثانية | عزل مكتبات الأدمن من الحزمة |
| Cumulative Layout Shift | 0.28 | 0.01 | حجز مساحات الصور بنسب أبعاد ثابتة |
دروس مستفادة وتوصيات حقيقية
لو كنت رح أبدأ نفس المشروع من الصفر اليوم، هاي الأمور اللي كنت رح أعملها بشكل مختلف:
- ما كنت رح استخدم Zustand لسلة تسوق بهذا الحجم: Zustand مكتبة ممتازة، بس لسلة بسيطة فيها مصفوفة عناصر والكميات وزر فتح السلة،
useReducerمع React Context العادي كان كافي وزيادة بدون إضافة تبعيات خارجية. - حجز مساحات الصور شبه مجاني ومفعوله جبار: ما بتحتاج مكتبات جافاسكريبت معقدة عشان تحل مشكلة اهتزاز الصفحة. مجرد كلاس
aspect-squareأو تحديد نسبة أبعاد العنصر بحل 90% من مشاكل الـ CLS قبل ما تنزل الصورة أصلاً. - افحص هيكل الحزم من أول يوم: إذا كنت حاطط لوحة التحكم والواجهة بنفس المستودع، Rollup رح يدمجهم سوا تلقائياً إلا إذا حددت العكس. شغل أمر
npx vite-bundle-visualizerفي بداية المشروع، مش بعد ما تطلع على الإنتاج وتتفاجأ إنه حزمة الموبايل حجمها 800 كيلوبايت!
النقاش والتفاعلات
شارك بأفكارك، اطرح أسئلتك، أو اترك تفاعلاً بالأسفل عبر حسابك على GitHub.