رجوع للمدونة
#أداء وسرعة #Laravel #React #معمارية برمجية #Vite
· 7 دقائق قراءة

كيف رفعت سرعة متجرنا الإلكتروني على الموبايل من 41 إلى 96 على PageSpeed

تجربة عملية بالأرقام لتنظيف حزم الجافاسكريبت الزائدة، بناء مسار تحويل صور WebP فوري في Laravel، وضبط مقاييس Core Web Vitals على اتصالات الموبايل الحقيقية.

محمد رياض

محمد رياض

مهندس أنظمة شامل وتطبيقات موبايل

أول مرة فتحت فيها 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 نقاط رئيسية:

  1. تلوث حزمة الكود الأولية (Bundle Contamination): واجهة الزبون ولوحة التحكم للإدارة كانوا بنفس المشروع (Monorepo). وبسبب مسارات الـ Imports الافتراضية، مكتبات الرسوم البيانية (recharts) ومحرر النصوص (react-quill) دخلوا مباشرة في الكود اللي بحمله الزبون أول ما يفتح المتجر! يعني زائر داخل بس يشتري قميص، عم يحمّل كود شارتات وتقارير مبيعات هو مش محتاجها أصلاً.
  2. صور خام وبدون تحديد أبعاد: البائعين كانوا يرفعوا صور المنتجات بدقة 3MB بصيغة JPEG مباشرة. لما المتجر يعرض شبكة المنتجات على الموبايل بعمودين، كان يحمل صورة بحجم شاشة كمبيوتر داخل مربع عرضه 180 بكسل فقط، وبدون تحديد نسبة أبعاد (Aspect Ratio)، مما رفع مؤشر الـ CLS لـ 0.28.
  3. استعلامات قاعدة بيانات مكررة مع كل تنقل: كل ضغطة على فلتر أو تصنيف كانت تبعث طلب للـ 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% من إجمالي حجم البيانات المنقولة في صفحات المنتجات. كان لازم نلتزم بقاعدتين:

  1. ما تخلي المستخدم يحمل صورة بعرض 1800 بكسل على شاشة موبايل عرضها 390 بكسل.
  2. ما تسمح للصور إنها تحرك عناصر الصفحة أثناء التحميل.

بناء نظام التصغير الفوري في 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 Paint4.8 ثواني1.2 ثانيةبفضل صور WebP وfetchpriority
First Contentful Paint2.8 ثواني0.7 ثانيةعزل مكتبات الأدمن من الحزمة
Cumulative Layout Shift0.280.01حجز مساحات الصور بنسب أبعاد ثابتة

دروس مستفادة وتوصيات حقيقية

لو كنت رح أبدأ نفس المشروع من الصفر اليوم، هاي الأمور اللي كنت رح أعملها بشكل مختلف:

  1. ما كنت رح استخدم Zustand لسلة تسوق بهذا الحجم: Zustand مكتبة ممتازة، بس لسلة بسيطة فيها مصفوفة عناصر والكميات وزر فتح السلة، useReducer مع React Context العادي كان كافي وزيادة بدون إضافة تبعيات خارجية.
  2. حجز مساحات الصور شبه مجاني ومفعوله جبار: ما بتحتاج مكتبات جافاسكريبت معقدة عشان تحل مشكلة اهتزاز الصفحة. مجرد كلاس aspect-square أو تحديد نسبة أبعاد العنصر بحل 90% من مشاكل الـ CLS قبل ما تنزل الصورة أصلاً.
  3. افحص هيكل الحزم من أول يوم: إذا كنت حاطط لوحة التحكم والواجهة بنفس المستودع، Rollup رح يدمجهم سوا تلقائياً إلا إذا حددت العكس. شغل أمر npx vite-bundle-visualizer في بداية المشروع، مش بعد ما تطلع على الإنتاج وتتفاجأ إنه حزمة الموبايل حجمها 800 كيلوبايت!
محمد رياض

عندك أفكار أو مشكلة مشابهة تحتاج حل؟

بأكتب عن أنظمة حقيقية، مقايضات، ومعماريات برمجية. تواصل معي أو ناقش تحدياتك التقنية.

// النقاش والتفاعلات GitHub Powered

النقاش والتفاعلات

شارك بأفكارك، اطرح أسئلتك، أو اترك تفاعلاً بالأسفل عبر حسابك على GitHub.