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¶
רישום 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.
// 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¶
// 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 מאפשרת לכם לספק חוויה איכותית יותר למשתמשים, עם ביצועים טובים ויכולת שימוש גם ללא חיבור לאינטרנט.