DEV Community

Cover image for PWA: Criar notificação de "uma nova versão está disponível"
Eduardo Rabelo
Eduardo Rabelo

Posted on

PWA: Criar notificação de "uma nova versão está disponível"

Você já esteve em um site e notou uma notificação que sugere que há uma nova versão do site disponível? Recentemente, visitei a Caixa de entrada do Google e notei uma notificação um pouco como a imagem abaixo:

Já criei vários Progressive Web Apps que simplesmente atualizam o Service Worker silenciosamente para o usuário em segundo plano, porém, eu realmente gosto dessa abordagem - especialmente para um aplicativo planejado para trabalhar offline-first. Se você já tentou criar um aplicativo web completamente offline-first, sabe como pode ser complicado fazer alterações no cache dos usuários quando houver atualizações no site e o usuário tiver conectividade. É aqui que uma notificação pop-up, como a da Caixa de entrada do Google, fornece ao usuário um meio de sempre ter a versão mais recente dos recursos armazenados em cache. Isso me fez pensar como eu poderia construir algo semelhante e, acontece que é um pouco mais complicado do que parece, mas não é impossível!

Neste artigo, mostrarei como adicionar uma notificação ao seu site e exibi-la sempre que houver uma nova versão disponível do seu Service Worker. Você também aprenderá como atualizar a página, para que o usuário esteja atualizado e tenha a versão mais recente de qualquer arquivo em cache. Este artigo é um pouco longo, então aperte os cintos e fique confortável!

Projeto de Exemplo

Neste exemplo, vou usar uma página web bem básica que consiste em três ativos:

  • index.html
  • dog.jpg
  • service-worker.js
$ mkdir exemplo-service-worker
$ cd $_
$ touch index.html
$ touch service-worker.js

Download dog.jpg

Para começar, o HTML da minha página web parece um pouco com o código a seguir:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>PWA - Novo Service Worker disponível</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <style>
      body {
        margin: 0;
      }
      img {
        display: block;
        max-width: 100%;
      }
      #notification {
        background: #444;
        bottom: 8px;
        color: #fff;
        display: none;
        padding: 16px;
        position: absolute;
        right: 8px;
        width: 240px;
      }
      #notification.show {
        display: block;
      }
    </style>
  </head>

  <body>
    <img src="./dog.jpg" />
    <!-- A notificação que iremos mostrar -->
    <div id="notification">
      Uma nova versão está disponível. Clique <a id="reload">aqui</a> para
      atualizar.
    </div>
  </body>
  <script>
    if ("serviceWorker" in navigator) {
      navigator.serviceWorker
        .register("./service-worker.js")
        .then(function(registration) {
          // SUCESSO - ServiceWorker Registrado
          console.log(
            "ServiceWorker registrado com sucesso no escopo: ",
            registration.scope
          );
        })
        .catch(function(err) {
          // ERRO - Falha ao registrar o ServiceWorker
          console.log("Falha ao registrar o ServiceWorker: ", err);
        });
    }
  </script>
</html>

Na página web acima, você pode perceber que adicionei um código HTML padrão e o registro de um Service Worker. Vamos adicionar um pouco de magia agora! No arquivo service-worker.js adicione o seguinte código:

const cacheName = "firstVersion";

self.addEventListener("install", event => {
  event.waitUntil(
    caches.open(cacheName).then(cache => cache.addAll(["./dog.jpg"]))
  );
});

self.addEventListener("fetch", function(event) {
  event.respondWith(
    caches.match(event.request).then(function(response) {
      if (response) {
        return response;
      }
      return fetch(event.request);
    })
  );
});

No código acima, adicionamos a funcionalidade básica de armazenamento em cache ao nosso Service Worker. Depois que ele for instalado, e cada vez que um usuário fizer a solicitação para a imagem dog.jpg, o Service Worker irá buscar do cache e exibir instantaneamente para o usuário. Se você não estiver familiarizado com o código acima, recomendo dar uma olhada neste artigo para obter mais informações. Ele o levará através dos princípios básicos e ajudará você a entender como o armazenamento em cache do Service Worker funciona.

