O que são Progressive Web Apps (PWAs) e como implementá-las

Question155

Tagspwa, progressive-web-apps, service-worker, web-manifest, offline, push-notifications, web-development

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