Com o Service Worker, desistimos de tentar resolver o problema off-line e demos aos desenvolvedores as partes móveis para que eles mesmos o resolvessem. Ele permite controlar o armazenamento em cache e como as solicitações são tratadas. Isso significa que você pode criar seus próprios padrões. Vamos analisar alguns padrões possíveis de forma isolada, mas, na prática, você provavelmente vai usar muitos deles em conjunto, dependendo do URL e do contexto.
Para conferir uma demonstração de alguns desses padrões, consulte Trained-to-thrill e este vídeo mostrando o impacto no desempenho.
Máquina de cache: quando armazenar recursos
O service worker permite processar solicitações de forma independente do armazenamento em cache. Por isso, vou demonstrar elas separadamente. Primeiro, o armazenamento em cache, quando deve ser feito?
Na instalação, como uma dependência

O Service Worker gera um evento install
. Você pode usar isso para preparar as coisas que precisam estar
prontas antes de processar outros eventos. Enquanto isso acontece, qualquer versão anterior do seu Service Worker
ainda está em execução e exibindo páginas. Portanto, o que você faz aqui não pode interromper isso.
Ideal para:CSS, imagens, fontes, JS, modelos… basicamente qualquer coisa que você considere estática para essa "versão" do seu site.
Essas são coisas que tornariam seu site totalmente não funcional se não fossem buscados. 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 esse 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 não for possível buscar algum dos recursos, a chamada cache.addAll()
será rejeitada.
No trained-to-thrill, uso isso para armazenar em cache recursos estáticos.
Na instalação, não como uma dependência

Isso é semelhante ao exemplo acima, mas não atrasará a conclusão da instalação e não fará com que ela falhe 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
();
}),
);
});
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. É claro que você
terá que considerar a possível ausência desses níveis e tentar armazená-los em cache novamente se eles
estiverem ausentes.
O Service Worker pode ser encerrado durante o download dos níveis 11 a 20, porque ele terminou de processar os 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 tem suporte apenas em bifurcações do Chromium.
Ao ativar

Ideal para: limpeza e migração.
Depois que 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 antiga foi removida, é um bom
momento para processar
migrações de esquema no IndexedDB
e excluir caches não usados.
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 da página. Mantenha a ativação o mais simples possível e use-a apenas para coisas que
não podiam ser feitas enquanto a versão antiga estava ativa.
No trained-to-thrill, uso isso para remover caches antigos.
Na interação do usuário

Ideal para:quando não é possível colocar todo o site off-line e você escolheu permitir que o usuário selecione o conteúdo que quer disponível off-line. Por exemplo, um vídeo no YouTube, um artigo na Wikipedia ou uma galeria específica no Flickr.
Ofereça ao usuário um botão "Ler mais tarde" ou "Salvar para uso off-line". Quando clicado, busque o que você precisa da rede e coloque 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 de caches está disponível nas páginas e nos service workers, o que significa que você pode adicionar ao cache diretamente da página.
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á recebida da rede, enviada para a página e adicionada ao cache ao mesmo tempo.
Se você fizer isso para vários URLs, como avatares, tome cuidado para não aumentar o armazenamento da sua origem. Se o usuário precisar recuperar espaço em disco, você não vai querer ser o candidato principal. Elimine 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. O código
acima usa .clone()
para criar outras
cópias que podem ser lidas separadamente.
No trained-to-thrill, uso isso para armazenar em cache as imagens do Flickr.
Desatualizado durante a revalidação

Ideal para:atualizar recursos com frequência em que não é essencial ter a versão mais recente. 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

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 para seu site. Somente o service worker é ativado. Você solicita permissão para fazer isso em uma página, e o usuário vai receber 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. Também 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 calendário.
O resultado final comum é uma notificação que, quando tocada, abre/foca uma página relevante, mas para a qual atualizar caches antes que isso aconteça é extremamente importante. O usuário obviamente está on-line no momento de receber a mensagem push, mas pode não estar quando interagir com a notificação. Por isso, é 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

A sincronização em segundo plano é outro recurso criado com base no service worker. Ele permite que você solicite 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 para seu site. Apenas o worker do serviço é ativado. Você solicita permissão para fazer isso em uma página, e o usuário será solicitado.
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 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
A 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. Você pode descobrir quanto você tem:
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 de navegador, o navegador pode descartar seus dados se o dispositivo estiver sob pressão de armazenamento. Infelizmente, o navegador não consegue diferenciar os filmes que você quer manter a todo custo e o jogo que não é tão importante.
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.
});
É claro que o usuário precisa conceder a permissão. Para isso, use a API Permissions.
É importante que o usuário faça parte desse fluxo, porque agora ele pode controlar a exclusão. Se o armazenamento do dispositivo estiver cheio e a exclusão de dados não essenciais não resolver o problema, o usuário poderá decidir quais itens manter e remover.
Para que isso funcione, é necessário que os sistemas operacionais tratem as origens "duráveis" como equivalentes aos apps específicos da plataforma nos detalhamentos do uso de armazenamento, em vez de informar o navegador como um único item.
Sugestões de veiculação: como responder a solicitações
Não importa o quanto de armazenamento em cache você faz, o worker de serviço não vai usar o cache, a menos que você informe quando e como. Confira alguns padrões para processar solicitações:
Somente cache