Neste momento, se abrirmos a página da web, ela ficará um pouco parecida com a imagem abaixo:

Até aí tudo bem, mas nós temos uma página web que realmente não faz muita coisa! Para completar as peças do quebra-cabeça, precisamos atualizar nosso código para que ele notifique o usuário quando houver uma mudança no próprio Service Worker. Antes de mergulharmos mais fundo, vamos dar uma olhada no fluxo básico que precisa acontecer:

No diagrama acima, você pode perceber que várias etapas precisam ser realizadas antes de termos um produto atualizado. Em primeiro lugar, o navegador verifica se houve atualização no arquivo do service worker. Se houver uma atualização disponível, mostramos uma notificação na tela, caso contrário, não fazemos nada. Quando o usuário clica na notificação, enviamos uma mensagem para o service worker e informamos que ele pule a espera e se torne o service worker ativo. Quando a instalação for concluída, recarregamos a página e nosso novo service worker está no controle!

Ufa! Finalmente! 🎉😆

Embora possa parecer confuso, ao final deste artigo, o fluxo acima fará um pouco mais de sentido. Vamos pegar o que aprendemos no fluxo acima e aplicar as alterações no código em nossa página web. Vamos fazer as alterações abaixo em nosso arquivo index.html:

...
<script>
let newWorker;

// O evento de clique na notificação
document.getElementById("reload").addEventListener("click", function() {
  newWorker.postMessage({ action: "skipWaiting" });
});

if ("serviceWorker" in navigator) {
  navigator.serviceWorker
    .register("./service-worker.js") // [A]
    .then(function(registration) {
      registration.addEventListener("updatefound", () => { // [B]
        // Uma atualização no Service Worker foi encontrada, instalando...
        newWorker = registration.installing; // [C]

        newWorker.addEventListener("statechange", () => {
          // O estado do Service Worker mudou?
          switch (newWorker.state) {
            case "installed": {
              // Existe um novo Service Worker disponível, mostra a notificação
              if (navigator.serviceWorker.controller) {
                let notification = document.getElementById("notification");
                notification.className = "show";
                break;
              }
            }
          }
        });
      });

      // SUCESSO - ServiceWorker Registrado
      console.log(
        "ServiceWorker registrado com sucesso no escopo: ",
        registration.scope
      );
    })
    .catch(function(err) {
      // ERRO - Falha ao registrar o ServiceWorker
      console.log("Falha ao registrar o ServiceWorker: ", err);
    });
}
</script>
...

Wow! O código na página index.html Cresceu bastante! Vamos dividi-lo passo a passo para entender melhor o fluxo.

Depois de registrar o service worker ([A]), adicionamos um eventListener ao evento .updateFound (‌[B]). Este evento é acionado sempre que a propriedade ServiceWorkerRegistration.installing (‌[C]) adquire um novo Service Worker. Isso determinará se houve alguma alteração no arquivo do service worker e ocorre quando o usuário recarrega ou retorna à página web. O navegador tem uma maneira prática de verificar o conteúdo do arquivo service-worker.js e, mesmo que tenha sido alterado apenas por um byte, ele será tratado como uma nova versão.

Se uma nova versão for descoberta, o evento .updateFound ([B]) será acionado. Se esse evento for disparado, precisaremos verificar se um novo Service Worker foi adquirido e atribuí-lo a uma nova variável ([C]), pois usaremos isso em um estágio posterior.

Agora que determinamos que há um novo service worker esperando para ser instalado, podemos exibir uma notificação na parte inferior de nossa página notificando o usuário de que há uma nova versão disponível:

Se você se lembrar do diagrama no início deste artigo, você se lembrará de que ainda precisamos concluir as etapas 3 e 4 para que o novo Service Worker comece a controlar a página. Para a etapa 3, precisamos adicionar funcionalidades a notificação, para que, quando o usuário clicar em atualizar, nós enviamos um .postMessage() para o nosso Service Worker pular a fase de espera. Lembre-se de que você não pode se comunicar diretamente com um Service Worker do cliente, precisamos usar o método .postMessage() para enviar uma mensagem para ele (seja um Window, Worker ou SharedWorker). A mensagem é recebida no evento "message" no navigator.serviceWorker.

