Técnicas comuns para criar aplicativos off-line

Jake Archibald
Jake Archibald

Com os Service Workers, oferecemos aos desenvolvedores uma maneira de resolver problemas de conexão de rede. Você tem controle sobre o armazenamento em cache e como as solicitações são processadas. Isso significa que você pode criar seus próprios padrões. Confira alguns padrões possíveis isolados, mas na prática, você provavelmente vai usá-los em conjunto, dependendo do URL e do contexto.

Para uma demonstração prática de alguns desses padrões, consulte Trained-to-thrill.

Quando armazenar recursos

Browser Support

  • Chrome: 40.
  • Edge: 17.
  • Firefox: 44.
  • Safari: 11.1.

Source

Os service workers permitem processar solicitações de forma independente do armazenamento em cache. Por isso, vou demonstrá-los separadamente. Primeiro, determine quando você deve usar o cache.

Na instalação, como uma dependência

Na instalação, como uma dependência.

A API Service Worker oferece um evento install. Você pode usar isso para preparar coisas que precisam estar prontas antes de você processar outros eventos. Durante o install, as versões anteriores do service worker continuam sendo executadas e veiculando páginas. O que você fizer não deve interromper o service worker atual.

Ideal para: CSS, imagens, fontes, JS, modelos ou qualquer outra coisa que você considere estática para essa versão do seu site.

Busque os elementos que deixariam seu site completamente inoperante se não fossem buscados, como um app equivalente específico da plataforma faria parte do download inicial.

self.addEventListener('install', function (event) {
  event.waitUntil(
    caches.open('mysite-static-v3').then(function (cache) {
      return cache.addAll([
        '/css/whatever-v3.css',
        '/css/imgs/sprites-v6.png',
        '/css/fonts/whatever-v8.woff',
        '/js/all-min-v4.js',
        // etc.
      ]);
    }),
  );
});

event.waitUntil usa uma promessa para definir a duração e o sucesso da instalação. Se a promessa for rejeitada, a instalação será considerada uma falha e o service worker será abandonado. Se uma versão mais antiga estiver em execução, ela será deixada intacta. caches.open() e cache.addAll() retornam promessas. Se algum dos recursos não for buscado, a chamada cache.addAll() será rejeitada.

No trained-to-thrill, uso isso para armazenar recursos estáticos em cache.

Na instalação, não como uma dependência

Na instalação, não como uma dependência.

Isso é semelhante à instalação como uma dependência, mas não atrasa a conclusão da instalação e não causa falha na instalação se o armazenamento em cache falhar.

Ideal para: recursos maiores que não são necessários imediatamente, como recursos para níveis posteriores de um jogo.

self.addEventListener('install', function (event) {
  event.waitUntil(
    caches.open('mygame-core-v1').then(function (cache) {
      cache
        .addAll
        // levels 11-20
        ();
      return cache
        .addAll
        // core assets and levels 1-10
        ();
    }),
  );
});

Este exemplo não transmite a promessa cache.addAll para os níveis de 11 a 20 de volta para event.waitUntil. Portanto, mesmo que falhe, o jogo ainda estará disponível off-line. É claro que você terá que lidar com a possível ausência desses níveis e tentar armazená-los em cache novamente se eles estiverem faltando.

O service worker pode ser encerrado enquanto os níveis de 11 a 20 são baixados, já que ele terminou de processar eventos. Isso significa que eles não serão armazenados em cache. A API Web Periodic Background Synchronization pode lidar com casos como esse e downloads maiores, como filmes.

Browser Support

  • Chrome: 40.
  • Edge: 17.
  • Firefox: 44.
  • Safari: 11.1.

Source

Ao ativar

Na ativação.

Ideal para: limpeza e migração.

Quando um novo service worker é instalado e uma versão anterior não está sendo usada, o novo é ativado, e você recebe um evento activate. Como a versão anterior está fora do caminho, é um bom momento para lidar com migrações de esquema no IndexedDB e também excluir caches não utilizados.

self.addEventListener('activate', function (event) {
  event.waitUntil(
    caches.keys().then(function (cacheNames) {
      return Promise.all(
        cacheNames
          .filter(function (cacheName) {
            // Return true if you want to remove this cache,
            // but remember that caches are shared across
            // the whole origin
          })
          .map(function (cacheName) {
            return caches.delete(cacheName);
          }),
      );
    }),
  );
});

