بناء تطبيق ويب تقدمي (PWA) باستخدام Next.js App Router

AI Bot
بواسطة AI Bot ·

جاري تحميل مشغل تحويل النص إلى كلام الصوتي...

تجمع تطبيقات الويب التقدمية (PWA) بين أفضل ما في الويب والتطبيقات الأصلية: التثبيت على الشاشة الرئيسية، والعمل بدون اتصال، وإشعارات الدفع، والأداء الأمثل. في هذا الدليل التعليمي، ستقوم بتحويل تطبيق Next.js App Router إلى تطبيق PWA كامل، خطوة بخطوة.

المتطلبات الأساسية

قبل البدء، تأكد من توفر:

  • Node.js 20+ مثبت على جهازك
  • معرفة أساسية بـ Next.js App Router و TypeScript
  • محرر أكواد (يُنصح بـ VS Code)
  • متصفح متوافق مع PWA مثل Chrome أو Edge أو Firefox

ما ستبنيه

تطبيق Next.js يتميز بـ:

  • التثبيت كتطبيق أصلي على الهاتف والحاسوب
  • العمل بدون اتصال بفضل التخزين المؤقت الذكي
  • إرسال إشعارات الدفع للمستخدمين
  • المزامنة التلقائية عند عودة الاتصال
  • الحصول على درجة 100 في اختبار Lighthouse PWA

الخطوة 1: إنشاء مشروع Next.js

لنبدأ بتهيئة مشروع Next.js جديد مع TypeScript:

npx create-next-app@latest my-pwa-app --typescript --tailwind --app --src-dir
cd my-pwa-app

ثبّت الحزم اللازمة لدعم PWA:

npm install next-pwa @ducanh2912/next-pwa
npm install -D webpack

نستخدم @ducanh2912/next-pwa وهو النسخة المحدثة والمتوافقة مع Next.js 14+ و App Router.

الخطوة 2: تهيئة next-pwa

عدّل ملف next.config.ts لتفعيل دعم PWA:

// next.config.ts
import type { NextConfig } from "next";
import withPWAInit from "@ducanh2912/next-pwa";
 
const withPWA = withPWAInit({
  dest: "public",
  cacheOnFrontEndNav: true,
  aggressiveFrontEndNavCaching: true,
  reloadOnOnline: true,
  swcMinify: true,
  disable: process.env.NODE_ENV === "development",
  workboxOptions: {
    disableDevLogs: true,
  },
});
 
const nextConfig: NextConfig = {
  reactStrictMode: true,
};
 
export default withPWA(nextConfig);

هذا التكوين يقوم بـ:

  • إنشاء Service Worker في مجلد public/
  • تفعيل التخزين المؤقت المكثف للتنقل في جانب العميل
  • إعادة التحميل تلقائيًا عند عودة الاتصال
  • تعطيل Service Worker في بيئة التطوير لتجنب التعارضات

الخطوة 3: إنشاء ملف Web App Manifest

يخبر ملف manifest المتصفح بكيفية عرض تطبيقك بعد التثبيت. أنشئ src/app/manifest.ts:

// src/app/manifest.ts
import type { MetadataRoute } from "next";
 
export default function manifest(): MetadataRoute.Manifest {
  return {
    name: "تطبيقي PWA",
    short_name: "تطبيقي",
    description: "تطبيق ويب تقدمي مبني بـ Next.js App Router",
    start_url: "/",
    display: "standalone",
    background_color: "#0a0a0a",
    theme_color: "#3b82f6",
    orientation: "portrait-primary",
    icons: [
      {
        src: "/icons/icon-192x192.png",
        sizes: "192x192",
        type: "image/png",
        purpose: "maskable",
      },
      {
        src: "/icons/icon-384x384.png",
        sizes: "384x384",
        type: "image/png",
      },
      {
        src: "/icons/icon-512x512.png",
        sizes: "512x512",
        type: "image/png",
        purpose: "any",
      },
    ],
    screenshots: [
      {
        src: "/screenshots/desktop.png",
        sizes: "1280x720",
        type: "image/png",
        form_factor: "wide",
      },
      {
        src: "/screenshots/mobile.png",
        sizes: "390x844",
        type: "image/png",
        form_factor: "narrow",
      },
    ],
    categories: ["productivity", "utilities"],
  };
}

الأيقونات ذات purpose: "maskable" ضرورية لنظام Android. فهي تسمح للنظام بتطبيق قناع تكيفي على أيقونتك لتندمج بشكل متناسق مع التطبيقات الأخرى.