O código dentro do arquivo service-worker.js deve ser atualizado para responder ao evento de mensagem:

self.addEventListener("message", function(event) {
  if (event.data.action === "skipWaiting") {
    self.skipWaiting();
  }
});

Estamos quase lá! Em nossa quarta e última etapa, precisamos que nossa página web recarregue e ative o novo Service Worker. Para fazer isso, precisamos atualizar a página index.html e recarregar a página assim que o evento controllerchange for disparado:

...
<script>
...
let refreshing;

// Esse evento será chamado quando o Service Worker for atualizado
// Aqui estamos recarregando a página
navigator.serviceWorker.addEventListener("controllerchange", function() {
  if (refreshing) {
    return;
  }
  window.location.reload();
  refreshing = true;
});
</script>
...

É isso aí! Agora você tem um exemplo totalmente funcional! 👏😎

O Resultado

Para testar isso em ação, inicie o projeto no seu localhost e navegue para a página index.html. Usando o Google Chrome e DevTools, fica bem fácil testar nosso Service Worker. Abrindo o DevTools, podemos ir até a guia Application e com a opção de menu Service Workers selecionada, você deve observar que o nosso Service Worker está instalado na página atual.

Esse é o resultado que esperávamos, o Service Worker está instalado e controlando a página. Cada vez que atualizamos a página, recuperamos a imagem do cachorro do cache ao invés da rede.

Para simular uma atualização no nosso service worker, farei uma pequena alteração no arquivo service-worker.js:

const cacheName = "secondVersion";

No código acima, simplesmente atualizei o nome do cache para secondVersion. Essa pequena alteração permitirá que o navegador saiba que temos um novo Service Worker para o rock and roll. Ao atualizar a página, veremos a notificação de que há uma versão mais recente disponível. Usando o DevTools do Chrome, posso ver o novo Service Worker esperando para ser ativado; observe que a seção Status agora tem duas versões, cada uma com um status diferente:

Se você clicar no link de atualização na barra de notificações em nossa página web, o novo Service Worker será instalado e controlará a página. Você pode verificar isso no DevTools e indo para o guia Application. Você poderá notar que o novo Service Worker está instalado e controlando a página. Você pode ver isso na imagem abaixo, o número da versão na seção Status:

Conclusão

Usar uma técnica como essa (mostrando uma notificação) é uma ótima maneira de garantir que você mantenha seu Progressive Web App atualizado e com toda a mágica do armazenamento em cache, ao mesmo tempo em que mantém viva a versão mais recente do seu service worker!

Se você gostaria de ver o código completo para este exemplo, por favor, dirija-se ao repositório em github.com/deanhume/pwa-update-available.

Eu tenho que admitir, levei um tempo para descobrir tudo isso, e eu não poderia ter feito isso sem os artigos a seguir. Eu recomendo a leitura caso você queira aprender mais:

Créditos ⭐️

Top comments (3)

Collapse
 
josealcione profile image
josealcione

Exatamente o que eu procurava.
Obrigado

Collapse
 
lgdelai profile image
GUILHERME DELAI ⚡

Olá. Parabéns pelo artigo.

No inicio você fala sobre atualizar o pwa silenciosamente.

Pode explicar como é feito este procedimento?

Collapse
 
oieduardorabelo profile image
Eduardo Rabelo

fala Guilherme, uma atualização do Service Worker é acionada automaticamente pelo navegador assim que o arquivo de sw for alterado,

Podemos reunir alguns outros motivos também:

  • Um evento de navegação para uma página dentro do escopo de um sw
  • Eventos funcionais como push ou sync ou uma verificação de atualização do arquivo de sw (pelo navegador) nas últimas 24 horas
  • Chamando .register(), no caso da URL do sw ter sido alterada, você deve evitar mudar a url do seu sw em caso de trabalho offline (ex: index.html registra sw-v1.js como sw, o sw faz o cache do index.html para trabalhar offline, você atualiza o index.html para usar sw-v2.js, o usuário nunca receberá o sw-v2.js)

