Перейти к содержимому

49. PWA с Angular

PWA (Progressive Web App) — это веб-приложение, которое работает как нативное: устанавливается на устройство, работает офлайн, получает push-уведомления и загружается мгновенно. Angular PWA — это встроенная поддержка через Service Worker 🚀


Окно терминала
ng add @angular/pwa

Команда автоматически:

  • Устанавливает @angular/service-worker
  • Создаёт ngsw-config.json
  • Добавляет manifest.webmanifest
  • Регистрирует Service Worker в app.config.ts
  • Добавляет метатеги в index.html

{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"updateMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.html",
"/manifest.webmanifest",
"/*.css",
"/*.js"
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/assets/**",
"/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
]
}
}
],
"dataGroups": [
{
"name": "api-freshness",
"urls": ["/api/news", "/api/products"],
"cacheConfig": {
"strategy": "freshness",
"maxSize": 100,
"maxAge": "3d",
"timeout": "10s"
}
},
{
"name": "api-performance",
"urls": ["/api/categories", "/api/config"],
"cacheConfig": {
"strategy": "performance",
"maxSize": 10,
"maxAge": "1d"
}
}
],
"navigationUrls": [
"/**",
"!/**/*.*",
"!/**/*__*",
"!/**/*__*/**"
]
}

Все ресурсы загружаются сразу при установке Service Worker. Идеально для критического JS/CSS:

{ "installMode": "prefetch" }

Ресурсы кешируются при первом обращении. Для изображений и второстепенных файлов:

{ "installMode": "lazy" }

Запросы к API отдаются из кеша немедленно, фоново обновляются:

{
"strategy": "performance",
"maxSize": 50,
"maxAge": "1h"
}

Сначала идём в сеть, если нет ответа за timeout — берём из кеша:

{
"strategy": "freshness",
"maxSize": 100,
"maxAge": "3d",
"timeout": "5s"
}

{
"name": "Мой Angular App",
"short_name": "MyApp",
"theme_color": "#dd0031",
"background_color": "#0f172a",
"display": "standalone",
"scope": "/",
"start_url": "/",
"orientation": "portrait",
"icons": [
{
"src": "assets/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "assets/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "assets/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
],
"shortcuts": [
{
"name": "Добавить товар",
"url": "/products/new",
"icons": [{ "src": "assets/icons/add.png", "sizes": "96x96" }]
}
]
}

services/sw-update.service.ts
import { Injectable } from '@angular/core';
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
import { filter } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
export class SwUpdateService {
constructor(private swUpdate: SwUpdate) {}
initialize() {
if (!this.swUpdate.isEnabled) {
console.log('Service Worker не активен (dev mode)');
return;
}
// Подписываемся на события обновления
this.swUpdate.versionUpdates
.pipe(filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'))
.subscribe(evt => {
console.log(`Текущая версия: ${evt.currentVersion.hash}`);
console.log(`Новая версия: ${evt.latestVersion.hash}`);
// Спрашиваем пользователя
const shouldUpdate = confirm(
'🚀 Доступна новая версия приложения. Обновить сейчас?'
);
if (shouldUpdate) {
this.swUpdate.activateUpdate().then(() => {
window.location.reload();
});
}
});
// Периодическая проверка обновлений (каждые 6 часов)
setInterval(() => {
this.swUpdate.checkForUpdate();
}, 6 * 60 * 60 * 1000);
}
}
// app.component.ts
@Component({...})
export class AppComponent {
constructor(swUpdateService: SwUpdateService) {
swUpdateService.initialize();
}
}

services/push-notification.service.ts
import { Injectable } from '@angular/core';
import { SwPush } from '@angular/service-worker';
@Injectable({ providedIn: 'root' })
export class PushNotificationService {
readonly VAPID_PUBLIC_KEY = 'YOUR_VAPID_PUBLIC_KEY';
constructor(private swPush: SwPush) {}
async subscribeToNotifications() {
if (!this.swPush.isEnabled) {
throw new Error('Service Worker Push не поддерживается');
}
try {
const subscription = await this.swPush.requestSubscription({
serverPublicKey: this.VAPID_PUBLIC_KEY,
});
// Отправляем подписку на сервер
await this.sendSubscriptionToServer(subscription);
console.log('✅ Push-уведомления подключены');
return subscription;
} catch (error) {
console.error('❌ Ошибка подписки:', error);
throw error;
}
}
listenToNotifications() {
return this.swPush.messages;
}
listenToClicks() {
return this.swPush.notificationClicks;
}
private async sendSubscriptionToServer(sub: PushSubscription) {
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sub),
});
}
}

services/install-prompt.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class InstallPromptService {
private deferredPrompt: any = null;
canInstall$ = new BehaviorSubject<boolean>(false);
constructor() {
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault(); // Не показываем браузерный промпт
this.deferredPrompt = e;
this.canInstall$.next(true);
});
window.addEventListener('appinstalled', () => {
this.deferredPrompt = null;
this.canInstall$.next(false);
console.log('✅ Приложение установлено!');
});
}
async install() {
if (!this.deferredPrompt) return;
this.deferredPrompt.prompt();
const { outcome } = await this.deferredPrompt.userChoice;
if (outcome === 'accepted') {
console.log('Пользователь установил приложение');
} else {
console.log('Пользователь отклонил установку');
}
this.deferredPrompt = null;
this.canInstall$.next(false);
}
}