Livro de receitas off-line

Jake Archibald
Jake Archibald

Com o Service Worker, desistimos de tentar resolver a questão do off-line e disponibilizamos aos desenvolvedores as peças necessárias para que eles mesmos resolvessem essa questão. Ele oferece controle sobre o armazenamento em cache e como as solicitações são processadas. Isso significa que você mesmo cria os próprios padrões. Vamos examinar isoladamente alguns padrões possíveis, mas, na prática, você provavelmente vai usar vários deles em conjunto, dependendo do URL e do contexto.

Para uma demonstração funcional de alguns desses padrões, consulte Trained-to-thrill e este vídeo mostrando o impacto sobre o desempenho.

A máquina de cache: quando armazenar recursos

O Service Worker permite processar solicitações de forma independente do cache. Vou demonstrar isso separadamente. Para começar, armazenamento em cache: quando deve ser feito?

Na instalação, como uma dependência

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

O Service Worker oferece um evento install. Você pode usar isso para preparar o que precisa estar pronto antes de processar outros eventos. Enquanto isso acontece, todas as versões anteriores do Service Worker ainda estão executando e exibindo páginas. Portanto, o que você fizer agora não pode interferir com essas atividades.

Ideal para:CSS, imagens, fontes, JS, modelos… basicamente, tudo que você considere estático para essa "versão" do site.

São os itens que, em caso de falha de acesso, interromperiam totalmente o site, itens que um app equivalente específico da plataforma incluiria como 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 faz 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 esse service worker será abandonado (se uma versão mais antiga estiver em execução, ela vai ser deixada intacta). caches.open() e cache.addAll() retornam promessas. Se a recuperação de um dos recursos falhar, a chamada cache.addAll() será rejeitada.

Em 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.
Na instalação, não como uma dependência.

Semelhante à anterior, mas sem retardar a conclusão da instalação e sem provocar erros na instalação em caso de falha no armazenamento em cache.

Ideal para:recursos maiores que não são imediatamente necessários, 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
       
();
   
}),
 
);
});

O exemplo acima não transmite a promessa cache.addAll para os níveis 11 a 20 de volta para event.waitUntil. Portanto, mesmo que falhe, o jogo ainda estará disponível off-line. Naturalmente, você terá de considerar a possível ausência desses níveis e tentar armazená-los em cache de novo se estiverem ausentes.

O Service Worker pode ser encerrado durante o download dos níveis 11 a 20, porque já concluiu o processamento dos eventos, o que significa que eles não serão armazenados em cache. No futuro, a API Periodic Background Sync da Web vai processar casos como esse e downloads maiores, como filmes. No momento, essa API só tem suporte em bifurcações do Chromium.

Ao ativar

Ao ativar.
Ao ativar.

Ideal para: limpeza e migração.

Depois que um novo Service Worker for instalado e nenhuma versão anterior estiver sendo usada, o novo será ativado e você receberá um evento activate. Como a versão antiga não está ativa, é um bom momento para processar 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, outros eventos, como fetch, são colocados em uma fila. Portanto, uma ativação longa pode bloquear o carregamento de páginas. Mantenha a ativação o mais simples possível e use-a apenas para atividades que não podem ser feitas enquanto a versão antiga estava ativa.

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

Na interação do usuário

Na interação do usuário.
Na interação do usuário.

Ideal para:quando não é possível que o site todo fique off-line e você escolheu permitir que o usuário selecione o conteúdo que quer disponibilizar off-line. Por exemplo, um vídeo sobre um tópico no YouTube, um artigo na Wikipédia, uma determinada galeria no Flickr.

Ofereça ao usuário um botão "Ler mais tarde" ou "Salvar para uso off-line". Quando clicado, recupere o que for necessário da rede e armazene-o 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 caches está disponível em páginas e em service workers. Isso significa que você pode adicionar ao cache diretamente da página.

Na resposta da rede

Na resposta da rede.
Na resposta da rede.

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

