ServiceWorker: A revolução da plataforma Web
Mesmo não sendo o melhor nome de feature adicionada à plataforma Web, tudo indica o ServiceWorker como sendo a adição mais significativa para a plataforma Web desde a introdução do AJAX -- há mais de 10 anos atrás. Não confunda o ServiceWorker com o WebWorker (usado para descarregar operações intensas de computação para outra thread), o ServiceWorker permite que você intercepte (e faça hijack) em requisições do seu site antes das mesmas serem finalizadas. Este artigo explora como isso funciona, o que isso significa e nos possibilita, e como você pode implementar ServiceWorker, seguindo um estudo de caso.
Sentado entre humanos e websites, o ServiceWorker coloca você no assento do motorista. Quando um ServiceWorker estiver instalado, você será capaz de entregar respostas às requisições através do ServiceWorker (no lado do cliente), sem necessariamente tocar na rede.
ServiceWorker é a evolução do AppCache manifest. O AppCache foi esmagado por anos devido aos seus diversos problemas -- que foram extensivamente documentados -- em um ressonante grito "AppCache sucks" em toda a blogosfera. A maioria dos problemas no AppCache giravam em torno de ele ser uma interface interativa muito simples para controlar o comportamente offline, que atrás das cortinas, possui uma complicada série de regras que não eram simples e nem intuitivas.
A especificação do ApplicationCache é como uma cebola: Ela tem muitas camadas, e conforme você as descasca você vai sendo reduzido a lágrimas. -- Jake Archibald
Por outro lado, o ServiceWorker oferece uma API programática que permite que você alcance muito mais do que o AppCache nunca pôde. Ao invés de regras quase-muito-mágicas em torno de uma sintaxe declarativa e simplista, o ServiceWorker se baseia em puro código JavaScript.
Mesmo sem ter um suporte perfeito nos browsers atualamente, o ServiceWorker está apenas no início. Uma vez que você implemente ServiceWorker nos seus sites, você será capaz de disponibilizar o funcionamento offline para as suas páginas. Sim, mesmo quando as pessoas não tiverem acesso a qualquer tipo de conectividade, elas serão capazes de acessar o seu site enquanto estão offline, desde que tenham visitado a sua página pelo menos uma vez anteriormente. E isso é apenas o básico oferecido. O ServiceWorker também possibilita que você envie Push Notifications, assim como as aplicações mobile fazem, mesmo quando o website estiver rodando em background; você também pode usar o Background Sync, que permite que você execute atualizações de conteúdo de uma vez só ou periodicamente no seu website enquanto ele está em background; e Add to Home Screen, que faz exatamente o que parece, fazendo com que o seu website pareça quase como uma aplicação nativa.
Eu gastei algum tempo brincando com a API do ServiceWorker e me senti obrigado a escrever a respeito. Mesmo sendo bem nova, esta API é uma potência da plataforma Web que devemos conhecer se quisermos ter uma chance de competir quando se trata de dispositivos móveis. Além disso, ServiceWorker é ótimo para performance, além de suas capacidades offline, por nos dar um controle refinado sobre o cache. Lembra daquelas mensagens chatas "este gravatar não está em cache por tanto tempo" no PageSpeed? Você pode usar ServiceWorker como um cache intermediário que os armazena em cache por muito mais tempo!
Se você possui um site pessoal, blog, ou um pequeno site que você brinca de vez em quando, eu recomendo que você siga este guia e tente implementar ServiceWorker. Eu fiz isso com o ponyfoo.com
e além de ser um ótimo exercício para a cabeça, foi muito bom saber que agora podemos acessar a estes artigos também quando estivermos offline.
Começando
Antes de começar, devemos saber algumas coisas sobre o ServiceWorker.
O ServiceWorker não tem acesso ao DOM -- ele é um worker que roda fora do escopo da página
O ServiceWorker se baseia fortemente em Promises, você precisa se sentir confortável com elas (read the guide)
https
é obrigatório para sites em produção que pretendem usar ServiceWorker, mas para testes locais é possível usarhttp://localhost
tranquilamente
O ServiceWorker é uma poderosa adição para a plataforma Web, tendo a habilidade de interceptar todas as requisições originadas de um site -- incluindo aquelas feitas para outras origens (e.g: i.imgur.com
). Por essa razão, o requisito do uso de https
. É por questão de segurança.
O típico exemplo de instalação de um ServiceWorker está no trecho de código abaixo. Este pequeno trecho é o que usei no ponyfoo/ponyfoo
, e mostra como o ServiceWorker é uma melhoria progressiva (progressive enhancement). Este é todo o código do lado do cliente que faz referência ao ServiceWorker. O teste de recurso (feature test) garante que a página não deixe de funcionar nos navegadores mais antigos, e o resto do código para o ServiceWorker habilitado vai ficar no script service-worker.js
.
javascript if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/service-worker.js'); }
Note que o ServiceWorker tem o seu escopo de acordo com o endpoint usado para registrar o worker. Se apenas usarmos /service-worker.js
então o escopo será toda a origem, mas se registrarmos um ServiceWorker com o endpoint /js/service-worker.js
, ele somente será capaz de interceptar requisições na sua origem do escopo /js/
, como por exemplo /js/all.js
-- não tão útil.
Uma vez que um ServiceWorker é registrado, ele será baixado e executado. Depois disso, o primeiro passo no seu ciclo de vida será disparar o evento de install
. Você pode registrar listeners que serão disparados no seu arquivo service-worker.js
.
O exemplo abaixo é uma versão simplificada de como eu instalei o ServiceWorker par o Pony Foo. O método event.waitUntil
recebe uma Promise e então espera pela sua resolução. Se a Promise for preenchida, o ServiceWorker terá acesso a API caches
que pode ser usada como um cache intermediário que fica no lado do cliente. Como você pode observar no código abaixo, a API caches
também é fortemente baseada em Promises. Depois de abrir o cache v1::fundamentals
usamos o método cache.addAll
para armazenar respostas GET
para cada um dos recursos de offlineFundamentals
no cache
.
js var offlineFundamentals = [ '/', '/offline', '/css/all.css', '/js/all.js' ]; self.addEventListener('install', function installer (event) { event.waitUntil( caches .open('v1::fundamentals') .then(function prefill (cache) { cache.addAll(offlineFundamentals); }) ); });
Porquê estou guardando em cache estes recursos específicos? Porque os usuários serão capazes de visitar a página inicial do meu site mesmo quando estiverem offline. Além disso, o recurso /offline
pode ser usado como resposta offline padrão para requisições com Content-Type: application/html
feitas para endpoints na minha origem (e.g: ponyfoo.com/articles/history
enquanto estiver offline), como veremos em seguida.
Você pode inspecionar o ciclo de vida de um ServiceWorker usando a sua DevTool. Isso tornará a sua vida muito mais fácil enquanto estiver fazendo o debug! Apenas vá para a tab
Resources
e escolha a última opção -- Service Workers. Se você usa o Chrome, o suporte já está disponível desde a versão 48 (e já está no Chrome Canary).
Uma vez que estes recursos são colocados no cache com sucesso, o ServiceWorker passa a estar instalado (installed
).
O ciclo de vida do ServiceWorker
Além dos status de installed
e installing
enquanto aguarda o cache ser preenchido -- existe também um passo de ativação durante o processo de um ServiceWorker velho passar a ser redundante (redundant
) enquanto o mais novo o substitue, e se torna activated
(até que, o novo ServiceWorker mude para activating
). Existem 5 estados possíveis no ciclo de vida do ServiceWorker.
Installing
enquanto estiver bloqueado na Promise doevent.waitUntil
durante o eventoinstall
installed
enquanto espera para se tornar ativoactivating
enquanto estiver bloqueado na Promise doevent.waiUntil
durante o eventoactivate
activated
quando estiver totalmente operacional e capaz de interceptar requisições viafetch
redundant
ao ser substituído por uma versão mais recente do script do ServiceWorker, ou por ser descartado devido a uma falha noinstall
Depois que o ServiceWorker estiver como installed
, o evento activate
é acionado, e o mesmo processo é seguido. Durante a etapa de ativação, você poderia limpar o cache usand a API de caches
. Lembra de como eu prefixaei o meu cache como v1::
antes? Se eu atualizei os arquivos fundamentais no meu cache eu poderia apenas apagar (.delete
) o cache mais antigo e conferir o número da versão, como pode ser visto abaixo.
`\
js var version = 'v2::';
self.addEventListener('activate', function activator (event) { event.waitUntil( caches.keys().then(function (keys) { return Promise.all(keys .filter(function (key) { return key.indexOf(version) !== 0; }) .map(function (key) { return caches.delete(key); }) ); }) ); }); `\
Agora que você tem um ServiceWorker ativo (activated
), você pode começar a interceptar requisições.
Interceptando requisições em um ServiceWorker
Sempre que uma requisição for iniciada e um ServiceWorker estiver ativado, o evento fetch
é gerado no ServiceWorker, ao invés da bater na rede. Os event handlers para o evento fetch
aguardam para produzir uma resposta para a requisição, e eles podem ou não acessar a rede.
Na sua forma mais simples, o seu ServiceWorker só poderia agir como um infiltrado na rede. Neste caso, a aplicação raramente será diferente, e não fará diferença, ter ou não um ServiceWorker. Note que, por padrão, o fetch
não inclui credenciais, como cookies, e deixa de fazer requisições a terceiros que não suportam CORS.
js self.addEventListener('fetch', function fetcher (event) { event.respondWith(fetch(event.request)); });
Normalmente, você não deseja armazenar em cache as respostas que não são GET
, então estas devem ser provavelmente filtradas. O código a seguir será o padrão de resposta para todos os POST
,PUT
, PATCH
,HEAD
, ou OPTIONS
originários do escopo do ServiceWorker.
js self.addEventListener('fetch', function fetcher (event) { var request = event.request; if (request.method !== 'GET') { event.respondWith(fetch(request)); return; } // manipula outras requisições });
Se você está procurando por fórmulas para gerenciar requisições, o livro offline cookbook é um bom lugar para se olhar.
Estratégias
Existem diversas estratégias diferentes que você pode aplicar para resolver requisições no seu ServiceWorker. Aqui estão algumas das que achei mais interessantes.
Rede, e então Cache
Faça
Fetch
da rede primeiro, e faça fallback para uma resposta que já esteja em cache, caso o fetch falhe.
Você não conseguirá buscar os recursos de cache do ServiceWorker quando estiver on-line com esta estratégia, porque a rede sempre vem em primeiro lugar. Por essa razão, fazer fetch
primeiro também tem a desvantagem de que uma rede intermitente, não confiável, ou muito lenta nunca produzirá respostas, mesmo que possa ter um cache perfeitamente utilizável, ele será desperdiçado.
Sempre bata na rede para requisições que não são do tipo
GET
Bata na rede
Se a requisição for bem sucedida, use sua resposta (
response
) e a armazene no cacheSe a requisição falhar, tente usar
caches.match(request)
Se for uma requisição para o cache, então o use como resposta (
response
)Se não existe nada em cache, faça um fallback para
/offline
Este fluxo é melhor na produção de novos conteúdos (em oposição as respostas obsoletas que estão em cache), do que outros, mas não é tão útil para melhorias que não sejam totalmente offline (em oposição a coisas efetivamente offline devido a baixa conectividade). Dispositivos móveis não vão tirar o máximo proveito desta estratégia porque sua conectividade pode ser muito baixa, mas não baixa o suficiente para o dispositivo tornar o navigator.online
em off
, e assim você vai acabar no mesmo lugar, como se estivesse sem um ServiceWorker.
Em cache, então na Rede
Procure pela resposta em cache primeiro, mas sempre busque da rede independentemente do estado do cache.
Este fluxo é semelhante ao anterior, exceto pelo fato de você ir ao cache primeiro. Aqui as respostas podem ser imediatas e você notará melhorias de desempenho em acessos de qualquer conteúdo que foi armazenado em cache anteriormente.
Sempre bata na rede para requisições que não são do tipo
GET
Verifique o
caches.match(request)
para ver se existe um requisição em cacheBata na rede, independentemente dos acessos ao cache
Se a requisição for bem sucedida, use sua resposta (
response
) e a armazene no cacheSe a requisição falhar,tente fazer fallback para
/offline
Retorne a requisição em cache no caso de acesso ao cache, caso contrário busque a resposta (
fetch
)
Em algum momento o cache será renovado de novo, pois o fetch
é sempre usado - independentemente de acessos ao cache.
O problema nesse caso é que o cache pode estar obsoleto. Suponha que você tenha visitado uma página uma vez. O worker usa o fetch
e, em seguida, a resposta é armazenada em cache. Quando você visitar a página pela segunda vez, você recebe a resposta armazenada em cache pela última vez imediatamente, e então o fetch
é executado, atualizando a versão mais recente para o cache. Nesse ponto, você já aplicou a versão anterior, o que não é o conteúdo mais recente.
Em cache, então na Rede e postMessage
O fluxo anterior pode servir conteúdo obsoleto, mas você pode atualizar a interface quando a resposta atualizada chegar. Até agora eu não tinha feito nada com postMessage
, de maneira que que este é basicamente um exercício de pensamento. A interface do postMessage
pode ser usada para transmitir mensagens entre o worker e as abas do navegador.
Usando o mesmo fluxo como descrito em Em Cache, então na Rede, você pode passar mensagens entre o worker e a aplicação, para que quando o cache for atualizado, qualquer aba que esteja na mesma página, como o endpoint do cache, fique atualizada. Claro, a interação deve ser cuidadosamente elaborada. Uma combinação de virtual diffing DOM e um planejamento cuidadoso ao lidar com recursos em cache que não são páginas HTML será uma melhor escolha no sentido de tornar estas atualizações mais suaves.
Dito isto, esta abordagem é provavelmente um pouco sofisticada demais para a maioria das aplicações. Como de costume, de qualquer maneira tudo depende do seu caso de uso.
Implementação
No meu blog (Pony Foo) eu usei a tática de "Em Cache, então na rede". Este é o código que tivemos até agora. Isso nos ajudou a garantir nossas requisições que não são GET
.
js self.addEventListener('fetch', function fetcher (event) { var request = event.request; if (request.method !== 'GET') { event.respondWith(fetch(request)); return; } // manipula outras requisições });
Depois disso, podemos olhar para os acessos ao cache usando caches.match(request)
e então responder com o resultado do callback queriedCache
.
js event.respondWith(caches .match(request) .then(queriedCache) );
O método queriedCache
recebe a resposta armazenada em cache(cached
), caso exista. Logo é feita uma requisição (fetch
) independentemente do cache ter sido acessado. Também tentamos fazer um gracefully fallback quando a requisição ou o cache falhou, com o callback unableToResolve
. Por último, retornamos a resposta do cache (cached
) e fazemos fallback para a Promise networked
no caso do valor não estar armazenado no cache.
js function queriedCache (cached) { var networked = fetch(request) .then(fetchedFromNetwork, unableToResolve) .catch(unableToResolve); return cached || networked; }
Se o fetch
funcionou e o fetchedFromNetwork
for chamado, então armazenamos uma cópia da resposta no cache
e então retornamos a resposta não alterada.
js function fetchedFromNetwork (response) { var clonedResponse = response.clone(); caches.open(version + 'pages').then(function add (cache) { cache.put(request, clonedResponse); }); return response; }
Quando não foi possível resolver as requisições do fetch
precisamos fazer fallback. Por padrão, podemos fazer disso uma resposta opaca (offlineResponse
). Como você pode ver, você pode codificar objetos Response
e usá-los para reagir a requisições.
js function unableToResolve () { return offlineResponse(); } function offlineResponse () { return new Response('', { status: 503, statusText: 'Service Unavailable' }); }
Se estivessemos lidando com uma imagem, poderíamos retornar alguma imagem de arco-íris como placeholder no lugar - desde que arco-íris
seja uma URL que já foi armazenado em cache durante a etapa de instalação ServiceWorker.
js function unableToResolve () { var accepts = request.headers.get('Accept'); if (accepts.indexOf('image') !== -1) { return caches.match(rainbows); } return offlineResponse(); }
Além disso, se fosse um gravatar, poderíamos usar uma imagem adaptada do mysteryMan
para isso, também armazenada em cache durante a instalação.
js function unableToResolve () { var url = new URL(request.url); var accepts = request.headers.get('Accept'); if (accepts.indexOf('image') !== -1) { if (url.host === 'www.gravatar.com') { return caches.match(mysteryMan); } return caches.match(rainbows); } return offlineResponse(); }
Da mesma forma, no caso de uma requisição que aceita HTML, podemos retornar o / offline
que tínhamos instalado anteriormente.
js function unableToResolve () { var url = new URL(request.url); var accepts = request.headers.get('Accept'); if (accepts.indexOf('image') !== -1) { if (url.host === 'www.gravatar.com') { return caches.match(mysteryMan); } return caches.match(rainbows); } if (url.origin === location.origin) { return caches.match('/offline'); } return offlineResponse(); }
Por último, como o Pony Foo é uma single page application, o ServiceWorker também precisa entender como renderizar uma página offline usando JSON
. Neste caso podemos notar que a origem bate com a do Pony Foo e que os headers são application/json
. Eu posso então construir uma resposta que será interpretada com uma view offline.
js if (url.origin === location.origin && accepts.indexOf('application/json') !== -1) { return offlineView(); } function offlineView () { var viewModel = { model: { action: 'error/offline' } }; var options = { status: 200, headers: new Headers({ 'content-type': 'application/json' }) }; return new Response(JSON.stringify(viewModel), options); }
Há muitas outras opções. Em sites de conteúdo você poderia ir mais longe, a ponto de gerar automaticamente um arquivo ServiceWorker que tem todo o conteúdo embutido nele (ou talvez em uma grande carga que está em cache durante a instalação). Ou você pode progressivamente fazer uma varredura no site através do ServiceWorker usando requestIdleCallback
. Ou você pode apenas armazenar em cache coisas que as pessoas realmente visitam. Na maioria das vezes, isso é bom o suficiente.
Desde que eu seja capaz de visitar um conteúdo que eu já tenha visto, mas off-line, estarei feliz por ter implementado ServiceWorker. A imagem abaixo mostra os resultados no WebPageTest.org na visita repedita, onde nenhuma requisição é feita, salvando ~200ms
do início do start render e em torno de ~2.5s
do carregamento completo da página. Saimos de 43 requisições para zero, e de ~2mb
de peso da página para apenas ~200kb
.
Definitivamente uma adição de valor a qualquer website.