Durante a ativação, eventos como fetch são colocados em uma fila. Portanto, uma ativação longa pode bloquear o carregamento da página. Mantenha a ativação o mais simples possível e use-a apenas para coisas que você não podia fazer enquanto a versão anterior estava ativa.

No trained-to-thrill, uso isso para remover caches antigos.

Na interação do usuário

Na interação do usuário.

Ideal para: quando não é possível deixar o site inteiro off-line e você escolhe permitir que o usuário selecione o conteúdo que quer disponibilizar off-line. Por exemplo, um vídeo no YouTube, um artigo na Wikipédia ou uma galeria específica no Flickr.

Ofereça ao usuário um botão "Ler mais tarde" ou "Salvar para acesso off-line". Quando ele é clicado, busca o que você precisa da rede e coloca no cache.

document.querySelector('.cache-article').addEventListener('click', function (event) {
  event.preventDefault();

  var id = this.dataset.articleId;
  caches.open('mysite-article-' + id).then(function (cache) {
    fetch('/get-article-urls?id=' + id)
      .then(function (response) {
        // /get-article-urls returns a JSON-encoded array of
        // resource URLs that a given article depends on
        return response.json();
      })
      .then(function (urls) {
        cache.addAll(urls);
      });
  });
});

A API Cache está disponível em páginas e service workers, o que significa que você pode adicionar ao cache diretamente da página.

Browser Support

  • Chrome: 40.
  • Edge: 16.
  • Firefox: 41.
  • Safari: 11.1.

Source

Na resposta da rede

Na resposta da rede.

Ideal para: atualizar recursos com frequência, como a caixa de entrada de um usuário ou o conteúdo de um artigo. Também é útil para conteúdo não essencial, como avatares, mas é preciso ter cuidado.

Se uma solicitação não corresponder a nada no cache, ela será buscada na rede, enviada para a página e adicionada ao cache ao mesmo tempo.

Se você fizer isso para um intervalo de URLs, como avatares, tome cuidado para não aumentar o armazenamento da origem. Se o usuário precisar recuperar espaço em disco, você não vai querer ser o principal candidato. Remova os itens do cache que você não precisa mais.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.open('mysite-dynamic').then(function (cache) {
      return cache.match(event.request).then(function (response) {
        return (
          response ||
          fetch(event.request).then(function (response) {
            cache.put(event.request, response.clone());
            return response;
          })
        );
      });
    }),
  );
});

Para permitir o uso eficiente da memória, só é possível ler o corpo de uma resposta/solicitação uma vez. A amostra de código usa .clone() (link em inglês) para criar cópias adicionais que podem ser lidas separadamente.

No trained-to-thrill, uso isso para armazenar imagens do Flickr em cache.

Stale-while-revalidate

Stale-while-revalidate.

Ideal para: recursos atualizados com frequência em que ter a versão mais recente não é essencial. Os avatares podem se enquadrar nessa categoria.

Se houver uma versão em cache disponível, use-a, mas busque uma atualização para a próxima vez.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.open('mysite-dynamic').then(function (cache) {
      return cache.match(event.request).then(function (response) {
        var fetchPromise = fetch(event.request).then(function (networkResponse) {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
        return response || fetchPromise;
      });
    }),
  );
});

Isso é muito semelhante ao stale-while-revalidate do HTTP.

Na mensagem push

Na mensagem push.

A API Push é outro recurso criado com base no service worker. Isso permite que o service worker seja ativado em resposta a uma mensagem do serviço de mensagens do SO. Isso acontece mesmo quando o usuário não tem uma guia aberta no seu site. Apenas o service worker é ativado. Você solicita permissão para fazer isso em uma página, e o usuário recebe uma solicitação.

Ideal para: conteúdo relacionado a uma notificação, como uma mensagem de chat, uma notícia de última hora ou um e-mail. Além de conteúdo que muda com pouca frequência e se beneficia da sincronização imediata, como uma atualização de lista de tarefas ou uma alteração de agenda.

O resultado final comum é uma notificação que, quando tocada, abre e foca uma página relevante, e para a qual a atualização de caches antecipada é extremamente importante. O usuário está on-line no momento em que recebe a mensagem push, mas pode não estar quando interage com a notificação. Por isso, é fundamental disponibilizar esse conteúdo off-line.