Ideal para:tudo o que você considera estático em uma determinada "versão" do seu site. Você precisa ter armazenado em cache esses dados no evento de instalação para que eles estejam disponíveis.
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, Cache, fallback para a rede o cobre.
Somente rede

Ideal para:coisas que não têm 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 simply don't call event.respondWith, which
// will result in default browser behavior
});
…embora você não precise lidar com esse caso especificamente, Cache, fallback para a rede o cobre.
Cache, com fallback para a 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 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 o que não está armazenado em cache, o que inclui todas as solicitações que não são GET, porque elas não podem ser armazenadas em cache.
Cache e corrida de rede

Ideal para:recursos pequenos em que você está buscando 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, a extração de recursos da rede pode ser mais rápida do que a do disco. No entanto, acessar a rede quando o usuário tem o conteúdo no dispositivo pode ser um desperdício de dados. Tenha isso em mente.
// 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)]));
});
A rede está voltando ao cache

Ideal para:uma correção rápida para recursos atualizados com frequência, fora da "versão" do site. Por exemplo, artigos, avatares, linhas do tempo de mídias sociais 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 mais antiga em cache. Se a solicitação de rede for bem-sucedida, provavelmente você vai querer atualizar a entrada de cache.
No entanto, esse método tem falhas. Se o usuário tiver uma conexão intermitente ou lenta, ele terá que esperar a falha da rede para receber o conteúdo perfeitamente aceitável no dispositivo. Isso pode levar muito tempo e é uma experiência frustrante para o usuário. Consulte o próximo padrão, Cache e depois rede, para uma solução melhor.
self.addEventListener('fetch', function (event) {
event.respondWith(
fetch(event.request).catch(function () {
return caches.match(event.request);
}),
);
});
Armazenamento em cache e rede

Ideal para:conteúdo atualizado com frequência. Por exemplo, artigos, linhas do tempo de mídias sociais 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, em seguida, atualizar a página quando/se os dados da rede chegarem.
Às vezes, é possível substituir os dados atuais quando novos dados chegam (por exemplo, placar do jogo), mas isso pode ser perturbador com conteúdos maiores. Basicamente, não "desapareça" algo que o usuário esteja lendo ou interagindo.
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 o conteúdo na tela o mais rápido possível e atualizar o conteúdo 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 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;
});
}),
);
});
No trained-to-thrill, trabalhei com isso usando XHR em vez de fetch e abusando do cabeçalho Accept para informar ao service worker de onde receber o resultado (código da página, código do service worker).
Fallback genérico

Se você não conseguir veicular algo do cache e/ou da rede, forneça uma alternativa genérica.
Ideal para:imagens secundárias, como avatares, solicitações POST com falha e uma página "Indisponível enquanto estiver 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ê vai migrar provavelmente será uma dependência de instalação.
Se a página estiver postando um e-mail, o worker de serviço 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.
Modelagem do lado do worker do serviço

Ideal para: páginas que não podem ter a resposta do servidor armazenada em cache.
Renderizar páginas no servidor acelera as coisas, mas isso pode significar incluir dados de estado que não fazem sentido em um cache, por exemplo, "Logado como…". Se a página for controlada por um service worker, você poderá solicitar dados JSON com um modelo e renderizá-lo.
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:
- cache na instalação, para a interface e o comportamento estáticos
- armazenamento em cache na resposta da rede, para as imagens e os dados do Flickr
- Buscar do cache, voltando para a rede, para a maioria das solicitações
- Buscar do cache e depois da rede, para os resultados da pesquisa do Flickr
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);
}),
);
});
…você entendeu.
Créditos
…para os ícones incríveis:
- Code (em inglês) por buzzyrobot
- Agenda de Scott Lewis
- Rede por Ben Rizzo
- SD por Thomas Le Bas
- CPU por iconsmind.com
- Trash por trasnik
- Notificação de @daosme
- Layout do Mister Pixel
- Cloud, de P.J. Onori
Agradeço a Jeff Posnick por detectar muitos erros antes de clicar em "Publicar".
Leitura adicional
- Service Workers: uma introdução
- O service worker está pronto?: acompanhe o status da implementação nos principais navegadores.
- Promessas do JavaScript: uma introdução: guia de promessas