ServiceWorker: A revolução da plataforma Web

Em:

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.

A picture of clocks

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 usar http://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).

ServiceWorker lifecycle on DevTools

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 do event.waitUntil durante o evento install
  • installed enquanto espera para se tornar ativo
  • activating enquanto estiver bloqueado na Promise do event.waiUntil durante o evento activate
  • activated quando estiver totalmente operacional e capaz de interceptar requisições via fetch
  • redundant ao ser substituído por uma versão mais recente do script do ServiceWorker, ou por ser descartado devido a uma falha no install

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 cache
    • Se 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 cache
  • Bata na rede, independentemente dos acessos ao cache
    • Se a requisição for bem sucedida, use sua resposta (response) e a armazene no cache
    • Se 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.

WebPageTest.org results on repeat view

Definitivamente uma adição de valor a qualquer website.

  • BrazilJS on the road 2018 – Pesquisa

    Em 2018 a BrazilJS promoverá uma série de eventos em várias cidades do Brasil 👌👏🌈

  • Web Share API

    Recentemente, a equipe do Chrome anunciou que um de seus membros, o Matt Giuca, está trabalhando em uma proposta para uma simples API: o Web Share. Esta API permite que websites consigam invocar os recursos de compartilhamento nativos do Sistema Operacional. Um ponto importante da Web Share API é que o controle de onde e […]

  • Sugestões para a BrazilJS Conf

    2017 será o ano da sétima edição consecutiva da BrazilJS Conf. Quem nos acompanha desde 2011, sabe que evoluímos muito de lá pra cá, mas não estamos nem na metade do caminho. 😀 Seguindo uma sugestão incrível de alguns participantes da edição de 2016 (via pesquisa de satisfação), estamos abrindo para a comunidade a possibilidade […]

Patrocinadores BrazilJS

Gold

Silver

Bronze

Apoio

BrazilJS® é uma iniciativa NASC.     Hosted by Getup