לדלג לתוכן

11.5 PWA הרצאה

אפליקציות ווב מתקדמות - Progressive Web Apps (PWA)

הPWA היא אפליקציית ווב שמספקת חוויה דומה לאפליקציה מקומית (native) - עובדת אופליין, ניתנת להתקנה, ושולחת התראות. בשיעור זה נלמד כיצד להפוך אפליקציית Next.js ל-PWA.


מה זה PWA?

הPWA היא אפליקציית ווב שעומדת בשלושה קריטריונים:

  • אמינה - Reliable - טוענת מיד, גם בתנאי רשת גרועים או אופליין
  • מהירה - Fast - מגיבה מהר לאינטראקציות
  • מרתקת - Engaging - מרגישה כמו אפליקציה מקומית, ניתנת להתקנה

יכולות של PWA

  • עבודה אופליין או ברשת חלשה
  • התקנה על מסך הבית (ללא חנות אפליקציות)
  • התראות Push
  • גישה ל-APIs של המכשיר (מצלמה, GPS, חיישנים)
  • עדכון אוטומטי ברקע
  • סנכרון נתונים ברקע

מניפסט - Web App Manifest

המניפסט הוא קובץ JSON שמתאר את האפליקציה לדפדפן ולמערכת ההפעלה.

// public/manifest.json
{
  "name": "האפליקציה שלי",
  "short_name": "אפליקציה",
  "description": "אפליקציית ווב מתקדמת לניהול משימות",
  "start_url": "/",
  "display": "standalone",
  "orientation": "portrait",
  "background_color": "#ffffff",
  "theme_color": "#1a73e8",
  "dir": "rtl",
  "lang": "he",
  "icons": [
    {
      "src": "/icons/icon-72.png",
      "sizes": "72x72",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-96.png",
      "sizes": "96x96",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-128.png",
      "sizes": "128x128",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-144.png",
      "sizes": "144x144",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ],
  "screenshots": [
    {
      "src": "/screenshots/desktop.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide"
    },
    {
      "src": "/screenshots/mobile.png",
      "sizes": "375x812",
      "type": "image/png",
      "form_factor": "narrow"
    }
  ]
}

חיבור המניפסט ל-HTML

// app/layout.tsx
import { type Metadata } from 'next';

export const metadata: Metadata = {
  manifest: '/manifest.json',
  themeColor: '#1a73e8',
  appleWebApp: {
    capable: true,
    statusBarStyle: 'default',
    title: 'האפליקציה שלי',
  },
};

מצבי תצוגה - Display Modes

  • fullscreen - מסך מלא, ללא שום ממשק דפדפן
  • standalone - נראה כמו אפליקציה מקומית, ללא סרגל כתובות
  • minimal-ui - כמו standalone עם כפתורי ניווט מינימליים
  • browser - חלון דפדפן רגיל
/* עיצוב מותאם למצב standalone */
@media (display-mode: standalone) {
  .browser-only {
    display: none;
  }

  .app-header {
    /* padding נוסף עבור notch */
    padding-top: env(safe-area-inset-top);
  }
}

Service Workers - עובדי שירות

Service Worker הוא סקריפט שרץ ברקע, נפרד מהדף, ומשמש כ-proxy בין הדפדפן לרשת.

מחזור חיים - Lifecycle

התקנה (Install) -> המתנה (Waiting) -> הפעלה (Activate) -> פעיל

רישום Service Worker

// lib/registerSW.ts
export function registerServiceWorker() {
  if ('serviceWorker' in navigator) {
    window.addEventListener('load', async () => {
      try {
        const registration = await navigator.serviceWorker.register('/sw.js', {
          scope: '/',
        });

        console.log('SW רשום בהצלחה:', registration.scope);

        // בדיקת עדכונים
        registration.addEventListener('updatefound', () => {
          const newWorker = registration.installing;
          newWorker?.addEventListener('statechange', () => {
            if (
              newWorker.state === 'installed' &&
              navigator.serviceWorker.controller
            ) {
              // יש גרסה חדשה
              console.log('גרסה חדשה זמינה');
            }
          });
        });
      } catch (error) {
        console.error('רישום SW נכשל:', error);
      }
    });
  }
}

Service Worker בסיסי

// public/sw.js
const CACHE_NAME = 'my-app-v1';

// קבצים לשמירה ב-cache בהתקנה
const STATIC_ASSETS = [
  '/',
  '/offline',
  '/manifest.json',
  '/icons/icon-192.png',
  '/icons/icon-512.png',
];

// אירוע התקנה - שמירת קבצים סטטיים
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll(STATIC_ASSETS);
    })
  );
  // הפעלה מיידית ללא המתנה
  self.skipWaiting();
});

