Resumo
Saiba como usamos bibliotecas de service workers para tornar o app da Web da Google I/O 2015 rápido e com foco no modo off-line.
Visão geral
O app da Web do Google I/O 2015 deste ano foi escrito pela equipe de relações com desenvolvedores do Google com base em designs dos nossos amigos da Instrument, que criaram a experiência de áudio/visual. A missão da nossa equipe era garantir que o app da Web do I/O (que vamos chamar de IOWA) mostrasse tudo o que a Web moderna poderia fazer. Uma experiência totalmente off-line estava no topo da nossa lista de recursos essenciais.
Se você leu algum dos outros artigos deste site recentemente, provavelmente já
encontrou service workers.
Não se surpreenda se o suporte off-line do IOWA depender muito
deles. Motivados pelas necessidades reais do IOWA, desenvolvemos duas
bibliotecas para lidar com dois casos de uso off-line diferentes:
sw-precache
para automatizar
o pré-cache de recursos estáticos e
sw-toolbox
para lidar
com o armazenamento em cache de execução e estratégias de fallback.
As bibliotecas se complementam e nos permitiram implementar uma estratégia eficiente em que o "shell" de conteúdo estático do IOWA era sempre veiculado diretamente do cache, e os recursos dinâmicos ou remotos eram veiculados da rede, com respostas de fallback em cache ou estáticas quando necessário.
Pré-cache com sw-precache
Os recursos estáticos do IOWA, como HTML, JavaScript, CSS e imagens, fornecem a estrutura
principal do aplicativo da Web. Havia dois requisitos específicos que eram
importantes ao pensar em armazenar esses recursos em cache: queríamos garantir
que a maioria dos recursos estáticos fosse armazenada em cache e mantida atualizada.
O sw-precache
foi criado pensando nesses
requisitos.
Integração em tempo de build
sw-precache
com o processo de build baseado em gulp
do IOWA,
e contamos com uma série de padrões glob
para garantir que geramos uma lista completa de todos os recursos estáticos usados pelo IOWA.
staticFileGlobs: [
rootDir + '/bower_components/**/*.{html,js,css}',
rootDir + '/elements/**',
rootDir + '/fonts/**',
rootDir + '/images/**',
rootDir + '/scripts/**',
rootDir + '/styles/**/*.css',
rootDir + '/data-worker-scripts.js'
]
Abordagens alternativas, como codificar em disco uma lista de nomes de arquivos em uma matriz e lembrar de aumentar o número da versão do cache sempre que um desses arquivos é alterado, eram muito propensas a erros, especialmente porque tínhamos vários membros da equipe verificando o código. Ninguém quer interromper o suporte off-line deixando um novo arquivo em uma matriz mantida manualmente. A integração no build significa que podemos fazer alterações em arquivos existentes e adicionar novos arquivos sem preocupações.
Como atualizar recursos em cache
O sw-precache
gera um script de worker de serviço básico
que inclui um hash MD5 exclusivo para cada
recurso que é pré-armazenado em cache. Sempre que um recurso é alterado
ou um novo recurso é adicionado, o script do service worker é regenerado. Isso
aciona automaticamente o fluxo de atualização do service worker,
em que os novos recursos são armazenados em cache e os desatualizados são limpos.
Todos os recursos existentes com hashes MD5 idênticos são mantidos. Isso
significa que os usuários que visitaram o site antes só vão fazer o download do
conjunto mínimo de recursos alterados, o que leva a uma experiência muito mais eficiente
do que se todo o cache estivesse expirado em massa.
Cada arquivo que corresponde a um dos padrões glob é transferido por download e armazenado em cache na
primeira vez que um usuário acessa o IOWA. Fizemos um esforço para garantir que apenas os recursos
essenciais necessários para renderizar a página fossem pré-armazenados em cache. O conteúdo secundário, como a
mídia usada no experimento audio/visual,
ou as imagens de perfil dos palestrantes das sessões, não foram
pré-armazenados em cache. Em vez disso, usamos a biblioteca sw-toolbox
para processar solicitações off-line para esses recursos.
sw-toolbox
, para todas as nossas necessidades dinâmicas
Como mencionado, não é viável pré-cachear todos os recursos necessários para que um site funcione off-line. Alguns recursos são muito grandes ou usados com pouca frequência para que isso seja
vantajoso, e outros são dinâmicos, como as respostas de uma API
remota ou serviço. No entanto, o fato de uma solicitação não estar pré-armazenada em cache não significa que ela
precisa resultar em um NetworkError
.
O sw-toolbox
nos deu a
flexibilidade para implementar gerenciadores de solicitação
que lidam com o armazenamento em cache de execução para alguns recursos e substitutos personalizados para
outros. Também usamos essa abordagem para atualizar os recursos armazenados em cache em resposta
a notificações push.
Confira alguns exemplos de processadores de solicitações personalizados que criamos com base no
sw-toolbox. Foi fácil integrá-los ao script do service worker de base
usando o importScripts parameter
do sw-precache
,
que puxa arquivos JavaScript independentes para o escopo do service worker.
Experimento audiovisual
Para o experimento de áudio/visual,
usamos a estratégia de cache networkFirst
do sw-toolbox
. Todas as solicitações HTTP que correspondem ao padrão de URL do experimento
primeiro são feitas na rede. Se uma resposta bem-sucedida for
enviada, ela será armazenada usando a
API Cache Storage.
Se uma solicitação subsequente for feita quando a rede estiver indisponível, a
resposta armazenada em cache anteriormente será usada.
Como o cache era atualizado automaticamente sempre que uma resposta de rede sucesso retornava, não precisávamos especificar recursos de versão ou expirar entradas.
toolbox.router.get('/experiment/(.+)', toolbox.networkFirst);
Imagens do perfil do palestrante
Para as imagens de perfil dos palestrantes, nosso objetivo era mostrar uma versão em cache anterior da
imagem de um palestrante, se ela estivesse disponível, ou retornar à rede para recuperar a
imagem, se ela não estivesse. Se essa solicitação de rede falhasse, como alternativa final, usávamos uma
imagem de marcador de posição genérica que era pré-armazenada em cache (e, portanto, sempre estaria
disponível). Essa é uma estratégia comum para lidar com imagens que
podem ser substituídas por um marcador de posição genérico. Ela foi fácil de implementar
vinculando os manipuladores cacheFirst
e
cacheOnly
de sw-toolbox
.
var DEFAULT_PROFILE_IMAGE = 'images/touch/homescreen96.png';
function profileImageRequest(request) {
return toolbox.cacheFirst(request).catch(function() {
return toolbox.cacheOnly(new Request(DEFAULT_PROFILE_IMAGE));
});
}
toolbox.precache([DEFAULT_PROFILE_IMAGE]);
toolbox.router.get('/(.+)/images/speakers/(.*)',
profileImageRequest,
{origin: /.*\.googleapis\.com/});
Atualizações na programação dos usuários
Um dos principais recursos do IOWA era permitir que os usuários conectados criassem e
mantivessem uma programação de sessões que planejavam participar. Como era de se esperar,
as atualizações de sessão eram feitas por solicitações HTTP POST
a um servidor de back-end, e
passamos algum tempo trabalhando na melhor maneira de processar essas solicitações de modificação
de estado quando o usuário está off-line. Criamos uma combinação de uma
que enfileirou solicitações com falha no IndexedDB, com a lógica na página da Web principal
que verificava o IndexedDB em busca de solicitações enfileiradas e tentava novamente qualquer uma que encontrasse.
var DB_NAME = 'shed-offline-session-updates';
function queueFailedSessionUpdateRequest(request) {
simpleDB.open(DB_NAME).then(function(db) {
db.set(request.url, request.method);
});
}
function handleSessionUpdateRequest(request) {
return global.fetch(request).then(function(response) {
if (response.status >= 500) {
return Response.error();
}
return response;
}).catch(function() {
queueFailedSessionUpdateRequest(request);
});
}
toolbox.router.put('/(.+)api/v1/user/schedule/(.+)',
handleSessionUpdateRequest);
toolbox.router.delete('/(.+)api/v1/user/schedule/(.+)',
handleSessionUpdateRequest);
Como as novas tentativas foram feitas no contexto da página principal, podemos ter certeza de que elas incluíram um novo conjunto de credenciais do usuário. Depois que as tentativas foram bem-sucedidas, mostramos uma mensagem para informar ao usuário que as atualizações anteriores em fila foram aplicadas.
simpleDB.open(QUEUED_SESSION_UPDATES_DB_NAME).then(function(db) {
var replayPromises = [];
return db.forEach(function(url, method) {
var promise = IOWA.Request.xhrPromise(method, url, true).then(function() {
return db.delete(url).then(function() {
return true;
});
});
replayPromises.push(promise);
}).then(function() {
if (replayPromises.length) {
return Promise.all(replayPromises).then(function() {
IOWA.Elements.Toast.showMessage(
'My Schedule was updated with offline changes.');
});
}
});
}).catch(function() {
IOWA.Elements.Toast.showMessage(
'Offline changes could not be applied to My Schedule.');
});
Google Analytics off-line
Da mesma forma, implementamos um gerenciador para enfileirar todas as solicitações do Google Analytics com falha e tentar executá-las novamente mais tarde, quando a rede estiver disponível. Com essa abordagem, estar off-line não significa sacrificar os insights oferecidos pelo Google Analytics. Adicionamos o parâmetro qt
a cada solicitação em fila, definido como o tempo decorrido
desde a primeira tentativa da solicitação, para garantir que um tempo de atribuição de evento adequado fosse enviado ao back-end do Google Analytics. O Google Analytics
oferece suporte oficial
a valores de qt
de até 4 horas. Por isso, fizemos o possível para reproduzir essas
solicitações assim que possível, sempre que o worker de serviço fosse iniciado.
var DB_NAME = 'offline-analytics';
var EXPIRATION_TIME_DELTA = 86400000;
var ORIGIN = /https?:\/\/((www|ssl)\.)?google-analytics\.com/;
function replayQueuedAnalyticsRequests() {
simpleDB.open(DB_NAME).then(function(db) {
db.forEach(function(url, originalTimestamp) {
var timeDelta = Date.now() - originalTimestamp;
var replayUrl = url + '&qt=' + timeDelta;
fetch(replayUrl).then(function(response) {
if (response.status >= 500) {
return Response.error();
}
db.delete(url);
}).catch(function(error) {
if (timeDelta > EXPIRATION_TIME_DELTA) {
db.delete(url);
}
});
});
});
}
function queueFailedAnalyticsRequest(request) {
simpleDB.open(DB_NAME).then(function(db) {
db.set(request.url, Date.now());
});
}
function handleAnalyticsCollectionRequest(request) {
return global.fetch(request).then(function(response) {
if (response.status >= 500) {
return Response.error();
}
return response;
}).catch(function() {
queueFailedAnalyticsRequest(request);
});
}
toolbox.router.get('/collect',
handleAnalyticsCollectionRequest,
{origin: ORIGIN});
toolbox.router.get('/analytics.js',
toolbox.networkFirst,
{origin: ORIGIN});
replayQueuedAnalyticsRequests();
Páginas de destino de notificação push
Os workers de serviço não apenas processavam a funcionalidade off-line do IOWA, como também forneciam as notificações push que usávamos para notificar os usuários sobre atualizações nas sessões marcadas. A página de destino associada a essas notificações mostrava os detalhes da sessão atualizada. Essas páginas já estavam sendo armazenadas em cache como parte do site geral, então já funcionavam off-line, mas precisávamos garantir que os detalhes da sessão na página estivessem atualizados, mesmo quando visualizados off-line. Para fazer isso, modificamos os metadados da sessão armazenados em cache com as atualizações que acionaram a notificação push e armazenamos o resultado no cache. Essas informações atualizadas serão usadas na próxima vez que a página de detalhes da sessão for aberta, seja on-line ou off-line.
caches.open(toolbox.options.cacheName).then(function(cache) {
cache.match('api/v1/schedule').then(function(response) {
if (response) {
parseResponseJSON(response).then(function(schedule) {
sessions.forEach(function(session) {
schedule.sessions[session.id] = session;
});
cache.put('api/v1/schedule',
new Response(JSON.stringify(schedule)));
});
} else {
toolbox.cache('api/v1/schedule');
}
});
});
Problemas e considerações
É claro que ninguém trabalha em um projeto da escala do IOWA sem encontrar alguns problemas. Confira alguns dos problemas que encontramos e como os resolvemos.
Conteúdo desatualizado
Sempre que você planeja uma estratégia de armazenamento em cache, seja implementada por workers de serviço
ou pelo cache padrão do navegador, há uma troca entre
entregar recursos o mais rápido possível e entregar os recursos mais recentes. Com sw-precache
, implementamos uma estratégia agressiva de cache-first
para o shell do nosso aplicativo, o que significa que nosso worker de serviço não verifica a
rede para atualizações antes de retornar o HTML, o JavaScript e o CSS na página.
Felizmente, conseguimos aproveitar os eventos do ciclo de vida do service worker para detectar quando um novo conteúdo estava disponível depois que a página já tinha sido carregada. Quando um service worker atualizado é detectado, mostramos uma mensagem de aviso ao usuário informando que ele precisa recarregar a página para conferir o conteúdo mais recente.
if (navigator.serviceWorker && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.onstatechange = function(e) {
if (e.target.state === 'redundant') {
var tapHandler = function() {
window.location.reload();
};
IOWA.Elements.Toast.showMessage(
'Tap here or refresh the page for the latest content.',
tapHandler);
}
};
}
Verifique se o conteúdo estático é estático
O sw-precache
usa um hash MD5 do conteúdo dos arquivos locais e só busca
os recursos cujo hash mudou. Isso significa que os recursos ficam disponíveis na página
quase imediatamente, mas também significa que, depois que algo é armazenado em cache, ele
permanece em cache até que um novo hash seja atribuído a um script atualizado do service worker.
Encontramos um problema com esse comportamento durante a I/O porque nosso back-end precisava atualizar dinamicamente os IDs dos vídeos do YouTube da transmissão ao vivo para cada dia da conferência. Como o arquivo de modelo subjacente era estático e não mudou, o fluxo de atualização do worker de serviço não foi acionado, e o que deveria ser uma resposta dinâmica do servidor com a atualização de vídeos do YouTube acabou sendo a resposta em cache para vários usuários.
Para evitar esse tipo de problema, verifique se o aplicativo da Web está estruturado de modo que o shell seja sempre estático e possa ser pré-armazenado com segurança, enquanto os recursos dinâmicos que modificam o shell são carregados de forma independente.
Quebre o cache das suas solicitações de pré-cache
Quando sw-precache
faz solicitações de recursos para pré-cache, ele usa essas
respostas indefinidamente, desde que ele ache que o hash MD5 do arquivo não
mudou. Isso significa que é particularmente importante garantir que a resposta à
solicitação de pré-cache seja nova e não retornada do cache HTTP
do navegador. Sim, as solicitações fetch()
feitas em um worker de serviço podem responder com
dados do cache HTTP do navegador.
Para garantir que as respostas que armazenamos em cache sejam diretamente da rede, e não do
cache HTTP do navegador, o sw-precache
automaticamente
anexa um parâmetro de consulta de quebra de cache
a cada URL solicitado. Se você não estiver usando sw-precache
e estiver
usando uma estratégia de resposta de cache-first, faça algo semelhante
no seu próprio código.
Uma solução mais limpa para a quebra de cache seria definir o
modo de cache
de cada Request
usado para pré-armazenamento em cache para reload
, o que garante que a
resposta venha da rede. No entanto, no momento, a opção de modo de cache
não é compatível
com o Chrome.
Suporte para login e logout
O IOWA permitia que os usuários fizessem login usando as Contas do Google e atualizassem as programações de eventos personalizadas, mas também significava que eles poderiam sair mais tarde. O armazenamento em cache de dados de respostas personalizadas é obviamente um assunto complicado, e não há sempre uma única abordagem correta.
Como a visualização da sua programação pessoal, mesmo off-line, era essencial para a experiência da IOWA, decidimos que o uso de dados em cache era adequado. Quando um usuário sai, limpamos os dados da sessão armazenados em cache anteriormente.
self.addEventListener('message', function(event) {
if (event.data === 'clear-cached-user-data') {
caches.open(toolbox.options.cacheName).then(function(cache) {
cache.keys().then(function(requests) {
return requests.filter(function(request) {
return request.url.indexOf('api/v1/user/') !== -1;
});
}).then(function(userDataRequests) {
userDataRequests.forEach(function(userDataRequest) {
cache.delete(userDataRequest);
});
});
});
}
});
Cuidado com parâmetros de consulta extras
Quando um worker de serviço verifica uma resposta em cache, ele usa um URL de solicitação como chave. Por padrão, o URL da solicitação precisa corresponder exatamente ao URL usado para armazenar a resposta em cache, incluindo todos os parâmetros de consulta na parte search do URL.
Isso acabou causando um problema durante o desenvolvimento, quando começamos a usar
parâmetros de URL para acompanhar de onde vinha nosso
tráfego. Por exemplo, adicionamos
o parâmetro utm_source=notification
aos URLs que foram abertos ao clicar em uma das
notificações e usamos utm_source=web_app_manifest
no start_url
para o manifesto do app da Web.
Os URLs que correspondiam às respostas armazenadas em cache apareciam como falhas quando esses parâmetros
eram anexados.
Isso é parcialmente abordado pela opção ignoreSearch
,
que pode ser usada ao chamar Cache.match()
. Infelizmente, o Chrome ainda não tem
suporte a ignoreSearch
. Mesmo que tivesse, o comportamento seria tudo ou nada. O que precisávamos era uma
maneira de ignorar alguns parâmetros de consulta de URL, mas considerar outros que fossem importantes.
Ampliamos sw-precache
para remover alguns parâmetros de consulta antes de verificar uma correspondência
de cache e permitir que os desenvolvedores personalizem quais parâmetros são ignorados pela opção
ignoreUrlParametersMatching
.
Confira a implementação:
function stripIgnoredUrlParameters(originalUrl, ignoredRegexes) {
var url = new URL(originalUrl);
url.search = url.search.slice(1)
.split('&')
.map(function(kv) {
return kv.split('=');
})
.filter(function(kv) {
return ignoredRegexes.every(function(ignoredRegex) {
return !ignoredRegex.test(kv[0]);
});
})
.map(function(kv) {
return kv.join('=');
})
.join('&');
return url.toString();
}
O que isso significa para você
A integração do worker de serviço no app da Web do Google I/O é provavelmente o uso mais complexo e real que foi implantado até o momento. Estamos ansiosos
para que a comunidade de desenvolvedores da Web use as ferramentas que criamos
sw-precache
e
sw-toolbox
, além das
técnicas que estamos descrevendo para impulsionar seus próprios aplicativos da Web.
Os service workers são uma melhoria progressiva
que você pode começar a usar hoje. Quando usados como parte de um app da Web estruturado
corretamente, a velocidade e os benefícios off-line são significativos para os usuários.