Livro de receitas off-line

Jake Archibald
Jake Archibald

Com o Service Worker, desistimos de tentar resolver o problema off-line e demos aos desenvolvedores as partes necessárias para resolvê-los por conta própria. Ela oferece controle sobre o armazenamento em cache e como as solicitações são processadas. Isso significa que você cria seus próprios padrões. Vejamos alguns padrões possíveis em isolamento, mas, na prática, você provavelmente usará muitos deles em conjunto, dependendo do URL e do contexto.

Para uma demonstração funcional de alguns desses padrões, consulte Treinado para emocionar e este vídeo que mostra o impacto no desempenho.

A máquina de cache: quando armazenar recursos

O service worker permite que você processe solicitações de forma independente do armazenamento em cache. Por isso, vou demonstrá-las separadamente. Para começar, armazenamento em cache: quando isso deve ser feito?

Na instalação: como uma dependência

Na instalação, como uma dependência
Na instalação: como uma dependência.

O service worker fornece um evento install. Você pode usar isso para preparar o que precisa estar pronto antes de processar outros eventos. Isso acontece, ainda que isso aconteça, qualquer versão anterior do Service Worker ainda esteja em execução e exibindo páginas. Portanto, o que você fizer aqui não poderá interromper isso.

Ideal para:CSS, imagens, fontes, JS, modelos ou qualquer elemento que você considere estático para essa "versão" do site.

São os itens que tornariam seu site completamente não funcional se não fossem buscados, itens que um app específico da plataforma equivalente 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 faz 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 permanecerá intacta. caches.open() e cache.addAll() retornam promessas. Se a busca de algum dos recursos não for feita, a chamada cache.addAll() será rejeitada.

Em trained-to-thrill, eu uso isso para armazenar em cache recursos estáticos.

Na instalação, não como uma dependência

Na instalação, não como uma dependência.
Na instalação, não como uma dependência.

Isso é semelhante ao exemplo acima, mas não atrasa a conclusão da instalação e não causa falha na instalação em caso de falha no armazenamento em cache.

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 ela falhe, o jogo ainda estará disponível off-line. Obviamente, você precisará considerar a possível ausência desses níveis e tentar armazená-los em cache novamente se estiverem ausentes.

O Service Worker pode ser eliminado durante o download dos níveis 11 a 20, já que ele terminou de processar eventos, o que significa que eles não serão armazenados em cache. No futuro, a API Web Periodic Background Synchronization vai lidar com casos como esse e downloads maiores, como filmes. No momento, essa API só tem suporte em bifurcações do Chromium.

Na ativação

Na ativação.
Na ativação.

Ideal para:limpeza e migração.

Depois que um novo Service Worker for instalado e uma versão anterior não estiver sendo usada, a nova será ativada e você receberá um evento activate. Como a versão antiga não está mais ativa, é um bom momento para lidar com migrações de esquema no IndexedDB e também excluir caches não utilizados.

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 os carregamentos de página. Mantenha a ativação o mais simples possível e use-a apenas para coisas que você não podia fazer enquanto a versão antiga estava ativa.

Em trained-to-thrill, eu uso isso para remover caches antigos.

Na interação do usuário

Na interação do usuário.
Na interação do usuário.

Ideal para:quando o site inteiro não pode ficar off-line e você permite que o usuário selecione o conteúdo que ele quer disponibilizar off-line. como um vídeo sobre o YouTube, um artigo na Wikipédia ou uma galeria específica no Flickr.

Disponibilize ao usuário um botão "Ler mais tarde" ou "Salvar para off-line". Quando for clicado, busque o que você precisa na rede e coloque-o 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 em páginas e em service workers, o que significa que você pode adicionar ao cache diretamente da página.

Na resposta da rede

Na resposta da rede.
Na resposta da rede.

Ideal para:atualizações frequentes de recursos, como a caixa de entrada do usuário ou o conteúdo de artigos. Também útil para conteúdo não essencial, como avatares, mas com cuidado.