Este código atualiza os caches antes de mostrar uma notificação:

self.addEventListener('push', function (event) {
  if (event.data.text() == 'new-email') {
    event.waitUntil(
      caches
        .open('mysite-dynamic')
        .then(function (cache) {
          return fetch('/inbox.json').then(function (response) {
            cache.put('/inbox.json', response.clone());
            return response.json();
          });
        })
        .then(function (emails) {
          registration.showNotification('New email', {
            body: 'From ' + emails[0].from.name,
            tag: 'new-email',
          });
        }),
    );
  }
});

self.addEventListener('notificationclick', function (event) {
  if (event.notification.tag == 'new-email') {
    // Assume that all of the resources needed to render
    // /inbox/ have previously been cached, e.g. as part
    // of the install handler.
    new WindowClient('/inbox/');
  }
});

On background-sync

Em background-sync.

A sincronização em segundo plano é outro recurso criado com base no service worker. Ele permite solicitar a sincronização de dados em segundo plano uma única vez ou em um intervalo (extremamente heurístico). Isso acontece mesmo quando o usuário não tem uma guia aberta no seu site. Apenas o service worker é ativado. Você pede permissão para fazer isso em uma página, e o usuário recebe uma solicitação.

Ideal para: atualizações não urgentes, especialmente aquelas que acontecem com tanta frequência que uma mensagem push por atualização seria muito frequente para os usuários, como linhas do tempo de redes sociais ou artigos de notícias.

self.addEventListener('sync', function (event) {
  if (event.id == 'update-leaderboard') {
    event.waitUntil(
      caches.open('mygame-dynamic').then(function (cache) {
        return cache.add('/leaderboard.json');
      }),
    );
  }
});

Persistência de cache

Sua origem recebe uma certa quantidade de espaço livre para fazer o que quiser. Esse espaço livre é compartilhado entre todo o armazenamento de origem: armazenamento(local), IndexedDB, acesso ao sistema de arquivos e, claro, caches.

O valor recebido não é especificado. Isso varia de acordo com o dispositivo e as condições de armazenamento. Para saber quanto você tem, use:

if (navigator.storage && navigator.storage.estimate) {
  const quota = await navigator.storage.estimate();
  // quota.usage -> Number of bytes used.
  // quota.quota -> Maximum number of bytes available.
  const percentageUsed = (quota.usage / quota.quota) * 100;
  console.log(`You've used ${percentageUsed}% of the available storage.`);
  const remaining = quota.quota - quota.usage;
  console.log(`You can write up to ${remaining} more bytes.`);
}

No entanto, como todo armazenamento do navegador, ele pode descartar seus dados se o dispositivo ficar sob pressão de armazenamento. Infelizmente, o navegador não consegue diferenciar os filmes que você quer guardar a todo custo e o jogo que não é tão importante assim.

Para contornar isso, use a interface StorageManager:

// From a page:
navigator.storage.persist()
.then(function(persisted) {
  if (persisted) {
    // Hurrah, your data is here to stay!
  } else {
   // So sad, your data may get chucked. Sorry.
});

É claro que o usuário precisa conceder permissão. Para isso, use a API Permissions.

É importante que o usuário faça parte desse fluxo, já que agora ele pode controlar a exclusão. Se o dispositivo estiver com pouco espaço de armazenamento e a limpeza de dados não essenciais não resolver o problema, o usuário poderá decidir quais itens manter e remover.

Para isso funcionar, é necessário que os sistemas operacionais tratem as origens "duráveis" como equivalentes a apps específicos da plataforma nas análises detalhadas de uso do armazenamento, em vez de informar o navegador como um único item.

Veiculação de sugestões

Não importa o quanto você faça de cache, o service worker só usa o cache quando você diz a ele quando e como. Confira alguns padrões para processar solicitações:

Somente cache

Somente cache.

Ideal para: qualquer coisa que você considere estática para uma determinada "versão" do seu site. Você deve ter armazenado em cache esses recursos no evento de instalação, então pode contar com eles.

self.addEventListener('fetch', function (event) {
  // If a match isn't found in the cache, the response
  // will look like a connection error
  event.respondWith(caches.match(event.request));
});

…embora você não precise lidar com esse caso especificamente com frequência, a memória cache, com fallback para a rede, o cobre.

Somente rede

Somente rede.

Ideal para: itens que não têm um equivalente off-line, como pings de análise e solicitações que não são GET.

self.addEventListener('fetch', function (event) {
  event.respondWith(fetch(event.request));
  // or don't call event.respondWith, which
  // will result in default browser behavior
});

…embora você não precise lidar com esse caso especificamente com frequência, o cache, com fallback para a rede, o cobre.

Cache, voltando para a rede

Cache, voltando para a rede.

Ideal para: criar apps que priorizam o modo off-line. Nesses casos, é assim que você vai lidar com a maioria das solicitações. Outros padrões são exceções com base na solicitação recebida.

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

Isso oferece o comportamento "somente cache" para itens no cache e o comportamento "somente rede" para tudo que não está em cache, incluindo todas as solicitações não GET, já que elas não podem ser armazenadas em cache.

Disputa de cache e rede

Disputa entre cache e rede
.

Ideal para: recursos pequenos em que você busca desempenho em dispositivos com acesso lento ao disco.

Com algumas combinações de discos rígidos mais antigos, verificadores de vírus e conexões de Internet mais rápidas, obter recursos da rede pode ser mais rápido do que acessar o disco. No entanto, acessar a rede quando o usuário tem o conteúdo no dispositivo pode ser um desperdício de dados.

// Promise.race rejects when a promise rejects before fulfilling.
// To make a race function:
function promiseAny(promises) {
  return new Promise((resolve, reject) => {
    // make sure promises are all promises
    promises = promises.map((p) => Promise.resolve(p));
    // resolve this promise as soon as one resolves
    promises.forEach((p) => p.then(resolve));
    // reject if all promises reject
    promises.reduce((a, b) => a.catch(() => b)).catch(() => reject(Error('All failed')));
  });
}

self.addEventListener('fetch', function (event) {
  event.respondWith(promiseAny([caches.match(event.request), fetch(event.request)]));
});

A rede está voltando ao cache

A rede está voltando ao cache.

Ideal para: uma correção rápida para recursos que são atualizados com frequência, fora da "versão" do site. Por exemplo, artigos, avatares, linhas do tempo de redes sociais e rankings de jogos.

Isso significa que os usuários on-line recebem o conteúdo mais atualizado, mas os usuários off-line recebem uma versão mais antiga em cache. Se a solicitação de rede for bem-sucedida, provavelmente será necessário atualizar a entrada de cache.

No entanto, esse método tem falhas. Se a conexão do usuário for intermitente ou lenta, ele terá que esperar a rede falhar antes de receber o conteúdo perfeitamente aceitável que já está no dispositivo. Isso pode levar muito tempo e é uma experiência frustrante para o usuário. Consulte o próximo padrão, Cache e rede, para uma solução melhor.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    fetch(event.request).catch(function () {
      return caches.match(event.request);
    }),
  );
});

Cache e rede

Cache e rede.

Ideal para: conteúdo atualizado com frequência. Por exemplo, artigos, linhas do tempo de redes sociais e jogos. rankings.

Isso exige que a página faça duas solicitações, uma para o cache e outra para a rede. A ideia é mostrar primeiro os dados armazenados em cache e depois atualizar a página quando e se os dados da rede chegarem.

Às vezes, você pode simplesmente substituir os dados atuais quando novos dados chegam (como um ranking de jogos), mas isso pode ser prejudicial com conteúdos maiores. Basicamente, não faça algo "desaparecer" enquanto o usuário está lendo ou interagindo com ele.

O Twitter adiciona o novo conteúdo acima do antigo e ajusta a posição de rolagem para que o usuário não seja interrompido. Isso é possível porque o Twitter mantém uma ordem quase linear para o conteúdo. Copiei esse padrão para trained-to-thrill para mostrar conteúdo na tela o mais rápido possível, enquanto exibo conteúdo atualizado assim que ele chega.

Código na página:

var networkDataReceived = false;

startSpinner();

// fetch fresh data
var networkUpdate = fetch('/data.json')
  .then(function (response) {
    return response.json();
  })
  .then(function (data) {
    networkDataReceived = true;
    updatePage(data);
  });

