تطبيقات الويب التقدمية مع Next.js
مقدمة إلى تطبيقات الويب التقدمية (PWAs)
تجمع تطبيقات الويب التقدمية بين أفضل ما في تطبيقات الويب والتطبيقات الأصلية. توفر تجارب تشبه التطبيقات مع وظائف عدم الاتصال والإشعارات الفورية وإمكانية التثبيت مع الحفاظ على مدى الوصول وإمكانية الوصول إلى الويب.
فوائد PWA: توفر PWAs أداءً محسّنًا وإمكانية عدم الاتصال واستخدامًا منخفضًا للبيانات وتحديثات تلقائية وتجارب تشبه التطبيقات الأصلية دون قيود متجر التطبيقات.
المفاهيم الأساسية لـ PWA
- Service Workers: نصوص تعمل في الخلفية، تتيح وظائف عدم الاتصال والتخزين المؤقت
- بيان تطبيق الويب: ملف JSON يتحكم في كيفية ظهور التطبيق عند التثبيت
- HTTPS: مطلوب لـ service workers والعديد من ميزات PWA
- التصميم سريع الاستجابة: يعمل عبر جميع الأجهزة وأحجام الشاشات
إعداد PWA في Next.js
تثبيت next-pwa
npm install next-pwa
# أو
yarn add next-pwa
# أو
pnpm add next-pwa
تكوين next-pwa
// next.config.js
const withPWA = require('next-pwa')({
dest: 'public',
disable: process.env.NODE_ENV === 'development',
register: true,
skipWaiting: true,
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.(googleapis|gstatic)\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts',
expiration: {
maxEntries: 4,
maxAgeSeconds: 365 * 24 * 60 * 60 // سنة واحدة
}
}
},
{
urlPattern: /^https:\/\/api\.example\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
networkTimeoutSeconds: 10,
expiration: {
maxEntries: 50,
maxAgeSeconds: 24 * 60 * 60 // يوم واحد
}
}
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/i,
handler: 'CacheFirst',
options: {
cacheName: 'images',
expiration: {
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60 // 30 يومًا
}
}
}
]
});
module.exports = withPWA({
// تكوين Next.js الخاص بك
reactStrictMode: true
});
بيان تطبيق الويب
إنشاء manifest.json
// public/manifest.json
{
"name": "تطبيق Next.js PWA الخاص بي",
"short_name": "MyPWA",
"description": "PWA قوي مبني باستخدام Next.js",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
]
}
ربط البيان في التخطيط
// app/layout.tsx
export const metadata = {
manifest: '/manifest.json',
themeColor: '#000000',
appleWebApp: {
capable: true,
statusBarStyle: 'default',
title: 'تطبيق Next.js PWA الخاص بي'
}
};
export default function RootLayout({ children }) {
return (
<html lang="ar" dir="rtl">
<head>
<link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
</head>
<body>{children}</body>
</html>
);
}
نصيحة: استخدم أدوات مثل RealFaviconGenerator أو PWABuilder لإنشاء جميع الأيقونات وإعدادات البيان المطلوبة.
استراتيجيات Service Worker
استراتيجيات التخزين المؤقت
// 1. CacheFirst: الأسرع، الأفضل للأصول الثابتة
// يعيد المحتوى المخزن مؤقتًا إذا كان متاحًا، يعود إلى الشبكة
// 2. NetworkFirst: أولوية المحتوى الجديد
// يحاول الشبكة أولاً، يعود إلى ذاكرة التخزين المؤقت إذا كان غير متصل
// 3. CacheOnly: غير متصل فقط
// يستخدم ذاكرة التخزين المؤقت فقط، لا يستخدم الشبكة أبدًا
// 4. NetworkOnly: متصل فقط
// يستخدم الشبكة فقط، لا يستخدم ذاكرة التخزين المؤقت أبدًا
// 5. StaleWhileRevalidate: الأفضل من الاثنين
// يعيد ذاكرة التخزين المؤقت على الفور، يحدث ذاكرة التخزين المؤقت في الخلفية
Service Worker مخصص
// public/sw.js
const CACHE_NAME = 'my-pwa-cache-v1';
const urlsToCache = [
'/',
'/offline',
'/styles/main.css',
'/scripts/main.js'
];
// حدث التثبيت - تخزين الموارد الأساسية مؤقتًا
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(urlsToCache))
.then(() => self.skipWaiting())
);
});
// حدث التنشيط - تنظيف ذاكرات التخزين المؤقت القديمة
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
}).then(() => self.clients.claim())
);
});
// حدث الجلب - الخدمة من ذاكرة التخزين المؤقت أو الشبكة
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => {
// نجاح ذاكرة التخزين المؤقت - إرجاع الاستجابة المخزنة مؤقتًا
if (response) {
return response;
}
return fetch(event.request).then((response) => {
// التحقق من صحة الاستجابة
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// استنساخ الاستجابة لذاكرة التخزين المؤقت
const responseToCache = response.clone();
caches.open(CACHE_NAME)
.then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
});
}).catch(() => {
// فشلت الشبكة، إرجاع صفحة غير متصل
return caches.match('/offline');
})
);
});
دعم عدم الاتصال
صفحة غير متصل
// app/offline/page.tsx
export default function OfflinePage() {
return (
<div className="offline-container">
<h1>أنت غير متصل بالإنترنت</h1>
<p>يرجى التحقق من اتصالك بالإنترنت والمحاولة مرة أخرى.</p>
<button onClick={() => window.location.reload()}>
إعادة المحاولة
</button>
</div>
);
}
اكتشاف الاتصال/عدم الاتصال
// hooks/useOnlineStatus.ts
'use client';
import { useState, useEffect } from 'react';
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
// تعيين الحالة الأولية
setIsOnline(navigator.onLine);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
}
// components/OnlineIndicator.tsx
'use client';
import { useOnlineStatus } from '@/hooks/useOnlineStatus';
export function OnlineIndicator() {
const isOnline = useOnlineStatus();
if (isOnline) return null;
return (
<div className="offline-banner">
<span>⚠️ أنت غير متصل بالإنترنت حاليًا</span>
</div>
);
}
الإشعارات الفورية
طلب الإذن
// lib/notifications.ts
'use client';
export async function requestNotificationPermission() {
if (!('Notification' in window)) {
console.log('هذا المتصفح لا يدعم الإشعارات');
return false;
}
const permission = await Notification.requestPermission();
return permission === 'granted';
}
export async function subscribeUser() {
if (!('serviceWorker' in navigator)) return;
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!
)
});
// إرسال الاشتراك إلى الخادم الخاص بك
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
});
return subscription;
}
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;
}
إرسال الإشعارات الفورية
// app/api/push/send/route.ts
import webpush from 'web-push';
webpush.setVapidDetails(
'mailto:your-email@example.com',
process.env.VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
);
export async function POST(request: Request) {
const { subscription, title, body } = await request.json();
const payload = JSON.stringify({
title,
body,
icon: '/icons/icon-192x192.png',
badge: '/icons/badge-72x72.png'
});
try {
await webpush.sendNotification(subscription, payload);
return Response.json({ success: true });
} catch (error) {
console.error('خطأ في إرسال الإشعار الفوري:', error);
return Response.json(
{ error: 'فشل في إرسال الإشعار' },
{ status: 500 }
);
}
}
معالج الدفع في Service Worker
// public/sw.js (أضف إلى service worker الموجود)
self.addEventListener('push', (event) => {
const data = event.data.json();
const options = {
body: data.body,
icon: data.icon || '/icons/icon-192x192.png',
badge: data.badge || '/icons/badge-72x72.png',
vibrate: [200, 100, 200],
tag: 'notification-tag',
requireInteraction: true,
actions: [
{ action: 'open', title: 'فتح التطبيق' },
{ action: 'close', title: 'إغلاق' }
]
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'open') {
event.waitUntil(
clients.openWindow('/')
);
}
});
تحذير: تتطلب الإشعارات الفورية HTTPS في الإنتاج. لن تعمل على HTTP (باستثناء localhost للاختبار).
مطالبة تثبيت التطبيق
زر تثبيت مخصص
// components/InstallPrompt.tsx
'use client';
import { useState, useEffect } from 'react';
export function InstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = useState<any>(null);
const [showPrompt, setShowPrompt] = useState(false);
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e);
setShowPrompt(true);
};
window.addEventListener('beforeinstallprompt', handler);
return () => window.removeEventListener('beforeinstallprompt', handler);
}, []);
const handleInstall = async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
console.log('قبل المستخدم التثبيت');
}
setDeferredPrompt(null);
setShowPrompt(false);
};
if (!showPrompt) return null;
return (
<div className="install-prompt">
<p>ثبت تطبيقنا للحصول على تجربة أفضل!</p>
<button onClick={handleInstall}>تثبيت</button>
<button onClick={() => setShowPrompt(false)}>ليس الآن</button>
</div>
);
}
المزامنة في الخلفية
تنفيذ المزامنة في الخلفية
// lib/background-sync.ts
'use client';
export async function registerBackgroundSync(tag: string) {
if (!('serviceWorker' in navigator) || !('sync' in registration)) {
console.log('المزامنة في الخلفية غير مدعومة');
return false;
}
const registration = await navigator.serviceWorker.ready;
await registration.sync.register(tag);
return true;
}
// الاستخدام: وضع طلب API في قائمة الانتظار عند عدم الاتصال
export async function saveDataOffline(data: any) {
// الحفظ في IndexedDB
const db = await openDB();
await db.add('pending-requests', data);
// تسجيل المزامنة
await registerBackgroundSync('sync-data');
}
// public/sw.js (أضف إلى service worker)
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-data') {
event.waitUntil(syncPendingRequests());
}
});
async function syncPendingRequests() {
const db = await openIndexedDB();
const requests = await db.getAll('pending-requests');
for (const request of requests) {
try {
await fetch(request.url, {
method: request.method,
body: JSON.stringify(request.data),
headers: { 'Content-Type': 'application/json' }
});
// إزالة من IndexedDB عند النجاح
await db.delete('pending-requests', request.id);
} catch (error) {
console.error('فشلت المزامنة:', error);
}
}
}
تحسين الأداء
التخزين المؤقت المسبق للأصول الحرجة
// next.config.js - مع next-pwa
const withPWA = require('next-pwa')({
dest: 'public',
buildExcludes: [/middleware-manifest\.json$/],
publicExcludes: ['!robots.txt', '!sitemap.xml'],
additionalManifestEntries: [
{ url: '/offline', revision: 'offline-v1' }
]
});
قياس أداء PWA
// lib/performance.ts
'use client';
export function measurePWAPerformance() {
if (!('performance' in window)) return;
// قياس وقت تسجيل Service Worker
performance.mark('sw-registration-start');
navigator.serviceWorker.register('/sw.js').then(() => {
performance.mark('sw-registration-end');
performance.measure(
'sw-registration',
'sw-registration-start',
'sw-registration-end'
);
const measure = performance.getEntriesByName('sw-registration')[0];
console.log(`تم تسجيل SW في ${measure.duration}ms`);
});
// التقرير إلى التحليلات
if ('sendBeacon' in navigator) {
window.addEventListener('load', () => {
const perfData = JSON.stringify({
type: 'pwa-performance',
metrics: performance.getEntriesByType('navigation')
});
navigator.sendBeacon('/api/analytics', perfData);
});
}
}
تمرين: بناء PWA كامل الميزات
أنشئ PWA باستخدام Next.js يتضمن:
- Service worker مع استراتيجية CacheFirst للصور
- استراتيجية NetworkFirst لاستدعاءات API
- صفحة غير متصل مخصصة مع وظيفة إعادة المحاولة
- نظام إشعارات فورية مع إدارة الاشتراك
- مطالبة التثبيت مع واجهة مستخدم مخصصة
- المزامنة في الخلفية لتقديم النماذج عند عدم الاتصال
- مؤشر حالة الاتصال/عدم الاتصال
- مراقبة الأداء
مكافأة: أضف اختصارات التطبيق ومشاركة API الهدف ومعالجة الملفات API للحصول على تجربة تشبه التطبيقات الأصلية.
اختبار ميزات PWA
استخدام Lighthouse
# تشغيل تدقيق Lighthouse
npx lighthouse https://yourapp.com --view
# التحقق من درجة PWA
npx lighthouse https://yourapp.com --preset=pwa --view
قائمة التحقق من PWA
- ✅ يتم تقديمه عبر HTTPS
- ✅ تصميم سريع الاستجابة عبر الأجهزة
- ✅ manifest.json صالح مع جميع الحقول المطلوبة
- ✅ Service worker مسجل ونشط
- ✅ وظيفة عدم الاتصال تعمل
- ✅ أيقونات لجميع الأحجام (72px إلى 512px)
- ✅ وقت تحميل سريع (<3s على 3G)
- ✅ قابل للتثبيت (يعرض مطالبة التثبيت)
- ✅ يمكن الوصول إليه (تسميات ARIA، التنقل بلوحة المفاتيح)
- ✅ يعمل بدون JavaScript (التحسين التدريجي)
اعتبارات النشر
نشر Vercel
# vercel.json
{
"headers": [
{
"source": "/sw.js",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=0, must-revalidate"
},
{
"key": "Service-Worker-Allowed",
"value": "/"
}
]
}
]
}
نصيحة محترف: اختبر PWA الخاص بك على أجهزة ومتصفحات متعددة. يحتوي iOS Safari على دعم PWA مختلف مقارنةً بـ Android Chrome. قدم دائمًا بدائل للميزات غير المدعومة.