Livro de receitas off-line

Jake Archibald
Jake Archibald

Com o Service Worker, desistimos de tentar resolver a questão do off-line e disponibilizamos aos desenvolvedores as peças necessárias para que eles mesmos resolvessem essa questão. Ele oferece controle sobre o armazenamento em cache e como as solicitações são processadas. Isso significa que você pode criar seus próprios padrões. Vamos analisar alguns padrões possíveis no 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 Trained-to-thrill e este vídeo mostrando o impacto sobre o desempenho.

A máquina de cache: quando armazenar recursos

O Service Worker permite processar solicitações de forma independente do cache. Vou demonstrar isso separadamente. Para começar, armazenamento em cache: quando 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 oferece um evento install. Você pode usar isso para preparar o que precisa estar pronto antes de processar outros eventos. Enquanto isso acontece, todas as versões anteriores do Service Worker ainda estão executando e exibindo páginas. Portanto, o que você fizer agora não pode interferir com essas atividades.

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

São os itens que deixariam seu site totalmente inoperante se a busca por eles não fosse feita, itens que um app equivalente específico da plataforma 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 ficará intacta). caches.open() e cache.addAll() retornam promessas. Se a recuperação de um dos recursos falhar, a chamada cache.addAll() será rejeitada.

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

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.

Semelhante à anterior, mas sem retardar a conclusão da instalação e sem provocar erros na instalação em caso de falha no armazenamento em cache.

Ideal para:recursos maiores que não são imediatamente necessários, 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 falhe, o jogo ainda ficará disponível off-line. Naturalmente, você terá de considerar a possível ausência desses níveis e tentar armazená-los em cache de novo se estiverem ausentes.

O Service Worker pode ser encerrado durante o download dos níveis 11 a 20, porque já concluiu o processamento dos eventos, o que significa que eles não serão armazenados em cache. No futuro, a API Periodic Background Sync da Web vai processar casos como esse e downloads maiores, como filmes. No momento, essa API só é compatível com bifurcações do Chromium.

Ao ativar

Ao ativar.
Ao ativar.

Ideal para: limpeza e migração.

Depois que um novo Service Worker é instalado e uma versão anterior não está sendo usada, o novo é ativado e você recebe um evento activate. Como a versão antiga está desativada, é um bom momento para processar 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 o carregamento da página. Mantenha a ativação o mais simples possível e use-a apenas para atividades que não podem ser feitas enquanto a versão antiga estava ativa.

Em trained-to-thrill, 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 não é possível que o site todo fique off-line e você escolheu permitir que o usuário selecione o conteúdo que quer disponibilizar off-line. Por exemplo, um vídeo sobre um tópico no YouTube, um artigo na Wikipédia, uma determinada galeria no Flickr.

Ofereça ao usuário um botão "Ler mais tarde" ou "Salvar para uso off-line". Quando clicado, recupere o que for necessário da rede e armazene-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 caches está disponível em páginas e em service workers. Isso 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 de um usuário ou o conteúdo de artigos. Também é útil para conteúdo não essencial, como avatares, mas é preciso tomar cuidado.

Se um item solicitado não estiver no cache, busque-o na rede, envie-o para a página e adicione-o ao cache ao mesmo tempo.

Se você fizer isso para um grupo de URLs, como avatares, será necessário tomar cuidado para não ocupar excessivamente a memória da origem. Se o usuário precisar recuperar espaço em disco, não seja o candidato principal. Não deixe de descartar itens desnecessários do cache.

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 possibilitar o uso eficiente de memória, você pode ler um corpo de resposta/solicitação apenas uma vez. O código acima usa .clone() para criar cópias adicionais que podem ser lidas separadamente.

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

Stale-while-revalidate

Stale-while-revalidate.
Stale-while-revalidate.

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 armazenada em cache disponível, use-a, mas recupere 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;
      });
    }),
  );
});

Esse processo é muito semelhante ao stale-while-revalidate do HTTP.

Na mensagem push

Na mensagem push.
Na mensagem push.

A API Push é outro recurso baseado no service worker. Isso permite que o service worker seja despertado em resposta a uma mensagem do serviço de mensagens do SO. Isso acontece mesmo se o usuário não tiver 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 se aplica a conteúdo com poucas mudanças que se beneficie com uma sincronização imediata, como uma atualização de lista de tarefas ou uma alteração de calendário.

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 é extremamente 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. Com ele, é possível solicitar a sincronização de dados em segundo plano 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á notificado.

Ideal para:atualizações não urgentes, principalmente aquelas que ocorrem com tanta frequência que uma mensagem push por atualização seria muito frequente para os usuários, como linhas do tempo sociais ou artigos de notícias.

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 determinada 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. Isso varia de acordo com o dispositivo e as condições de armazenamento. Você pode descobrir quanto você recebeu por meio de:

if (navigator.storage && navigator.storage.estimate) {
  const quota = await navigator.storage.estimate();
  // quota.usage -> Number of bytes used.
  // quota.quota -> Maximum number of bytes available.
  const percentageUsed = (quota.usage / quota.quota) * 100;
  console.log(`You've used ${percentageUsed}% of the available storage.`);
  const remaining = quota.quota - quota.usage;
  console.log(`You can write up to ${remaining} more bytes.`);
}

No entanto, como todo o armazenamento do navegador, esse tem liberdade de descartar seus dados se o dispositivo ficar com pouco armazenamento disponível. O navegador não consegue distinguir entre os filmes que você quer manter a qualquer custo e o jogo com o qual você não se preocupa muito.

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

Obviamente, o usuário precisa conceder permissão. Para isso, use a API Permissions.

Fazer o usuário participar desse fluxo é importante, já que agora podemos esperar que ele esteja no controle da exclusão. Se o dispositivo estiver com pouco armazenamento e a limpeza de dados não essenciais não resolver o problema, o usuário poderá decidir 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 no detalhamento do uso de armazenamento, em vez de relatar o navegador como um único item.

Sugestões de exibição: como responder 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 determinada "versão" do site. Você deve ter armazenado em cache esses itens no evento de instalação. Portanto, pode confiar que eles 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ê nem sempre precise processar esse caso especificamente, Cache, fallback para rede fará isso.

Somente rede

Somente rede.
Somente na rede.

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

Cache, fallback para rede

cache, com retorno à rede.
Cache, com retorno para a rede.

Ideal para:criar apps que priorizam o modo off-line. Nesses casos, é assim que você vai processar 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 os itens no cache e o comportamento "somente rede" para tudo que não estiver armazenado em cache (o que inclui todas as solicitações diferentes de GET, porque elas não podem ser armazenadas em cache).

Disputa entre cache e rede

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

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

Em algumas combinações de discos rígidos mais antigos, antivírus e conexões mais rápidas com a Internet, buscar recursos da rede pode ser mais rápido do que acessar 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, considere isso.

// 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 fallback para cache

Rede com retorno ao cache.
Rede com fallback para 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ídia social e placares 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 anterior armazenada em cache. Se a solicitação de rede for bem-sucedida, provavelmente você vai querer atualizar a entrada do cache.

No entanto, esse método tem falhas. Se o usuário tiver uma conexão intermitente ou lenta, ele terá de esperar por uma falha de rede até receber o conteúdo perfeitamente aceitável que já está no dispositivo. Isso pode demorar muito 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 e depois rede.
Armazene em cache e depois na rede.

Ideal para:conteúdo atualizado com frequência. Por exemplo, artigos, linhas do tempo de mídia social e placares de jogos.

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

Às vezes, você pode simplesmente substituir os dados atuais quando chegam novos dados (por exemplo, o placar do jogo), mas isso pode ser prejudicial com conteúdos maiores. Basicamente, não "desapareça" em algo que o usuário possa ler ou interagir.

O Twitter adiciona o novo conteúdo sobre o conteú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, na maioria das vezes, uma ordem quase linear do conteúdo. Copiei esse padrão de trained-to-thrill para mostrar 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:

Você sempre deve acessar a rede e atualizar o cache à medida que avança.

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 esse problema usando XHR em vez de fetch e abusando do cabeçalho Accept para informar o Service Worker de onde buscar o resultado (código da página, código do Service Worker).

Fallback genérico

Substituto genérico.
Fallback genérico.

Se ocorrer uma falha na disponibilização de algum item do cache e/ou da rede, você poderá oferecer um fallback genérico.

Ideal para:imagens secundárias, como avatares, falha em solicitações POST e uma página "Não disponí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 para o qual você fizer fallback será provavelmente uma dependência de instalação.

Se a 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 com êxito.

Modelos do lado do service worker

Modelos do lado do service worker.
Modelos do lado do ServiceWorker.

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

A renderização de páginas no servidor acelera tudo, mas isso pode significar a inclusão de dados de estado que podem não fazer sentido em um cache, por exemplo, "Logado 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 precisa usar apenas um desses métodos. Na verdade, é provável que você use muitas delas, dependendo do URL da solicitação. Por exemplo, trained-to-thrill usa:

Basta examinar a solicitação e escolher 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ê sabe.

Créditos

... pelos ícones lindos:

Agradeço a Jeff Posnick por detectar vários erros lamentáveis antes de eu clicar em "Publicar".

Leitura adicional