// fetch cached data
caches
  .match('/data.json')
  .then(function (response) {
    if (!response) throw Error('No data');
    return response.json();
  })
  .then(function (data) {
    // don't overwrite newer network data
    if (!networkDataReceived) {
      updatePage(data);
    }
  })
  .catch(function () {
    // we didn't get cached data, the network is our last hope:
    return networkUpdate;
  })
  .catch(showErrorMessage)
  .then(stopSpinner);

Código no service worker:

Sempre acesse a rede e atualize um cache conforme avança.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.open('mysite-dynamic').then(function (cache) {
      return fetch(event.request).then(function (response) {
        cache.put(event.request, response.clone());
        return response;
      });
    }),
  );
});

Em trained-to-thrill, contornei isso usando XHR em vez de busca e abusando do cabeçalho "Accept" para informar ao service worker de onde buscar o resultado (código da página, código do service worker).

Substituição genérica

Fallback genérico.

Se você não conseguir veicular algo do cache ou da rede, forneça um fallback genérico.

Ideal para: imagens secundárias, como avatares, solicitações POST com falha e uma página "Indisponível off-line".

self.addEventListener('fetch', function (event) {
  event.respondWith(
    // Try the cache
    caches
      .match(event.request)
      .then(function (response) {
        // Fall back to network
        return response || fetch(event.request);
      })
      .catch(function () {
        // If both fail, show a generic fallback:
        return caches.match('/offline.html');
        // However, in reality you'd have many different
        // fallbacks, depending on URL and headers.
        // Eg, a fallback silhouette image for avatars.
      }),
  );
});

O item de substituição provavelmente será uma dependência de instalação.

Se a página estiver postando um e-mail, o service worker poderá armazenar o e-mail em uma caixa de saída do IndexedDB e responder informando à página que o envio falhou, mas os dados foram mantidos com sucesso.

Criação de modelos do lado do service worker

Criação de modelos do lado do service worker.

Ideal para: páginas que não podem ter a resposta do servidor armazenada em cache.

É mais rápido renderizar páginas no servidor, mas isso pode significar incluir dados de estado que não fazem sentido em um cache, como o estado de login. Se a página for controlada por um service worker, você poderá solicitar dados JSON com um modelo e renderizar isso.

importScripts('templating-engine.js');

self.addEventListener('fetch', function (event) {
  var requestURL = new URL(event.request.url);

  event.respondWith(
    Promise.all([
      caches.match('/article-template.html').then(function (response) {
        return response.text();
      }),
      caches.match(requestURL.path + '.json').then(function (response) {
        return response.json();
      }),
    ]).then(function (responses) {
      var template = responses[0];
      var data = responses[1];

      return new Response(renderTemplate(template, data), {
        headers: {
          'Content-Type': 'text/html',
        },
      });
    }),
  );
});

Junte tudo

Você não está limitado a um desses métodos. Na verdade, você provavelmente vai usar muitos deles, dependendo do URL da solicitação. Por exemplo, trained-to-thrill usa:

Basta analisar a solicitação e decidir o que fazer:

self.addEventListener('fetch', function (event) {
  // Parse the URL:
  var requestURL = new URL(event.request.url);

  // Handle requests to a particular host specifically
  if (requestURL.hostname == 'api.example.com') {
    event.respondWith(/* some combination of patterns */);
    return;
  }
  // Routing for local URLs
  if (requestURL.origin == location.origin) {
    // Handle article URLs
    if (/^\/article\//.test(requestURL.pathname)) {
      event.respondWith(/* some other combination of patterns */);
      return;
    }
    if (/\.webp$/.test(requestURL.pathname)) {
      event.respondWith(/* some other combination of patterns */);
      return;
    }
    if (request.method == 'POST') {
      event.respondWith(/* some other combination of patterns */);
      return;
    }
    if (/cheese/.test(requestURL.pathname)) {
      event.respondWith(
        new Response('Flagrant cheese error', {
          status: 512,
        }),
      );
      return;
    }
  }

  // A sensible default pattern
  event.respondWith(
    caches.match(event.request).then(function (response) {
      return response || fetch(event.request);
    }),
  );
});

Leitura adicional

Créditos

Para os ícones adoráveis:

E obrigado a Jeff Posnick por detectar muitos erros gritantes antes de eu clicar em "Publicar".