Quando uma atualização acontece, o comportamento padrão é:

  • O novo sw é executado pelo navegador ao lado do sw atual, com seu próprio evento install, rodando em um novo contexto
  • Caso o novo sw não esteja ok, tenha falha de sintaxe, algum 404 ou jogue um erro, a instalação é rejeitada e o novo sw é removido, mas o atual ainda continua controlando a página
  • Caso o novo sw tenha sucesso em sua instalação, ele irá aguardar até que o sw atual não esteja controlando nenhum cliente, ou seja, todos as tabs, navegadores, sessões usando o sw atual tem que ser fechadas (nesse período, o cliente/navegador/tab terá um overlap com os DOIS sw na página)
  • podemos usar o self.skipWaiting() para remover essa espera e assumir o controle da página assim que o sw for instalado

vou me concentrar no arquivo de sw nos exemplos a seguir,

vamos supor que instalamos o seguinte sw:

self.addEventListener('install', event => {
  console.log('instalando sw-v1');

  // - criamos um cache 'static-v1'
  // - adicionamos 'banner.jpg' ao cache
  event.waitUntil(
    caches.open('static-v1').then(cache => cache.add('/banner.jpg'))
  );
});

self.addEventListener('activate', event => {
  console.log('o sw-v1  está pronto para interceptar eventos e requests');
});

self.addEventListener('fetch', event => {
  const url = new URL(event.request.url);

  // response com 'banner.jpg' do cache se o request for
  // da mesma origin e o caminho ser '/hero.jpg'
  if (url.origin == location.origin && url.pathname == '/hero.jpg') {
    event.respondWith(caches.match('/banner.jpg'));
  }
});

agora, vamos atualizar o sw para um nova versão, lembrando, que "atualizar um sw" é basicamente mudar qualquer byte dentro do arquivo registrado como sw,

self.addEventListener('install', event => {
  console.log('instalando sw-v2');

  // - criamos um cache 'static-v2'
  // - adicionamos 'novo-banner.jpg' ao cache
  // - adicionamos 'self.skipWaiting()' para instalar a nova versão imediatamente
  event.waitUntil(
    caches.open('static-v2')
    .then(cache => cache.add('/novo-banner.jpg'))
  )
  .then(() => self.skipWaiting())
});

self.addEventListener('activate', event => {
  console.log('o sw-v2  está pronto para interceptar eventos e requests');
});

self.addEventListener('fetch', event => {
  const url = new URL(event.request.url);

  // response com 'novo-banner.jpg' do cache se o request for
  // da mesma origin e o caminho ser '/hero.jpg'
  if (url.origin == location.origin && url.pathname == '/hero.jpg') {
    event.respondWith(caches.match('/novo-banner.jpg'));
  }
});

Você pode perceber o uso do self.skipWaiting() na fase de instalação, isso irá silenciosamente dar controle da página para o novo sw, resultando na "atualização silenciosa" mencionada aqui.

Olhando o artigo novamente, você pode ver que enviamos um evento para o sw do frontend em:

// ... um pedaço do exemplo completo do artigo
// o evento "stagechange" irá ser disparado pelo navegador, a gente não controla isso
newWorker.addEventListener("statechange", () => {
  // O estado do Service Worker mudou?
  switch (newWorker.state) {
    case "installed": {
      // Existe um novo Service Worker disponível, mostra a notificação
      if (navigator.serviceWorker.controller) {
        let notification = document.getElementById("notification");
        notification.className = "show";
        break;
      }
    }
  }
});

// na notifação, enviamos um evento para o sw
document.getElementById("reload").addEventListener("click", function() {
  newWorker.postMessage({ action: "skipWaiting" });
});

// no sw, se o usuário clicar na mensagem do banner, ativamos o novo sw imediatamente
self.addEventListener("message", function(event) {
  if (event.data.action === "skipWaiting") {
    self.skipWaiting();
  }
});

Desse modo, o cliente controla se ele deve atualizar no momento que a detecção de uma nova versão do sw PELO navegador acontece.

A atualização irá acontecer de qualquer modo, se houver um novo sw, o navegador irá atualizar o cliente com base nas regras descritas no começo desse comentário.

Se tiver mais dúvidas só mandar!