// אירוע הפעלה - ניקוי cache ישן
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => name !== CACHE_NAME)
          .map((name) => caches.delete(name))
      );
    })
  );
  // השתלטות על כל הלשוניות
  self.clients.claim();
});

// אירוע fetch - יירוט בקשות
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cachedResponse) => {
      // אם יש ב-cache - החזר
      if (cachedResponse) {
        return cachedResponse;
      }

      // אחרת - בקש מהרשת
      return fetch(event.request).then((response) => {
        // שמור תגובה תקינה ב-cache
        if (response.ok && event.request.method === 'GET') {
          const responseClone = response.clone();
          caches.open(CACHE_NAME).then((cache) => {
            cache.put(event.request, responseClone);
          });
        }
        return response;
      }).catch(() => {
        // אופליין - החזר דף אופליין
        if (event.request.mode === 'navigate') {
          return caches.match('/offline');
        }
        return new Response('Offline', { status: 503 });
      });
    })
  );
});

אסטרטגיות מטמון - Caching Strategies

Cache First - מטמון קודם

// מתאים לנכסים סטטיים שלא משתנים (תמונות, פונטים)
self.addEventListener('fetch', (event) => {
  if (event.request.destination === 'image') {
    event.respondWith(
      caches.match(event.request).then((cached) => {
        return cached || fetch(event.request).then((response) => {
          const clone = response.clone();
          caches.open('images-cache').then((cache) => cache.put(event.request, clone));
          return response;
        });
      })
    );
  }
});

Network First - רשת קודם

// מתאים לתוכן דינמי (API, דפי HTML)
async function networkFirst(request) {
  try {
    const response = await fetch(request);
    // שמירה ב-cache לשימוש אופליין
    const cache = await caches.open('dynamic-cache');
    cache.put(request, response.clone());
    return response;
  } catch {
    // אם הרשת לא זמינה - חזרה ל-cache
    const cached = await caches.match(request);
    return cached || new Response('Offline', { status: 503 });
  }
}

Stale While Revalidate - החזר ישן, עדכן ברקע

// מתאים לתוכן שצריך להיות מהיר אבל גם עדכני
async function staleWhileRevalidate(request) {
  const cache = await caches.open('swr-cache');
  const cached = await cache.match(request);

  // עדכון ברקע
  const networkPromise = fetch(request).then((response) => {
    cache.put(request, response.clone());
    return response;
  });

  // החזר את ה-cache אם יש, אחרת המתן לרשת
  return cached || networkPromise;
}

Workbox - ניהול Service Worker

Workbox היא ספרייה של Google שמפשטת את כתיבת Service Workers.

npm install workbox-webpack-plugin workbox-precaching workbox-routing workbox-strategies
// sw.ts עם Workbox
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

// Precache - קבצים שנוצרו ב-build
precacheAndRoute(self.__WB_MANIFEST);

// Cache First לתמונות
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 60,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30 יום
      }),
      new CacheableResponsePlugin({
        statuses: [0, 200],
      }),
    ],
  })
);

// Network First ל-API
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api-cache',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 5 * 60, // 5 דקות
      }),
    ],
  })
);

// Stale While Revalidate לפונטים
registerRoute(
  ({ request }) => request.destination === 'font',
  new StaleWhileRevalidate({
    cacheName: 'fonts',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 20,
      }),
    ],
  })
);

דפוסי תמיכה אופליין - Offline Support Patterns

דף אופליין

// app/offline/page.tsx
export default function OfflinePage() {
  return (
    <div style={{ textAlign: 'center', padding: '48px 16px' }}>
      <h1>אין חיבור לאינטרנט</h1>
      <p>נראה שאתה במצב אופליין. בדוק את החיבור לרשת ונסה שוב.</p>
      <button onClick={() => window.location.reload()}>
        נסה שוב
      </button>
    </div>
  );
}

זיהוי מצב רשת

import { useState, useEffect } from 'react';

function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(
    typeof navigator !== 'undefined' ? navigator.onLine : true
  );

  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  return isOnline;
}

// שימוש
function NetworkStatus() {
  const isOnline = useOnlineStatus();

  if (isOnline) return null;

  return (
    <div role="alert" style={{ background: '#f44336', color: 'white', padding: '8px', textAlign: 'center' }}>
      אתה במצב אופליין. שינויים יישמרו כשהחיבור יחזור.
    </div>
  );
}

התראות Push

// בקשת הרשאה
async function requestNotificationPermission() {
  if (!('Notification' in window)) {
    console.log('הדפדפן לא תומך בהתראות');
    return false;
  }

  const permission = await Notification.requestPermission();
  return permission === 'granted';
}

