Question — 155
Tags —
Introdução
Progressive Web Apps (PWAs) são aplicações web que combinam o melhor das aplicações web tradicionais com recursos nativos de aplicativos móveis. Elas utilizam tecnologias web modernas para oferecer experiências similares às de aplicativos nativos, incluindo funcionamento offline, notificações push e instalação no dispositivo.
Conceito-chave
Uma PWA é construída sobre três pilares fundamentais: ser Confiável (funciona offline), Rápida (responde rapidamente às interações) e Envolvente (oferece experiência imersiva). Isso é alcançado principalmente através de Service Workers, Web App Manifest e HTTPS como requisitos básicos.
Tópicos Relevantes
Componentes principais de uma PWA
1. Service Worker
// sw.js - Service Worker básico
const CACHE_NAME = 'my-pwa-v1';
const urlsToCache = [
'/',
'/styles/main.css',
'/script/main.js',
'/images/logo.png',
'/offline.html'
];
// Instalação do Service Worker
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
console.log('Cache aberto');
return cache.addAll(urlsToCache);
})
);
});
// Interceptação de requisições
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => {
// Retorna do cache se disponível
if (response) {
return response;
}
return fetch(event.request).catch(() => {
// Fallback para página offline
if (event.request.destination === 'document') {
return caches.match('/offline.html');
}
});
})
);
});
// Atualização do Service Worker
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
})
);
});
2. Web App Manifest
{
"name": "Minha Progressive Web App",
"short_name": "MinhaPWA",
"description": "Uma aplicação web progressiva exemplar",
"start_url": "/",
"display": "standalone",
"theme_color": "#2196F3",
"background_color": "#ffffff",
"orientation": "portrait",
"scope": "/",
"icons": [
{
"src": "/images/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/images/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"categories": ["productivity", "utilities"],
"shortcuts": [
{
"name": "Nova Tarefa",
"url": "/new-task",
"icons": [
{
"src": "/images/new-task-icon.png",
"sizes": "96x96"
}
]
}
]
}
3. Registro do Service Worker
// main.js - Registro do Service Worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then((registration) => {
console.log('SW registrado com sucesso:', registration);
// Verificar por atualizações
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
showUpdateAvailable();
}
});
});
})
.catch((error) => {
console.log('Falha no registro do SW:', error);
});
});
}
function showUpdateAvailable() {
const updateBanner = document.createElement('div');
updateBanner.innerHTML = `
<div class="update-banner">
<p>Nova versão disponível!</p>
<button onclick="updateApp()">Atualizar</button>
<button onclick="dismissUpdate()">Depois</button>
</div>
`;
document.body.appendChild(updateBanner);
}
function updateApp() {
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({ action: 'skipWaiting' });
}
window.location.reload();
}
Estratégias de Cache avançadas
// Estratégias de cache no Service Worker
const CACHE_STRATEGIES = {
CACHE_FIRST: 'cache-first',
NETWORK_FIRST: 'network-first',
STALE_WHILE_REVALIDATE: 'stale-while-revalidate',
NETWORK_ONLY: 'network-only',
CACHE_ONLY: 'cache-only'
};
// Cache First - para recursos estáticos
const cacheFirst = async (request) => {
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
const networkResponse = await fetch(request);
cache.put(request, networkResponse.clone());
return networkResponse;
};
// Network First - para dados dinâmicos
const networkFirst = async (request) => {
const cache = await caches.open(CACHE_NAME);
try {
const networkResponse = await fetch(request);
cache.put(request, networkResponse.clone());
return networkResponse;
} catch (error) {
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
throw error;
}
};
// Stale While Revalidate - para recursos que podem ser atualizados
const staleWhileRevalidate = async (request) => {
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(request);
const fetchPromise = fetch(request).then((networkResponse) => {
cache.put(request, networkResponse.clone());
return networkResponse;
});
return cachedResponse || fetchPromise;
};
// Aplicar estratégias baseadas na URL
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
if (url.pathname.startsWith('/api/')) {
// API calls: Network First
event.respondWith(networkFirst(request));
} else if (url.pathname.match(/\.(css|js|png|jpg|jpeg|svg)$/)) {
// Static assets: Cache First
event.respondWith(cacheFirst(request));
} else if (url.pathname.startsWith('/data/')) {
// Data that can be stale: Stale While Revalidate
event.respondWith(staleWhileRevalidate(request));
} else {
// Default strategy
event.respondWith(networkFirst(request));
}
});
Notificações Push
// Configuração de Push Notifications
class PushNotificationManager {
constructor() {
this.publicVapidKey = 'BM8NvVrP9vkGPzDbJQY4nBcvX4Qa_sD7Ds6Eq3PkHBdMR8ZFGvYe5C8mJqb5-Rh1wGpKZc3oF2vM1nN5aE7Q';
}
async requestPermission() {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
console.log('Permissão para notificações concedida');
return this.subscribeToPush();
} else {
console.log('Permissão para notificações negada');
return null;
}
}
async subscribeToPush() {
if (!('serviceWorker' in navigator)) {
throw new Error('Service Worker não suportado');
}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToUint8Array(this.publicVapidKey)
});
// Enviar subscription para o servidor
await this.sendSubscriptionToServer(subscription);
return subscription;
}
async sendSubscriptionToServer(subscription) {
await fetch('/api/push/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(subscription)
});
}
urlBase64ToUint8Array(base64String) {
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;
}
}
// No Service Worker - tratamento de notificações
self.addEventListener('push', (event) => {
const options = {
body: 'Nova mensagem disponível!',
icon: '/images/notification-icon.png',
badge: '/images/notification-badge.png',
vibrate: [200, 100, 200],
data: {
dateOfArrival: Date.now(),
primaryKey: 1
},
actions: [
{
action: 'explore',
title: 'Ver detalhes',
icon: '/images/checkmark.png'
},
{
action: 'close',
title: 'Fechar',
icon: '/images/xmark.png'
}
]
};
if (event.data) {
const data = event.data.json();
options.body = data.message;
options.data = data;
}
event.waitUntil(
self.registration.showNotification('PWA Notification', options)
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'explore') {
event.waitUntil(
clients.openWindow('/details')
);
} else if (event.action === 'close') {
// Notification is closed, do nothing
} else {
// Default action
event.waitUntil(
clients.openWindow('/')
);
}
});
Exemplo Prático
Implementação completa de uma PWA de lista de tarefas:
<!-- index.html -->
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Todo PWA</title>
<!-- PWA Meta Tags -->
<meta name="theme-color" content="#2196F3">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Todo PWA">
<!-- Manifest -->
<link rel="manifest" href="/manifest.json">
<!-- Icons -->
<link rel="apple-touch-icon" href="/images/icon-192x192.png">
<link rel="stylesheet" href="/styles/main.css">
</head>
<body>
<div id="app">
<header>
<h1>Todo PWA</h1>
<button id="installBtn" class="install-btn" style="display: none;">
Instalar App
</button>
</header>
<main>
<div class="add-todo">
<input type="text" id="todoInput" placeholder="Nova tarefa...">
<button id="addBtn">Adicionar</button>
</div>
<div class="sync-status" id="syncStatus">
<span class="status-text">Online</span>
</div>
<ul id="todoList" class="todo-list">
<!-- Tarefas serão inseridas aqui -->
</ul>
</main>
</div>
<script src="/scripts/db.js"></script>
<script src="/scripts/sync.js"></script>
<script src="/scripts/app.js"></script>
</body>
</html>
// scripts/db.js - IndexedDB para persistência offline
class TodoDB {
constructor() {
this.dbName = 'TodoPWA';
this.version = 1;
this.db = null;
}
async init() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve(this.db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('todos')) {
const store = db.createObjectStore('todos', {
keyPath: 'id',
autoIncrement: true
});
store.createIndex('timestamp', 'timestamp', { unique: false });
store.createIndex('synced', 'synced', { unique: false });
}
};
});
}
async addTodo(todo) {
const transaction = this.db.transaction(['todos'], 'readwrite');
const store = transaction.objectStore('todos');
const todoWithMeta = {
...todo,
timestamp: Date.now(),
synced: false
};
return store.add(todoWithMeta);
}
async getAllTodos() {
const transaction = this.db.transaction(['todos'], 'readonly');
const store = transaction.objectStore('todos');
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async updateTodo(id, updates) {
const transaction = this.db.transaction(['todos'], 'readwrite');
const store = transaction.objectStore('todos');
const todo = await this.getTodo(id);
const updatedTodo = { ...todo, ...updates };
return store.put(updatedTodo);
}
async deleteTodo(id) {
const transaction = this.db.transaction(['todos'], 'readwrite');
const store = transaction.objectStore('todos');
return store.delete(id);
}
async getUnsyncedTodos() {
const transaction = this.db.transaction(['todos'], 'readonly');
const store = transaction.objectStore('todos');
const index = store.index('synced');
return new Promise((resolve, reject) => {
const request = index.getAll(false);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
}
// scripts/sync.js - Sincronização em background
class BackgroundSync {
constructor(db) {
this.db = db;
this.setupBackgroundSync();
}
setupBackgroundSync() {
if ('serviceWorker' in navigator && 'sync' in window.ServiceWorkerRegistration.prototype) {
navigator.serviceWorker.ready.then((registration) => {
return registration.sync.register('background-sync');
});
}
}
async syncData() {
if (!navigator.onLine) {
console.log('Offline - sync adiado');
return;
}
const unsyncedTodos = await this.db.getUnsyncedTodos();
for (const todo of unsyncedTodos) {
try {
await this.syncTodo(todo);
await this.db.updateTodo(todo.id, { synced: true });
} catch (error) {
console.error('Erro ao sincronizar:', error);
}
}
}
async syncTodo(todo) {
const response = await fetch('/api/todos', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(todo)
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
}
// No Service Worker
self.addEventListener('sync', (event) => {
if (event.tag === 'background-sync') {
event.waitUntil(
// Implementar lógica de sincronização
syncPendingData()
);
}
});
async function syncPendingData() {
// Abrir IndexedDB e sincronizar dados pendentes
console.log('Executando sincronização em background');
}
// scripts/app.js - Aplicação principal
class TodoApp {
constructor() {
this.db = new TodoDB();
this.backgroundSync = null;
this.deferredPrompt = null;
this.init();
}
async init() {
await this.db.init();
this.backgroundSync = new BackgroundSync(this.db);
this.setupEventListeners();
this.setupInstallPrompt();
this.loadTodos();
this.updateOnlineStatus();
// Registrar Service Worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}
}
setupEventListeners() {
document.getElementById('addBtn').addEventListener('click', () => this.addTodo());
document.getElementById('todoInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.addTodo();
});
window.addEventListener('online', () => this.updateOnlineStatus());
window.addEventListener('offline', () => this.updateOnlineStatus());
}
setupInstallPrompt() {
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
this.deferredPrompt = e;
document.getElementById('installBtn').style.display = 'block';
});
document.getElementById('installBtn').addEventListener('click', () => {
if (this.deferredPrompt) {
this.deferredPrompt.prompt();
this.deferredPrompt.userChoice.then((result) => {
if (result.outcome === 'accepted') {
console.log('PWA instalada');
}
this.deferredPrompt = null;
document.getElementById('installBtn').style.display = 'none';
});
}
});
}
async addTodo() {
const input = document.getElementById('todoInput');
const text = input.value.trim();
if (!text) return;
const todo = {
text,
completed: false,
createdAt: new Date().toISOString()
};
await this.db.addTodo(todo);
input.value = '';
this.loadTodos();
// Tentar sincronizar imediatamente se online
if (navigator.onLine) {
this.backgroundSync.syncData();
}
}
async loadTodos() {
const todos = await this.db.getAllTodos();
const todoList = document.getElementById('todoList');
todoList.innerHTML = todos.map(todo => `
<li class="todo-item ${todo.completed ? 'completed' : ''}">
<span class="todo-text">${todo.text}</span>
<div class="todo-actions">
<button onclick="app.toggleTodo(${todo.id})" class="toggle-btn">
${todo.completed ? '↶' : '✓'}
</button>
<button onclick="app.deleteTodo(${todo.id})" class="delete-btn">
🗑️
</button>
${!todo.synced ? '<span class="sync-indicator">⏳</span>' : ''}
</div>
</li>
`).join('');
}
async toggleTodo(id) {
const todos = await this.db.getAllTodos();
const todo = todos.find(t => t.id === id);
if (todo) {
await this.db.updateTodo(id, {
completed: !todo.completed,
synced: false
});
this.loadTodos();
}
}
async deleteTodo(id) {
await this.db.deleteTodo(id);
this.loadTodos();
}
updateOnlineStatus() {
const statusElement = document.getElementById('syncStatus');
const isOnline = navigator.onLine;
statusElement.innerHTML = `
<span class="status-text ${isOnline ? 'online' : 'offline'}">
${isOnline ? 'Online' : 'Offline'}
</span>
`;
if (isOnline) {
this.backgroundSync.syncData();
}
}
}
// Inicializar aplicação
const app = new TodoApp();
Benefícios
1. Experiência offline completa
- Funcionalidade mantida sem conexão
- Sincronização automática quando online
- Cache inteligente de recursos
2. Performance superior
- Carregamento instantâneo de recursos cached
- Lazy loading de recursos não críticos
- Otimização automática de requests
3. Engajamento nativo
- Instalação no dispositivo
- Notificações push
- Integração com sistema operacional
4. Baixo custo de desenvolvimento
- Uso de tecnologias web existentes
- Uma base de código para múltiplas plataformas
- Atualizações automáticas
5. SEO e descoberta
- Indexável por motores de busca
- URLs compartilháveis
- Progressive enhancement
Considerações de Implementação
Requisitos mínimos:
- HTTPS obrigatório
- Service Worker funcional
- Web App Manifest válido
- Design responsivo
Estratégias de cache:
- Cache First para recursos estáticos
- Network First para dados dinâmicos
- Stale While Revalidate para recursos atualizáveis
Limitações:
- Suporte limitado em alguns browsers
- Restrições de iOS Safari
- Tamanho de cache limitado
- Complexidade de gerenciamento de estado offline