
Compartilhar:
Yonatan já participou de alguns projetos incríveis tanto no meio acadêmico quanto no setor privado — desde C/C++, passando por Matlab, até PHP e JavaScript. Ex-CTO da Webiks e arquiteto de software na WalkMe. Atualmente, ele é arquiteto de software na Vonage e instrutor na Egghead.
Como os Service Workers do JavaScript aumentaram a velocidade do nosso site em 97,5%
Tempo de leitura: 8 minutos
Veja como conseguimos que nosso site carregasse 97,5% mais rápido usando service workers, como garantimos que os usuários sempre tenham acesso à versão mais recente e como você também pode fazer isso.
O site do sistema de design da Vivid evoluiu bastante em apenas um ano. Começou com algumas páginas básicas e agora se tornou um site de documentação completo, apresentando exemplos práticos de como usar nosso produto.
Utilizamos amplamente a divisão de código em nosso código, e nosso produto inclui vários recursos estáticos, como ícones e arquivos CSS. Para completar, nossa documentação usa iFrames para apresentar nossos componentes, cada um chamando suas dependências separadamente (sim, como uma arquitetura de microfront-end).
Mas, com o passar do tempo, as coisas começaram a dar errado.
Tudo começou com nosso ambiente de desenvolvimento. Depois de algumas atualizações da página, ele simplesmente travava.
vivid-docs-loading-error.gif
Com isso, a gente conseguia conviver. O que não conseguíamos aceitar era receber um ticket dos nossos usuários mostrando o quão grave a situação realmente era.
vivid-user-ticket.png
Sabíamos que precisávamos fazer algo a respeito. Precisávamos tornar nosso site mais rápido. Veja a seguir como identificamos e corrigimos os problemas de desempenho do nosso site.
O aumento de desempenho
Vamos começar pelo fim. Nosso principal objetivo era reduzir o número de chamadas ao servidor. Um objetivo secundário (ou bônus, se preferirem) era acelerar o tempo de carregamento das páginas do nosso aplicativo. Vocês podem ver os resultados e julgar por si mesmos:
Antes
vivid-performance-before.png
Na imagem acima, podemos ver o perfil das solicitações de rede antes da alteração. Em alguns momentos, o carregamento da página levava 860 ms – um tempo enorme! O tempo médio de carregamento de uma página era de cerca de 400 ms. Na seção mais à direita, é possível ver a barra cinza do site tentando carregar o all.css arquivo, o que parece não ter fim. Isso faz com que o site pare de carregar e, se o usuário tentar atualizar a página, a situação só piora!
vivid-performance-after.png
Então, como conseguimos isso?
Problema: O limite de conexões do Chrome
O Chrome oferece um limite de 6 conexões simultâneas a um servidor. O que nos surpreendeu foi o longo tempo de espera e o fato de as solicitações permanecerem “ativas” mesmo ao atualizar a página ou navegar por outra página.
Como tínhamos quase cem solicitações em cada página (dividimos o código ao extremo), o Chrome simplesmente interrompia a conexão com nosso site para todos os usuários após apenas alguns minutos de navegação. Assim, ficamos com uma missão: reduzir o número de arquivos que o Chrome precisava carregar.
Problema: excesso de arquivos
Tentativa nº 1: Remover o Prefetch
Como tivemos certeza de que havia arquivos demais? Quando abrimos o inspetor do Chrome, nosso código estava assim:
chrome-inspector-before-service-workers.png
Esses arquivos são apenas uma pequena parte do nosso manifesto de arquivos. Observe que também fazemos o pré-carregamento dos arquivos para acelerar o tempo de carregamento das páginas a seguir.
Nosso primeiro passo foi desativar o pré-carregamento. Embora isso tenha reduzido o número de solicitações, não adiantou muito, pois o pré-carregamento ocorria apenas quando a rede estava ociosa; por isso, o efeito foi mínimo. Precisávamos reduzir o número de scripts solicitados.
Tentativa nº 2: agrupar todos os arquivos em um único pacote grande
Em nosso projeto, usamos rollup para agrupar nossos arquivos. Nossa configuração tem como objetivo dividir tudo por código e permitir que os usuários utilizem seus próprios agrupadores para dividir por código, agrupar e realizar o “tree shake”.
Nesta etapa, acabamos de revisar todos os componentes, criamos um arquivo “barrel” e agrupamos todos eles em um único arquivo grande, o `vivid-components.js`, que usamos no lugar de todas as outras tags de script. Confira este commit para ver como fizemos isso.
Isso ajudou na maioria das páginas, mas — lembra dos iFrames? Eles ainda carregavam um monte de coisas — duplicatas dos arquivos já carregados pela página inicial.
Isso nos levou, então, à nossa solução final e mais eficaz: service workers.
Solução: Service Workers
Um service worker é uma camada entre nosso aplicativo e a rede. Ele pode monitorar todas as solicitações que entram e saem do aplicativo e processá-las.
No nosso caso, queríamos processar as solicitações, armazenar a resposta em cache e retorná-la nas solicitações subsequentes.
Como registrar um Service Worker
O primeiro passo é registrar o service worker no cliente:
(async function() {
const registration = await navigator.serviceWorker.register(
'/sw.js',
{
scope: '/',
}
);
})();O service worker pode processar solicitações de acordo com a pasta em que está localizado. É por isso que o coloquei na raiz do meu projeto.
No nosso projeto, o arquivo propriamente dito não está na raiz. Ele é movido para lá durante o nosso processo de compilação, o que nos proporciona uma experiência de desenvolvimento agradável e, ao mesmo tempo, permite que busquemos solicitações a partir da raiz. Você poderia usar o cabeçalho HTTP `service-worker-allowed` para servi-lo em uma pasta diferente, mas, usando o truque de “compilação na raiz”, não tivemos necessidade disso.
Funcionalidade do Service Worker
Nosso service worker tem a seguinte aparência:
const addResourcesToCache = async (resources) => {
const cache = await caches.open('vivid-cache');
await cache.addAll(resources);
};
const putInCache = async (request, response) => {
const cache = await caches.open('vivid-cache');
await cache.put(request, response);
};
const cacheFirst = async ({ request, preloadResponsePromise, fallbackUrl }) => {
const responseFromCache = await caches.match(request);
if (responseFromCache) {
return responseFromCache;
}
const preloadResponse = await preloadResponsePromise;
if (preloadResponse) {
console.info('using preload response', preloadResponse);
await putInCache(request, preloadResponse.clone());
return preloadResponse;
}
try {
const responseFromNetwork = await fetch(request);
await putInCache(request, responseFromNetwork.clone());
return responseFromNetwork;
} catch (error) {
const fallbackResponse = await caches.match(fallbackUrl);
if (fallbackResponse) {
return fallbackResponse;
}
return new Response('Network error happened', {
status: 408,
headers: { 'Content-Type': 'text/plain' },
});
}
};
const enableNavigationPreload = async () => {
if (self.registration.navigationPreload) {
await self.registration.navigationPreload.enable();
}
};
self.addEventListener('activate', (event) => {
event.waitUntil(enableNavigationPreload());
});
self.addEventListener('install', (event) => {
event.waitUntil(
addResourcesToCache([
'./',
'./index.html',
'/assets/styles/core/all.css',
'/assets/scripts/vivid-components.js',
'/assets/scripts/live-sample.js',
])
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
cacheFirst({
request: event.request,
preloadResponsePromise: event.preloadResponse,
fallbackUrl: './assets/images/vivid-logo.jpeg',
})
);
});
Temos duas funções utilitárias: addResourcesToCache para adicionar um recurso ao cache e putInCache para armazenar uma solicitação e sua resposta no cache.
Ambos utilizam os caches , que nos dá acesso ao CacheStorage .
cacheFirst É aqui que a mágica acontece. Ele tenta obter a resposta do cache. Se a encontrar, retorna a resposta armazenada no cache. (Linhas 12-15)
Caso contrário, ele tenta obter a resposta a partir de um pré-carregamento. Se funcionar, está tudo certo — armazenamos em cache e retornamos a resposta pré-carregada. (Linhas 17-22)
Se isso não der certo, passamos a enviar uma solicitação à rede (por exemplo, ao servidor), obtemos a resposta e a armazenamos em cache. (Linhas 24-27)
Se tudo der errado, simplesmente retornamos um erro acompanhado de uma imagem. (Linhas 29-35)
Ciclo de vida do Service Worker
O ciclo de vida do service worker:
Cadastro (já passamos por isso)
Instalação
Ativação
Nosso service worker monitora a fase de instalação e adiciona nossos principais recursos ao cache.
self.addEventListener('install', (event) => {
event.waitUntil(
addResourcesToCache([
'./',
'./index.html',
'/assets/styles/core/all.css',
'/assets/scripts/vivid-components.js',
'/assets/scripts/live-sample.js',
])
);
});
Observe o utilitário waitUntil que temos no objeto de evento. Esse recurso nos ajuda a evitar condições de corrida, pois aguarda a conclusão das operações assíncronas.
Em seguida, na activate fase, ativamos o pré-carregamento de conteúdo (com waitUntil).
const enableNavigationPreload = async () => {
if (self.registration.navigationPreload) {
await self.registration.navigationPreload.enable();
}
};
self.addEventListener('activate', (event) => {
event.waitUntil(enableNavigationPreload());
});
A etapa final é adicionar um ouvinte a fetch. Esse ouvinte intercepta as solicitações e nos permite tratá-las usando nossa cacheFirst função:
self.addEventListener('fetch', (event) => {
event.respondWith(
cacheFirst({
request: event.request,
preloadResponsePromise: event.preloadResponse,
fallbackUrl: './assets/images/vivid-logo.jpeg',
})
);
});
Observe a respondWith utilitário. Ele faz exatamente o que diz: dada a solicitação, podemos retornar qualquer resposta. Neste caso, retornamos o resultado de cacheFirst.
Como lidar com versões em um Service Worker
Pode haver um momento em que você queira atualizar a versão de um service worker. No nosso caso, isso é necessário para a atualização da nossa biblioteca. Para isso, precisamos indicar a versão no arquivo do nosso service worker, criar um cache com versão e excluir o cache antigo.
Como criar um cache com controle de versão em um Service Worker
Adicionar uma versão é bem simples: const VERSION = ‘3.17.0’;
Isso pode ser alterado manualmente a cada lançamento.
No nosso projeto, por exemplo, usamos o rollup para agrupar, então fizemos o seguinte “truque”:
Definimos a versão da seguinte maneira:
const VERSION = ‘SW_VERSION’;Durante a compilação, extraímos a versão do nosso
package.jsonUsamos o plugin “replace” do rollup para definir a versão em cada compilação.
Você pode ver nossa configuração aqui.
Como criar um cache com controle de versão em um Service Worker
Se você percebeu, o caches.open método aceita uma string:
const cache = await caches.open(VERSION);
Ele espera um nome ou um ID de cache que possamos consultar posteriormente.
Muitas vezes, você precisará atualizar o service worker ou o cache do seu site. Por exemplo, quando você atualiza a versão de uma biblioteca, adiciona uma nova seção à página ou faz qualquer alteração na resposta e deseja que ela seja exibida sem esperar que o cache expire.
Usar a versão como identificador nos permite acessar o cache de cada versão e, assim, exibir a versão atual e excluir as antigas.
Como excluir o cache obsoleto em um Service Worker
Agora que temos o cache com controle de versão, podemos excluí-lo.
Vamos criar uma função removeOldCache:
async function removeOldCache(event) {
await caches.keys().then(function (keys) {
return Promise.all(keys.filter(function (key) {
return key !== VERSION;
}).map(function (key) {
return caches.delete(key);
}));
}).then(function () {
return self.clients.claim();
});
}A função percorre todas as chaves do cache (linha 2) e, para cada chave que não seja a nova versão ativada, a excluímos (linha 6). Depois de fazer isso, usamos o self.clients.claim método para informar ao navegador que nosso novo Service Worker agora controla todas as abas (linha 9).
Chamamos essa função durante a ativação:
self.addEventListener('activate', (event) => {
event.waitUntil(removeOldCache(event));
event.waitUntil(enableNavigationPreload());
});
Observe que o cache é o cache global para todos os service workers. No nosso caso, podemos removê-los com segurança, mas no seu caso, você pode ter mais de um cache e, portanto, pode considerar usar um sufixo na versão para remover apenas o cache desejado.
Um exemplo é o caso em que se deseja separar o cache de HTML do cache de solicitações ao servidor. O cache de HTML será esvaziado quando você alterar áreas relacionadas ao lado do cliente, enquanto o cache de solicitações ao servidor será atualizado com outra periodicidade.
Atualização de Service Workers
Os novos Service Workers têm um período de espera. A substituição deles pode levar algumas horas. Podemos pular esse período de espera adicionando self.skipWaiting() em nossa fase de instalação.
Agora, nosso Service Worker será atualizado imediatamente quando fizermos alguma alteração (por exemplo, ao enviar uma nova versão da biblioteca).
Resumo
Os Service Workers são uma ferramenta muito poderosa para desenvolvedores web. Eles nos permitem controlar a comunicação entre o servidor e o cliente. Aqui, vimos o exemplo clássico de armazenamento em cache de conteúdo, mas existem muitos outros casos de uso.
Por exemplo, se armazenarmos em cache as respostas do servidor e as retornarmos, o usuário poderá continuar usando nosso aplicativo mesmo quando estiver offline.
Este exemplo de cache mostra nossa solução para o problema — e, de fato, ele o resolve. Mas os Service Workers atendem a muitas outras necessidades no desenvolvimento. Adoraria saber quais são as suas 🙂 Conte-me o que você está desenvolvendo com Service Workers no Twitter ou na Slack da Comunidade Vonage!
Muito obrigado a Oria Biton e Miki Ezra Stanger pela revisão atenciosa e minuciosa deste artigo
Compartilhar:
Yonatan já participou de alguns projetos incríveis tanto no meio acadêmico quanto no setor privado — desde C/C++, passando por Matlab, até PHP e JavaScript. Ex-CTO da Webiks e arquiteto de software na WalkMe. Atualmente, ele é arquiteto de software na Vonage e instrutor na Egghead.