// רישום ל-Push
async function subscribeToPush() {
  const registration = await navigator.serviceWorker.ready;

  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(
      process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!
    ),
  });

  // שליחת ה-subscription לשרת
  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription),
  });
}

// המרת VAPID 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;
}
// ב-Service Worker - טיפול בהתראות Push
self.addEventListener('push', (event) => {
  const data = event.data?.json() || {};

  event.waitUntil(
    self.registration.showNotification(data.title || 'התראה חדשה', {
      body: data.body,
      icon: '/icons/icon-192.png',
      badge: '/icons/badge-72.png',
      dir: 'rtl',
      lang: 'he',
      data: { url: data.url || '/' },
    })
  );
});

// לחיצה על ההתראה
self.addEventListener('notificationclick', (event) => {
  event.notification.close();

  event.waitUntil(
    self.clients.openWindow(event.notification.data.url)
  );
});

התקנת PWA - הוספה למסך הבית

'use client';

import { useState, useEffect } from 'react';

interface BeforeInstallPromptEvent extends Event {
  prompt: () => Promise<void>;
  userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}

function InstallPrompt() {
  const [installPrompt, setInstallPrompt] = useState<BeforeInstallPromptEvent | null>(null);
  const [isInstalled, setIsInstalled] = useState(false);

  useEffect(() => {
    // בדיקה אם כבר מותקן
    if (window.matchMedia('(display-mode: standalone)').matches) {
      setIsInstalled(true);
      return;
    }

    const handler = (e: Event) => {
      e.preventDefault();
      setInstallPrompt(e as BeforeInstallPromptEvent);
    };

    window.addEventListener('beforeinstallprompt', handler);

    window.addEventListener('appinstalled', () => {
      setIsInstalled(true);
      setInstallPrompt(null);
    });

    return () => window.removeEventListener('beforeinstallprompt', handler);
  }, []);

  const handleInstall = async () => {
    if (!installPrompt) return;

    await installPrompt.prompt();
    const { outcome } = await installPrompt.userChoice;

    if (outcome === 'accepted') {
      setIsInstalled(true);
    }
    setInstallPrompt(null);
  };

  if (isInstalled || !installPrompt) return null;

  return (
    <div style={{ padding: '16px', backgroundColor: '#e8f5e9', borderRadius: '8px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
      <div>
        <strong>התקינו את האפליקציה</strong>
        <p>גישה מהירה מהמסך הראשי, עובדת גם אופליין</p>
      </div>
      <button onClick={handleInstall} style={{ padding: '8px 24px', backgroundColor: '#1a73e8', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>
        התקן
      </button>
    </div>
  );
}

export default InstallPrompt;

PWA עם Next.js - שימוש ב-next-pwa

npm install next-pwa
// next.config.js
const withPWA = require('next-pwa')({
  dest: 'public',
  register: true,
  skipWaiting: true,
  disable: process.env.NODE_ENV === 'development',
  runtimeCaching: [
    {
      urlPattern: /^https:\/\/fonts\.(?:gstatic|googleapis)\.com\/.*/i,
      handler: 'CacheFirst',
      options: {
        cacheName: 'google-fonts',
        expiration: {
          maxEntries: 10,
          maxAgeSeconds: 365 * 24 * 60 * 60, // שנה
        },
      },
    },
    {
      urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|ico)$/i,
      handler: 'CacheFirst',
      options: {
        cacheName: 'images',
        expiration: {
          maxEntries: 100,
          maxAgeSeconds: 30 * 24 * 60 * 60, // 30 יום
        },
      },
    },
    {
      urlPattern: /^https:\/\/api\.myapp\.com\/.*/i,
      handler: 'NetworkFirst',
      options: {
        cacheName: 'api',
        expiration: {
          maxEntries: 50,
          maxAgeSeconds: 5 * 60, // 5 דקות
        },
      },
    },
  ],
});

module.exports = withPWA({
  // שאר ההגדרות של Next.js
});

סיכום

בשיעור זה למדנו על:

  • מה זה PWA - אפליקציה אמינה, מהירה ומרתקת
  • מניפסט - קובץ JSON שמגדיר את המראה וההתנהגות בהתקנה
  • Service Workers - סקריפטים ברקע שמנהלים cache ותקשורת
  • אסטרטגיות מטמון - Cache First, Network First, Stale While Revalidate
  • Workbox - ספרייה שמפשטת את ניהול ה-Service Worker
  • תמיכה אופליין - דף אופליין, זיהוי מצב רשת, סנכרון
  • התראות Push - בקשת הרשאה, רישום והצגת התראות
  • התקנת PWA - הוספה למסך הבית ו-next-pwa

PWA מאפשרת לכם לספק חוויה איכותית יותר למשתמשים, עם ביצועים טובים ויכולת שימוש גם ללא חיבור לאינטרנט.