Se um item solicitado não estiver no cache, busque-o na rede, envie-o para a página e adicione-o ao cache ao mesmo tempo.

Se você fizer isso para um grupo de URLs, como avatares, será necessário tomar cuidado para não ocupar excessivamente a memória da origem. Se o usuário precisar recuperar espaço em disco, não seja o candidato principal. Não deixe de descartar itens desnecessários do cache.

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 possibilitar o uso eficiente de memória, você pode ler um corpo de resposta/solicitação apenas uma vez. O código acima usa .clone() para criar cópias adicionais que podem ser lidas separadamente.

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

Stale-while-revalidate

Stale-while-revalidate.
Stale-while-revalidate.

Ideal para:atualizar recursos com frequência quando não é essencial ter a versão mais recente. Os avatares podem estar nessa categoria.

Se houver uma versão armazenada em cache disponível, use-a, mas recupere 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.
Na mensagem push.

A API Push é outro recurso baseado no service worker. Isso permite que o service worker seja despertado em resposta a uma mensagem do serviço de mensagens do SO. Isso acontece mesmo se o usuário não tiver uma guia aberta no site. Somente o service worker é ativado. Você solicita permissão para fazer isso em uma página, e o usuário será notificado.

Ideal para:conteúdo relacionado a uma notificação, como uma mensagem de chat, uma notícia recente ou um e-mail. Também se aplica a conteúdo com poucas mudanças que se beneficie com uma sincronização imediata, como uma atualização de lista de tarefas ou uma alteração de calendário.

O resultado final comum é uma notificação que, quando tocada, abre/foca uma página relevante. No entanto, a atualização dos caches antes que isso aconteça é extremamente importante. Obviamente, o usuário está on-line no momento de receber a mensagem push, mas poderá não estar quando finalmente interagir com a notificação. Portanto, é importante 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/');
 
}
});

Na sincronização em segundo plano

Na sincronização em segundo plano.
Na sincronização em segundo plano.

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

Ideal para:atualizações não urgentes, principalmente aquelas que ocorrem com tanta frequência que uma mensagem push por atualização seria muito frequente para os usuários, como linhas do tempo 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 do cache

Sua origem recebe uma determinada quantidade de espaço livre para usar como achar melhor. Esse espaço livre é compartilhado entre todos os armazenamentos de origem: armazenamento(local), IndexedDB, acesso ao sistema de arquivos e, claro, caches.

O valor recebido não é especificado. Ela varia de acordo com o dispositivo e as condições de armazenamento. Você pode descobrir quanto você recebeu por meio de:

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 o armazenamento do navegador, esse tem liberdade de descartar seus dados se o dispositivo ficar com pouco armazenamento disponível. O navegador não consegue distinguir entre os filmes que você quer manter a qualquer custo e o jogo com o qual você não se preocupa muito.

Para contornar esse problema, 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.
});

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

Fazer o usuário participar desse fluxo é importante, já que agora podemos esperar que ele controle a exclusão. Se o dispositivo do usuário estiver sob pressão e limpar dados não essenciais não resolver, ele poderá determinar quais itens manter e remover.

Para que isso funcione, é necessário que os sistemas operacionais tratem origens "duráveis" como equivalentes a apps específicos da plataforma no detalhamento do uso de armazenamento, em vez de relatar o navegador como um único item.

Sugestões de veiculação: como responder a solicitações

Não importa a quantidade de armazenamento em cache, o service worker não vai usar o cache a menos que você informe quando e como fazer isso. Confira alguns padrões para processar solicitações:

Somente cache

Somente cache.
Somente cache.

Ideal para:tudo que você considere estático para uma determinada "versão" do site. Você deve ter armazenado em cache esses itens no evento de instalação. Portanto, pode confiar que eles estarão lá.

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ê nem sempre precise processar esse caso especificamente, Cache, fallback para rede fará isso.

Somente rede

Somente rede.
Somente na rede.

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

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

… embora você nem sempre precise processar esse caso especificamente, Cache, fallback para rede fará isso.

Cache, fallback para rede