الخطوة 4: إضافة البيانات الوصفية لـ PWA

حدّث تخطيط الجذر لتضمين البيانات الوصفية اللازمة:

// src/app/layout.tsx
import type { Metadata, Viewport } from "next";
import "./globals.css";
 
export const metadata: Metadata = {
  title: "تطبيقي PWA",
  description: "تطبيق PWA مبني بـ Next.js App Router",
  appleWebApp: {
    capable: true,
    statusBarStyle: "default",
    title: "تطبيقي",
  },
  formatDetection: {
    telephone: false,
  },
};
 
export const viewport: Viewport = {
  themeColor: "#3b82f6",
  width: "device-width",
  initialScale: 1,
  maximumScale: 1,
  userScalable: false,
};
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ar" dir="rtl">
      <head>
        <link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
      </head>
      <body className="antialiased">{children}</body>
    </html>
  );
}

الخطوة 5: إنشاء أيقونات PWA

أنشئ الأيقونات المطلوبة. يمكنك استخدام أداة مثل pwa-asset-generator:

mkdir -p public/icons public/screenshots
npx pwa-asset-generator ./public/logo.svg ./public/icons \
  --icon-only --favicon --type png \
  --padding "10%" --background "#0a0a0a"

أو أنشئ الملفات التالية يدويًا:

public/
├── icons/
│   ├── icon-192x192.png
│   ├── icon-384x384.png
│   └── icon-512x512.png
├── screenshots/
│   ├── desktop.png
│   └── mobile.png
└── favicon.ico

الخطوة 6: تنفيذ التخزين المؤقت للعمل بدون اتصال

حدّث next.config.ts لتضمين قواعد التخزين المؤقت:

// next.config.ts
import type { NextConfig } from "next";
import withPWAInit from "@ducanh2912/next-pwa";
 
const withPWA = withPWAInit({
  dest: "public",
  cacheOnFrontEndNav: true,
  aggressiveFrontEndNavCaching: true,
  reloadOnOnline: true,
  swcMinify: true,
  disable: process.env.NODE_ENV === "development",
  workboxOptions: {
    disableDevLogs: true,
    runtimeCaching: [
      {
        urlPattern: /\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,
        handler: "CacheFirst",
        options: {
          cacheName: "image-cache",
          expiration: {
            maxEntries: 64,
            maxAgeSeconds: 30 * 24 * 60 * 60, // 30 يوم
          },
        },
      },
      {
        urlPattern: /^https:\/\/fonts\.(?:googleapis|gstatic)\.com\/.*/i,
        handler: "CacheFirst",
        options: {
          cacheName: "google-fonts-cache",
          expiration: {
            maxEntries: 16,
            maxAgeSeconds: 365 * 24 * 60 * 60, // سنة واحدة
          },
        },
      },
      {
        urlPattern: /^https:\/\/api\..*$/i,
        handler: "StaleWhileRevalidate",
        options: {
          cacheName: "api-cache",
          expiration: {
            maxEntries: 32,
            maxAgeSeconds: 60 * 60, // ساعة واحدة
          },
        },
      },
    ],
  },
});
 
const nextConfig: NextConfig = {
  reactStrictMode: true,
};
 
export default withPWA(nextConfig);

الخطوة 7: إنشاء صفحة عدم الاتصال

عندما يكون المستخدم بدون اتصال ويحاول الوصول إلى صفحة غير مخزنة مؤقتًا، اعرض صفحة خطأ ودية:

// src/app/offline/page.tsx
export default function OfflinePage() {
  return (
    <div className="flex min-h-screen flex-col items-center justify-center bg-gray-950 text-white">
      <div className="text-center">
        <div className="mb-6 text-6xl">📡</div>
        <h1 className="mb-4 text-3xl font-bold">
          أنت غير متصل بالإنترنت
        </h1>
        <p className="mb-8 text-gray-400">
          تحقق من اتصالك بالإنترنت وحاول مرة أخرى.
        </p>
        <button
          onClick={() => window.location.reload()}
          className="rounded-lg bg-blue-600 px-6 py-3 font-semibold
                     transition-colors hover:bg-blue-700"
        >
          إعادة المحاولة
        </button>
      </div>
    </div>
  );
}

يتم تخزين صفحة /offline مؤقتًا تلقائيًا بواسطة next-pwa. عندما يفقد المستخدم الاتصال، يُعاد توجيهه إلى هذه الصفحة بدلاً من رؤية خطأ المتصفح الافتراضي.