Se uma solicitação não corresponder a nada no cache, acesse-a na rede, envie-a para a página e adicione-a ao cache ao mesmo tempo.

Se você fizer isso para um intervalo de URLs, como avatares, tenha cuidado para não sobrecarregar o armazenamento da origem. Se o usuário precisar recuperar o espaço em disco, você não quer ser o principal candidato. Exclua os itens do cache que não são mais necessários.

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, você só pode 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.

Em trained-to-thrill, eu uso isso para armazenar em cache imagens do Flickr.

Desatualizado ao revalidar

Desatualizado ao revalidar.
Revalidar durante o período está desatualizado.

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

Na mensagem push.
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 no site. Somente o service worker é ativado. Você solicita permissão para fazer isso em uma página, e o usuário será solicitado.

Ideal para:conteúdo relacionado a uma notificação, como uma mensagem de chat, uma notícia recente ou um e-mail. Também muda com pouca frequência o conteúdo que se beneficia da sincronização imediata, como uma atualização de lista de tarefas ou uma alteração de agenda.

O resultado final comum é uma notificação que, quando tocada, abre/foca uma página relevante. No entanto, a atualização dos caches antes que isso aconteça é extremely importante. Obviamente, o usuário está on-line no momento de receber a mensagem push, mas talvez não esteja quando finalmente 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

Na sincronização em segundo plano.
Na sincronização em segundo plano.

A sincronização em segundo plano é outro recurso baseado no Service Worker. Ele permite solicitar 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 no site. Somente o service worker é 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 regularidade, fazendo com que uma mensagem push por atualização seja muito frequente para os usuários, como linhas do tempo de redes sociais ou matérias jornalísticas.

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

Sua origem recebe uma certa quantidade de espaço livre para fazer o que quiser. Esse espaço livre é compartilhado entre todos os armazenamentos de origem: armazenamento(local), IndexedDB, acesso ao sistema de arquivos e, claro, caches.

O valor recebido não é especificado. Ele varia de acordo com o dispositivo e as condições de armazenamento. Você pode saber quanto consegue por meio de:

navigator.storageQuota.queryInfo('temporary').then(function (info) {
  console.log(info.quota);
  // Result: <quota in bytes>
  console.log(info.usage);
  // Result: <used data in bytes>
});

No entanto, como todo o armazenamento do navegador, ele está livre para jogar fora seus dados se o dispositivo estiver sob pressão de armazenamento. Infelizmente, o navegador não consegue distinguir entre os filmes que você quer manter a todo custo e o jogo com o qual você não se importa.

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 permissão. Para isso, use a API Permissions.

Tornar o usuário parte desse fluxo é importante, já que agora podemos esperar que ele esteja no controle da exclusão. Se o dispositivo estiver sob pressão de armazenamento e a limpeza de dados não essenciais não resolver o problema, o usuário poderá julgar quais itens manter e remover.

Para que isso funcione, é necessário que os sistemas operacionais tratem origens "duráveis" como equivalentes a apps específicos da plataforma nos detalhamentos do uso do armazenamento, em vez de informar o navegador como um único item.

Sugestões de exibição: resposta a solicitações

Não importa a quantidade de armazenamento em cache, o service worker não usará o cache, a menos que você informe quando e como. Confira alguns padrões para processar solicitações:

Somente cache

Somente cache.
Somente cache.

Ideal para:tudo que você considere estático para uma "versão" específica do seu site. Elas são armazenadas em cache no evento de instalação, então é possível confiar que elas estarão lá.

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 especificamente com esse caso, Cache, fallback para rede.

Somente rede

Somente rede.
Somente rede.

Ideal para:itens que não têm equivalente off-line, como pings de análise e solicitações diferentes de 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 especificamente com esse caso, Cache, fallback para rede.

Cache, voltando para a rede