Cache, fallback para rede.
Cache, fallback para rede.

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

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

Com isso, você terá o comportamento "somente cache" para os itens do cache e o comportamento "somente rede" para qualquer item não armazenado em cache (incluindo todas as solicitações diferentes de GET, porque elas não podem ser armazenadas em cache).

Corrida entre cache e rede

Corrida entre cache e rede.
Corrida entre cache e rede.

Ideal para:recursos pequenos quando você procura desempenho em dispositivos com acesso a disco lento.

Em algumas combinações de discos rígidos mais antigos, antivírus e conexões mais rápidas com a Internet, buscar 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. Portanto, considere isso.

// Promise.race is no good to us because it rejects if
// a promise rejects before fulfilling. Let's make a proper
// 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)]));
});

Rede com fallback para cache

A rede está com fallback para cache.
A rede está com fallback para o cache.

Ideal para:uma solução rápida para recursos com atualização frequente, fora da "versão" do site. Por exemplo, artigos, avatares, linhas do tempo de mídia social e placares de jogos.

Isso significa que você oferece aos usuários on-line o conteúdo mais atualizado, mas os usuários off-line recebem uma versão anterior armazenada em cache. Se a solicitação de rede for bem-sucedida, provavelmente você vai querer atualizar a entrada do cache.

No entanto, esse método tem falhas. Se o usuário tiver uma conexão intermitente ou lenta, ele terá de esperar por uma falha de rede até receber o conteúdo perfeitamente aceitável que já está no dispositivo. Isso pode demorar muito e ser uma experiência do usuário frustrante. Confira o próximo padrão, Cache, depois rede, para uma solução melhor.

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

Cache, depois rede

Cache e depois rede.
Armazene em cache e depois na rede.

Ideal para:conteúdo atualizado com frequência. Por exemplo, artigos, linhas do tempo de mídia social e placares de jogos.

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/se os dados da rede chegarem.

Às vezes, é possível simplesmente substituir os dados atuais quando novos dados chegam (por exemplo, placar de jogos), mas isso pode ser perturbador com fragmentos de conteúdo maiores. Basicamente, não "desapareça" com algo que o usuário pode estar lendo ou com o que ele pode estar interagindo.

O Twitter adiciona o novo conteúdo sobre o conteú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 na maioria das vezes uma ordem basicamente linear do conteúdo. Copiei esse padrão para trained-to-thrill para colocar o conteúdo na tela o mais rápido possível e mostrar o 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:

Você deve sempre voltar à rede e atualizar o cache.

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 esse problema usando XHR em vez de fetch e abusando do cabeçalho Accept para informar o Service Worker de onde buscar o resultado (código da página, código do Service Worker).

Fallback genérico

Fallback genérico.
Fallback genérico.

Se ocorrer uma falha na disponibilização de algum item do cache e/ou da rede, você poderá oferecer um fallback genérico.

Ideal para:imagens secundárias, como avatares, falha em solicitações POST e uma página "Não disponível em modo 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 para o qual você fizer fallback será provavelmente uma dependência de instalação.

Se a página estiver enviando um e-mail, o service worker poderá fazer fallback para 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 retidos.

Modelos do lado do service worker

Modelos do lado do Service Worker.
Modelos do lado do ServiceWorker.

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

A renderização de páginas no servidor acelera tudo, mas isso pode significar a inclusão de dados de estado que podem não fazer sentido em um cache, por exemplo, "Logado como…". Se sua página for controlada por um service worker, você poderá solicitar dados JSON com um modelo e renderizá-los.

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

Em resumo

Você não precisa usar apenas um desses métodos. Na verdade, é provável que você use muitas delas, dependendo do URL da solicitação. Por exemplo, trained-to-thrill usa:

Basta examinar a solicitação e escolher 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);
   
}),
 
);
});

... você sabe.

Créditos

… pelos belos ícones:

Agradeço a Jeff Posnick por detectar vários erros lamentáveis antes que eu clicasse em "Publicar".

Leitura adicional