الخطوة 8: اكتشاف حالة الاتصال

أنشئ hook مخصص للتفاعل مع تغييرات الاتصال:

// src/hooks/useOnlineStatus.ts
"use client";
 
import { useSyncExternalStore } from "react";
 
function subscribe(callback: () => void) {
  window.addEventListener("online", callback);
  window.addEventListener("offline", callback);
  return () => {
    window.removeEventListener("online", callback);
    window.removeEventListener("offline", callback);
  };
}
 
function getSnapshot() {
  return navigator.onLine;
}
 
function getServerSnapshot() {
  return true; // دائمًا متصل على الخادم
}
 
export function useOnlineStatus() {
  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

استخدم هذا الـ hook في مكوّن شريط الإشعار:

// src/components/OnlineBanner.tsx
"use client";
 
import { useOnlineStatus } from "@/hooks/useOnlineStatus";
 
export function OnlineBanner() {
  const isOnline = useOnlineStatus();
 
  if (isOnline) return null;
 
  return (
    <div className="fixed top-0 left-0 right-0 z-50 bg-amber-600
                    px-4 py-2 text-center text-sm font-medium text-white">
      ⚠️ أنت حاليًا غير متصل بالإنترنت.
      بعض الميزات قد تكون محدودة.
    </div>
  );
}

الخطوة 9: زر تثبيت مخصص

أنشئ زر تثبيت أنيق يظهر عندما يعرض المتصفح خيار التثبيت:

// src/components/InstallPrompt.tsx
"use client";
 
import { useEffect, useState } from "react";
 
interface BeforeInstallPromptEvent extends Event {
  prompt(): Promise<void>;
  userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
 
export function InstallPrompt() {
  const [deferredPrompt, setDeferredPrompt] =
    useState<BeforeInstallPromptEvent | null>(null);
  const [isVisible, setIsVisible] = useState(false);
  const [isInstalled, setIsInstalled] = useState(false);
 
  useEffect(() => {
    if (window.matchMedia("(display-mode: standalone)").matches) {
      setIsInstalled(true);
      return;
    }
 
    const handler = (e: Event) => {
      e.preventDefault();
      setDeferredPrompt(e as BeforeInstallPromptEvent);
      setIsVisible(true);
    };
 
    window.addEventListener("beforeinstallprompt", handler);
 
    window.addEventListener("appinstalled", () => {
      setIsInstalled(true);
      setIsVisible(false);
      setDeferredPrompt(null);
    });
 
    return () => {
      window.removeEventListener("beforeinstallprompt", handler);
    };
  }, []);
 
  const handleInstall = async () => {
    if (!deferredPrompt) return;
 
    await deferredPrompt.prompt();
    const { outcome } = await deferredPrompt.userChoice;
 
    if (outcome === "accepted") {
      setIsVisible(false);
    }
    setDeferredPrompt(null);
  };
 
  if (!isVisible || isInstalled) return null;
 
  return (
    <div className="fixed bottom-4 left-4 right-4 z-50 mx-auto max-w-md
                    rounded-xl bg-gray-900 p-4 shadow-2xl border
                    border-gray-700 md:left-auto md:right-4">
      <div className="flex items-center gap-4">
        <div className="flex-shrink-0 text-3xl">📱</div>
        <div className="flex-1">
          <h3 className="font-semibold text-white">
            تثبيت التطبيق
          </h3>
          <p className="text-sm text-gray-400">
            وصول سريع من شاشتك الرئيسية
          </p>
        </div>
        <div className="flex gap-2">
          <button
            onClick={() => setIsVisible(false)}
            className="rounded-lg px-3 py-2 text-sm text-gray-400
                       hover:text-white transition-colors"
          >
            لاحقًا
          </button>
          <button
            onClick={handleInstall}
            className="rounded-lg bg-blue-600 px-4 py-2 text-sm
                       font-semibold text-white hover:bg-blue-700
                       transition-colors"
          >
            تثبيت
          </button>
        </div>
      </div>
    </div>
  );
}

الخطوة 10: إشعارات الدفع

نفّذ إشعارات الدفع لإشراك مستخدميك:

// src/components/PushNotification.tsx
"use client";
 
import { useEffect, useState } from "react";
 
const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || "";
 
function urlBase64ToUint8Array(base64String: string) {
  const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding)
    .replace(/-/g, "+")
    .replace(/_/g, "/");
  const rawData = window.atob(base64);
  const outputArray = new Uint8Array(rawData.length);
  for (let i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
}
 
export function PushNotification() {
  const [permission, setPermission] =
    useState<NotificationPermission>("default");
  const [isSubscribed, setIsSubscribed] = useState(false);
 
  useEffect(() => {
    if ("Notification" in window) {
      setPermission(Notification.permission);
    }
  }, []);
 
  const subscribeToNotifications = async () => {
    try {
      const registration = await navigator.serviceWorker.ready;
 
      const subscription = await registration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
      });
 
      await fetch("/api/push/subscribe", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(subscription),
      });
 
      setIsSubscribed(true);
      setPermission("granted");
    } catch (error) {
      console.error("خطأ في الاشتراك بالإشعارات:", error);
    }
  };
 
  if (permission === "denied") return null;
  if (isSubscribed) return null;
 
  return (
    <button
      onClick={subscribeToNotifications}
      className="flex items-center gap-2 rounded-lg bg-green-600 px-4
                 py-2 text-sm font-semibold text-white
                 hover:bg-green-700 transition-colors"
    >
      🔔 تفعيل الإشعارات
    </button>
  );
}