Cache, voltando à rede.
Cache, revertendo para a rede.

Ideal para:criar priorização do modo off-line. Nesses casos, é assim que você vai lidar com 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 qualquer item não armazenado em cache (o que inclui todas as solicitações diferentes de GET, já que não podem ser armazenadas em cache).

Disputa entre cache e rede

Disputa entre cache e rede.
Corrida entre cache e rede.

Ideal para:recursos pequenos em que você busca desempenho em dispositivos com acesso lento ao disco.

Com algumas combinações de discos rígidos mais antigos, scanners de vírus e conexões de Internet mais rápidas, conseguir recursos da rede pode ser mais rápido do que ir para o disco. No entanto, acessar a rede quando o usuário tem o conteúdo no dispositivo pode ser um desperdício de dados. Portanto, 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)]));
});

Rede com retorno para o cache

Rede voltando para o cache.
Rede que volta para o cache.

Ideal para:uma solução rápida para recursos que são atualizados com frequência, fora da "versão" do site. Por exemplo, artigos, avatares, linhas do tempo de mídias sociais e quadros de liderança 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 armazenada em cache. Se a solicitação de rede for bem-sucedida, talvez você queira 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 que a rede falhe antes de receber o conteúdo perfeitamente aceitável que já está no dispositivo. Isso pode levar muito tempo e ser uma experiência do usuário frustrante. Consulte o próximo padrão, Cache, depois rede, para uma solução melhor.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    fetch(event.request).catch(function () {
      return caches.match(event.request);
    }),
  );
});

Cache, depois rede

Cache, depois rede.
Cache e depois rede.

Ideal para:conteúdo com atualizações frequentes. Por exemplo, artigos, linhas do tempo de mídias sociais e jogos. placares.

Isso exige que a página faça duas solicitações, uma para o cache e outra para a rede. A ideia é mostrar os dados armazenados em cache primeiro e, em seguida, atualizar a página quando/se os dados da rede chegarem.

Às vezes, é possível simplesmente substituir os dados atuais quando novos dados chegarem (por exemplo, o ranking do jogo), mas isso pode ser prejudicial com conteúdos maiores. Basicamente, não "desapareça" algo que o usuário possa estar 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, em sua maioria, uma ordem praticamente linear do conteúdo. Copiei esse padrão para trained-to-thrill, a fim de exibir o conteúdo na tela o mais rápido possível e exibir conteúdo atualizado assim que ele chegar.

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 durante o processo.

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;
      });
    }),
  );
});

Em trained-to-thrill, contornei isso usando XHR em vez de fetch e abusando do cabeçalho Accept para informar ao service worker de onde encontrar o resultado (código da página, código do service worker).

Substituto genérico

Substituto genérico.
Substituto genérico.

Se você não consegue disponibilizar algo do cache e/ou da rede, convém fornecer um substituto genérico.

Ideal para:imagens secundárias, como avatares, falhas em solicitações POST e uma página "Indisponível em modo 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 que você substituir provavelmente será uma dependência de instalação.

Se sua página estiver postando um e-mail, o service worker poderá voltar a 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 retidos.

Modelos do lado do service worker

Modelos do lado do ServiceWorker.
Modelos do lado do ServiceWorker.

Ideal para:páginas que não podem armazenar a resposta do servidor em cache.

A renderização de páginas no servidor agiliza o processo, mas isso pode significar a inclusão de dados de estado que podem não fazer sentido em um cache, por exemplo, "Conectado como...". Se sua página for controlada por um service worker, você poderá solicitar dados JSON com um modelo e renderizá-los.

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 está limitado a um desses métodos. Na verdade, você provavelmente usará muitos deles, dependendo do URL da solicitação. Por exemplo, train-to-thrill usa:

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ê recebe a imagem.

Créditos

... pelos ícones adoráveis:

Graças a Jeff Posnick por detectar vários erros lamentáveis antes de eu clicar em "publicar".

Leia mais