PWA: transformando sua app em instalável

1. Fundamentos de PWA e o Manifesto Web

Uma Progressive Web App (PWA) é uma aplicação web que utiliza tecnologias modernas para oferecer uma experiência semelhante a um aplicativo nativo. Seus três pilares fundamentais são: confiável (carrega instantaneamente mesmo em redes instáveis), rápido (responde rapidamente às interações do usuário) e envolvente (oferece uma experiência imersiva, como um app instalado).

O coração da instalabilidade de uma PWA é o arquivo manifest.json. Este arquivo JSON descreve como a aplicação deve se comportar quando instalada no dispositivo do usuário. As propriedades essenciais incluem:

{
  "name": "Minha App PWA",
  "short_name": "AppPWA",
  "description": "Uma aplicação React instalável",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#3f51b5",
  "icons": [
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

Para que uma app seja considerada instalável, ela deve atender a três critérios obrigatórios: ser servida via HTTPS, possuir um Service Worker registrado e ter um manifest.json válido.

2. Gerando e Configurando o Manifest no React

No ecossistema React, você pode criar o manifest.json manualmente na pasta public/ ou utilizar plugins que automatizam o processo. Com o Vite, por exemplo, o plugin vite-plugin-pwa simplifica toda a configuração:

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';

export default defineConfig({
  plugins: [
    react(),
    VitePWA({
      registerType: 'autoUpdate',
      includeAssets: ['favicon.ico', 'robots.txt'],
      manifest: {
        name: 'Minha App PWA',
        short_name: 'AppPWA',
        description: 'App React instalável',
        theme_color: '#3f51b5',
        background_color: '#ffffff',
        display: 'standalone',
        orientation: 'portrait',
        scope: '/',
        start_url: '/',
        icons: [
          {
            src: '/icons/icon-192x192.png',
            sizes: '192x192',
            type: 'image/png',
            purpose: 'any maskable'
          },
          {
            src: '/icons/icon-512x512.png',
            sizes: '512x512',
            type: 'image/png',
            purpose: 'any maskable'
          }
        ]
      }
    })
  ]
});

A propriedade display: standalone remove a barra de endereço do navegador, criando uma experiência imersiva. Já orientation permite fixar a orientação da tela (retrato ou paisagem).

3. Service Worker: O Coração da PWA

O Service Worker é um script JavaScript que o navegador executa em segundo plano, separado da página web. Ele permite interceptar requisições de rede, gerenciar caches e enviar notificações push. Seu ciclo de vida inclui três eventos principais: instalação, ativação e fetch.

Usando o Workbox (biblioteca do Google para Service Workers), podemos implementar estratégias de cache de forma declarativa:

// sw.js
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';

// Pré-cache dos assets da build
precacheAndRoute(self.__WB_MANIFEST);

// Estratégia Cache First para assets estáticos (JS, CSS, fontes)
registerRoute(
  ({ request }) => request.destination === 'style' || 
                    request.destination === 'script' || 
                    request.destination === 'font',
  new CacheFirst({
    cacheName: 'static-assets',
    plugins: [
      new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 30 * 24 * 60 * 60 })
    ]
  })
);

// Estratégia Network First para navegação (HTML)
registerRoute(
  ({ request }) => request.mode === 'navigate',
  new NetworkFirst({
    cacheName: 'pages',
    plugins: [
      new ExpirationPlugin({ maxEntries: 5, maxAgeSeconds: 24 * 60 * 60 })
    ]
  })
);

// Estratégia Stale-While-Revalidate para dados de API
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new StaleWhileRevalidate({
    cacheName: 'api-data',
    plugins: [
      new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 5 * 60 })
    ]
  })
);

4. Estratégias de Cache para Aplicações React

Para aplicações React, é crucial definir estratégias específicas para diferentes tipos de recursos:

Cache de assets estáticos: Os bundles gerados pelo React (JS e CSS) mudam a cada build, então podemos usar CacheFirst com nomes únicos (hash).

Cache de dados de API: Para dados dinâmicos, a estratégia NetworkFirst com fallback para cache é ideal. Se a rede falhar, o usuário vê dados em cache:

// Cache de dados da API com fallback offline
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/produtos'),
  new NetworkFirst({
    cacheName: 'produtos-cache',
    networkTimeoutSeconds: 3,
    plugins: [
      new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 60 * 60 })
    ]
  })
);

Atualização dinâmica: Para evitar caches obsoletos, versionamos o cache e limpamos versões antigas durante a ativação do Service Worker:

self.addEventListener('activate', (event) => {
  const cacheWhitelist = ['static-assets-v2', 'pages-v2', 'api-data-v2'];
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {
          if (!cacheWhitelist.includes(cacheName)) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

5. Tornando a App Instalável: Evento beforeinstallprompt

O evento beforeinstallprompt é disparado quando o navegador detecta que a app atende aos critérios de instalabilidade. Podemos interceptá-lo e exibir um botão customizado:

// InstallButton.jsx
import { useState, useEffect } from 'react';

function InstallButton() {
  const [deferredPrompt, setDeferredPrompt] = useState(null);
  const [isInstalled, setIsInstalled] = useState(false);

  useEffect(() => {
    // Verifica se já está instalada
    if (window.matchMedia('(display-mode: standalone)').matches) {
      setIsInstalled(true);
    }

    const handler = (e) => {
      e.preventDefault();
      setDeferredPrompt(e);
    };

    window.addEventListener('beforeinstallprompt', handler);

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

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

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

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

  if (isInstalled) return null;

  return (
    <button onClick={handleInstall} className="install-btn">
      📲 Instalar App
    </button>
  );
}

6. Experiência Offline e Sincronização em Background

Para exibir conteúdo offline de forma elegante, criamos um componente indicador:

// OfflineIndicator.jsx
import { useState, useEffect } from 'react';

function OfflineIndicator() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);

  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);
    };
  }, []);

  if (isOnline) return null;

  return (
    <div className="offline-banner">
      ⚠️ Você está offline. Alguns dados podem não estar disponíveis.
    </div>
  );
}

Para sincronização em background, usamos a API Background Sync. Exemplo prático: enviar um formulário offline:

// Registro do sync no Service Worker
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-formulario') {
    event.waitUntil(sincronizarFormularios());
  }
});

async function sincronizarFormularios() {
  const db = await openDB('formularios-offline', 1);
  const formularios = await db.getAll('pendentes');

  for (const form of formularios) {
    try {
      await fetch('/api/formulario', {
        method: 'POST',
        body: JSON.stringify(form),
        headers: { 'Content-Type': 'application/json' }
      });
      await db.delete('pendentes', form.id);
    } catch (error) {
      console.error('Falha ao sincronizar formulário:', error);
    }
  }
}

7. Testes e Depuração de PWA no Node.js/React

Para garantir que sua PWA está funcionando corretamente, utilize estas ferramentas:

  • Lighthouse: Ferramenta integrada no Chrome DevTools que audita sua PWA e fornece métricas de performance, acessibilidade e boas práticas.
  • DevTools > Application: Painel dedicado para inspecionar Manifest, Service Workers, Cache Storage e IndexedDB.
  • Teste em dispositivos móveis: Use o modo de dispositivo no DevTools ou o ngrok para testar em dispositivos reais via HTTPS.

Para depuração avançada do Service Worker, use postMessage:

// No Service Worker
self.addEventListener('message', (event) => {
  if (event.data === 'ping') {
    event.ports[0].postMessage('pong');
  }
});

// No React
navigator.serviceWorker.controller.postMessage('ping');

8. Boas Práticas e Considerações Finais

Atualização silenciosa: Para evitar que o usuário precise recarregar a página manualmente, use skipWaiting e clients.claim:

self.addEventListener('install', () => {
  self.skipWaiting();
});

self.addEventListener('activate', (event) => {
  event.waitUntil(clients.claim());
});

Tratamento de erros: Sempre forneça feedback amigável quando o usuário estiver offline:

// Componente de fallback para dados offline
function DadosOffline() {
  return (
    <div className="offline-fallback">
      <h3>Sem conexão</h3>
      <p>Você está offline. Mostrando dados em cache.</p>
      <button onClick={() => window.location.reload()}>
        Tentar novamente
      </button>
    </div>
  );
}

Performance: Implemente lazy loading de rotas com React Router para pré-carregar apenas o necessário:

import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

const Home = lazy(() => import('./pages/Home'));
const Produtos = lazy(() => import('./pages/Produtos'));
const Sobre = lazy(() => import('./pages/Sobre'));

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<div>Carregando...</div>}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/produtos" element={<Produtos />} />
          <Route path="/sobre" element={<Sobre />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

Ao seguir estas práticas, sua aplicação React se tornará uma PWA completa, instalável e funcional mesmo offline. Lembre-se de testar em diferentes cenários de rede e dispositivos para garantir a melhor experiência do usuário.


Referências