الخطوة 11: المزامنة في الخلفية

نفّذ مزامنة البيانات عند عودة الاتصال:

// src/lib/backgroundSync.ts
"use client";
 
interface SyncData {
  url: string;
  method: string;
  body: string;
  timestamp: number;
}
 
const DB_NAME = "pwa-sync-queue";
const STORE_NAME = "pending-requests";
 
async function openDB(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, 1);
    request.onupgradeneeded = () => {
      request.result.createObjectStore(STORE_NAME, {
        autoIncrement: true,
      });
    };
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}
 
export async function queueRequest(
  url: string,
  method: string,
  body: object
) {
  const db = await openDB();
  const tx = db.transaction(STORE_NAME, "readwrite");
  const store = tx.objectStore(STORE_NAME);
 
  const syncData: SyncData = {
    url,
    method,
    body: JSON.stringify(body),
    timestamp: Date.now(),
  };
 
  store.add(syncData);
 
  if ("serviceWorker" in navigator && "SyncManager" in window) {
    const registration = await navigator.serviceWorker.ready;
    await (registration as any).sync.register("sync-pending");
  }
}
 
export async function processPendingRequests() {
  const db = await openDB();
  const tx = db.transaction(STORE_NAME, "readwrite");
  const store = tx.objectStore(STORE_NAME);
 
  const allRequests = await new Promise<SyncData[]>((resolve) => {
    const request = store.getAll();
    request.onsuccess = () => resolve(request.result);
  });
 
  for (const syncData of allRequests) {
    try {
      await fetch(syncData.url, {
        method: syncData.method,
        headers: { "Content-Type": "application/json" },
        body: syncData.body,
      });
    } catch {
      return;
    }
  }
 
  const clearTx = db.transaction(STORE_NAME, "readwrite");
  clearTx.objectStore(STORE_NAME).clear();
}

الخطوة 12: تجميع كل شيء معًا

حدّث صفحتك الرئيسية لدمج جميع المكونات:

// src/app/page.tsx
import { InstallPrompt } from "@/components/InstallPrompt";
import { OnlineBanner } from "@/components/OnlineBanner";
import { PushNotification } from "@/components/PushNotification";
import { ContactForm } from "@/components/ContactForm";
 
export default function Home() {
  return (
    <>
      <OnlineBanner />
      <main className="min-h-screen bg-gray-950 text-white">
        <div className="mx-auto max-w-4xl px-4 py-16">
          <h1 className="mb-4 text-5xl font-bold bg-gradient-to-r
                         from-blue-400 to-purple-500 bg-clip-text
                         text-transparent">
            تطبيقي PWA
          </h1>
          <p className="mb-8 text-xl text-gray-400">
            قابل للتثبيت، يعمل بدون اتصال، سريع.
          </p>
 
          <div className="mb-12 grid gap-6 md:grid-cols-3">
            <FeatureCard
              icon="📱"
              title="قابل للتثبيت"
              description="أضف التطبيق إلى شاشتك الرئيسية"
            />
            <FeatureCard
              icon="🔌"
              title="بدون اتصال"
              description="يعمل بدون اتصال بالإنترنت"
            />
            <FeatureCard
              icon="🔔"
              title="إشعارات"
              description="ابقَ على اطلاع في الوقت الحقيقي"
            />
          </div>
 
          <div className="mb-8">
            <PushNotification />
          </div>
 
          <section className="rounded-xl border border-gray-800 p-6">
            <h2 className="mb-4 text-2xl font-semibold">
              تواصل معنا
            </h2>
            <ContactForm />
          </section>
        </div>
      </main>
      <InstallPrompt />
    </>
  );
}
 
function FeatureCard({
  icon,
  title,
  description,
}: {
  icon: string;
  title: string;
  description: string;
}) {
  return (
    <div className="rounded-xl border border-gray-800 p-6 text-center
                    hover:border-gray-600 transition-colors">
      <div className="mb-3 text-4xl">{icon}</div>
      <h3 className="mb-2 text-lg font-semibold">{title}</h3>
      <p className="text-sm text-gray-400">{description}</p>
    </div>
  );
}

اختبار تطبيق PWA

الاختبار المحلي

قم بتشغيل بناء إنتاجي لاختبار Service Worker:

npm run build
npm start

لا يعمل Service Worker في وضع التطوير (npm run dev). يجب دائمًا الاختبار ببناء إنتاجي.

تدقيق Lighthouse

  1. افتح أدوات مطور Chrome (F12)
  2. انتقل إلى تبويب Lighthouse
  3. حدد Progressive Web App
  4. شغّل التدقيق

يجب أن يحصل تطبيقك على درجة قريبة من 100 إذا تم استيفاء جميع المعايير.

الفحوصات اليدوية

اختبر السيناريوهات التالية:

  • التثبيت: انقر على أيقونة التثبيت في شريط العناوين
  • بدون اتصال: فعّل وضع الطيران وتصفح التطبيق
  • التخزين المؤقت: زر الصفحات، انتقل لوضع عدم الاتصال، ثم أعد زيارتها
  • الإشعارات: فعّل الإشعارات وأرسل اختبارًا

إضافة إلى .gitignore

ملفات Service Worker والتخزين المؤقت المُنشأة لا يجب تتبعها في Git:

# PWA
public/sw.js
public/sw.js.map
public/workbox-*.js
public/workbox-*.js.map
public/worker-*.js
public/worker-*.js.map
public/fallback-*.js
public/swe-worker-*.js

استكشاف الأخطاء وإصلاحها

Service Worker لا يتحدث

امسح ذاكرة التخزين المؤقت في أدوات مطور Chrome، تبويب التطبيق، قسم Service Workers، وانقر على "Unregister". ثم أعد تحميل الصفحة.

لا يظهر طلب التثبيت

حدث beforeinstallprompt يُطلق فقط إذا:

  • الصفحة تُقدم عبر HTTPS (أو localhost)
  • ملف manifest صالح مع الحقول المطلوبة
  • تم تسجيل Service Worker
  • لم يقم المستخدم بتثبيت التطبيق بالفعل

الإشعارات لا تعمل

تحقق من أن:

  • مفاتيح VAPID مُهيأة بشكل صحيح
  • Service Worker نشط
  • المستخدم منح الإذن
  • المتصفح يدعم واجهة Push API

الخطوات التالية

  • أضف المشاركة الأصلية مع Web Share API
  • نفّذ الوضع الداكن التكيفي مع prefers-color-scheme
  • استكشف Periodic Background Sync للتحديثات المنتظمة
  • ادمج Workbox لاستراتيجيات تخزين مؤقت متقدمة
  • أضف دعم اختصارات التطبيق في ملف manifest

الخلاصة

لقد حوّلت تطبيق Next.js إلى تطبيق ويب تقدمي كامل. تطبيقك الآن قابل للتثبيت على جميع الأجهزة، ويعمل بدون اتصال بفضل التخزين المؤقت الذكي، ويمكنه إرسال إشعارات الدفع لإشراك مستخدميك.

تمثل تطبيقات PWA أفضل حل وسط بين تطبيقات الويب والتطبيقات الأصلية: قاعدة كود واحدة، نشر فوري عبر الرابط، وإمكانيات أصلية. مع Next.js App Router والأدوات الحديثة، أصبح إنشاء PWA أسهل من أي وقت مضى.


هل تريد قراءة المزيد من الدروس التعليمية؟ تحقق من أحدث درس تعليمي لدينا على بناء وكلاء ذكاء اصطناعي باستخدام Google ADK و TypeScript: دليل شامل من الصفر.

ناقش مشروعك معنا

نحن هنا للمساعدة في احتياجات تطوير الويب الخاصة بك. حدد موعدًا لمناقشة مشروعك وكيف يمكننا مساعدتك.

دعنا نجد أفضل الحلول لاحتياجاتك.

مقالات ذات صلة