Aplicativos mais rápidos de várias páginas com streams

Atualmente, os sites ou apps da Web, se você preferir, tendem a usar um destes dois esquemas de navegação:

  • O esquema de navegação que os navegadores oferecem por padrão, ou seja, você digita um URL na barra de endereço do navegador e uma solicitação de navegação retorna um documento como resposta. Depois, você clica em um link, que descarrega o documento atual para outro, o ad infinitum.
  • O padrão de aplicativo de página única, que envolve uma solicitação de navegação inicial para carregar o shell do aplicativo e depende do JavaScript para preencher esse shell com marcação renderizada pelo cliente com conteúdo de uma API de back-end para cada "navegação".

Os benefícios de cada abordagem foram elogiados por seus defensores:

  • O esquema de navegação que os navegadores fornecem por padrão é resiliente, porque as rotas não exigem JavaScript para ser acessível. A renderização de marcação de cliente por JavaScript também pode ser um processo potencialmente caro, o que significa que dispositivos mais simples podem acabar em uma situação em que o conteúdo é atrasado porque o dispositivo está bloqueado para processar scripts que fornecem conteúdo.
  • Por outro lado, os aplicativos de página única (SPAs) oferecem uma navegação mais rápida após o carregamento inicial. Em vez de depender do navegador para descarregar um documento e criar um documento totalmente novo (e repeti-lo a cada navegação), eles podem oferecer uma experiência mais rápida e mais "parecida com um aplicativo", mesmo que seja necessário que o JavaScript funcione.

Nesta postagem, falaremos sobre um terceiro método que equilibra as duas abordagens descritas acima: depender de um service worker para pré-armazenar em cache os elementos comuns de um site, como marcações de cabeçalho e rodapé, e usar streams para fornecer uma resposta HTML para o cliente o mais rápido possível, tudo isso usando o esquema de navegação padrão do navegador.

Por que transmitir respostas HTML em um service worker?

Streaming é algo que seu navegador da Web já faz quando faz solicitações. Isso é extremamente importante no contexto das solicitações de navegação, porque garante que o navegador não seja bloqueado ao aguardar uma resposta inteira antes de começar a analisar a marcação de documentos e renderizar uma página.

Um diagrama representando HTML sem streaming versus HTML de streaming. No primeiro caso, todo o payload de marcação não é processado até que chegue. No segundo, a marcação é processada de modo incremental à medida que chega em pedaços da rede.

Para service workers, o streaming é um pouco diferente, porque usa a API Streams JavaScript. A tarefa mais importante que um service worker realiza é interceptar e responder a solicitações, incluindo solicitações de navegação.

Essas solicitações podem interagir com o cache de várias maneiras, mas um padrão de armazenamento em cache comum para marcação é favorecer o uso de uma resposta da rede primeiro, mas voltar ao cache se uma cópia mais antiga estiver disponível. Como opção, forneça uma resposta substituta genérica se uma resposta utilizável não estiver no cache.

Esse padrão de marcação funciona bem e, embora ajude com a confiabilidade em termos de acesso off-line, não oferece nenhuma vantagem de desempenho inerente para solicitações de navegação que dependem de uma estratégia que prioriza a rede ou apenas uma estratégia de rede. É aí que entra o streaming, e vamos ver como usar o módulo workbox-streams com tecnologia da API Streams no service worker do Workbox para acelerar as solicitações de navegação em um site de várias páginas.

Como detalhar uma página da Web típica

Em termos de estrutura, os sites tendem a ter elementos comuns em todas as páginas. Uma organização típica de elementos de página geralmente é assim:

  • Cabeçalho.
  • Conteúdo.
  • rodapé

Usando o web.dev como exemplo, esse detalhamento de elementos comuns fica assim:

Um detalhamento dos elementos comuns no site web.dev. As áreas comuns delineadas são marcadas como "header", "content" e "footer".

A meta por trás da identificação de partes de uma página é determinar o que pode ser pré-armazenado em cache e recuperado sem acessar a rede (ou seja, as marcações de cabeçalho e rodapé comuns em todas as páginas) e a parte da página que sempre vamos acessar a rede primeiro, o conteúdo, nesse caso.

Quando sabemos como segmentar as partes de uma página e identificar os elementos comuns, podemos criar um service worker que sempre recupera a marcação de cabeçalho e rodapé instantaneamente do cache e solicita apenas o conteúdo da rede.

Em seguida, usando a API Streams via workbox-streams, podemos unir todas essas partes e responder às solicitações de navegação instantaneamente, solicitando a quantidade mínima de marcação necessária da rede.

Como criar um service worker de streaming

Há muitas partes envolvidas no streaming de conteúdo parcial em um service worker, mas cada etapa do processo vai ser explorada em detalhes à medida que você avança, começando com a estrutura do site.

Segmentar seu site em parciais

Antes de começar a programar um service worker de streaming, é preciso fazer três coisas:

  1. Crie um arquivo contendo apenas a marcação de cabeçalho do site.
  2. Crie um arquivo contendo apenas a marcação de rodapé do seu site.
  3. Extraia o conteúdo principal de cada página para um arquivo separado ou configure seu back-end para disponibilizar condicionalmente apenas o conteúdo da página com base em um cabeçalho de solicitação HTTP.

A última etapa é a mais difícil, especialmente se o site for estático. Se esse for seu caso, será necessário gerar duas versões de cada página: uma terá a marcação de página completa e a outra conterá apenas o conteúdo.

Como criar um service worker de streaming

Se você não tiver instalado o módulo workbox-streams, faça isso junto aos módulos do Workbox instalados no momento. Para este exemplo específico, isso envolve os seguintes pacotes:

npm i workbox-navigation-preload workbox-strategies workbox-routing workbox-precaching workbox-streams --save

A próxima etapa é criar o novo service worker e pré-armazenar em cache as parciais de cabeçalho e rodapé.

Pré-armazenamento em cache de parciais

A primeira coisa que você vai fazer é criar um service worker na raiz do projeto com o nome sw.js (ou o nome de arquivo de sua preferência). Nele, você vai começar com o seguinte:

// sw.js
import * as navigationPreload from 'workbox-navigation-preload';
import {NetworkFirst} from 'workbox-strategies';
import {registerRoute} from 'workbox-routing';
import {matchPrecache, precacheAndRoute} from 'workbox-precaching';
import {strategy as composeStrategies} from 'workbox-streams';

// Enable navigation preload for supporting browsers
navigationPreload.enable();

// Precache partials and some static assets
// using the InjectManifest method.
precacheAndRoute([
  // The header partial:
  {
    url: '/partial-header.php',
    revision: __PARTIAL_HEADER_HASH__
  },
  // The footer partial:
  {
    url: '/partial-footer.php',
    revision: __PARTIAL_FOOTER_HASH__
  },
  // The offline fallback:
  {
    url: '/offline.php',
    revision: __OFFLINE_FALLBACK_HASH__
  },
  ...self.__WB_MANIFEST
]);

// To be continued...

Esse código faz algumas coisas:

  1. Ativa o pré-carregamento de navegação para navegadores compatíveis.
  2. Faz o pré-cache da marcação de cabeçalho e rodapé. Isso significa que a marcação de cabeçalho e rodapé de cada página será recuperada instantaneamente, já que não será bloqueada pela rede.
  3. Armazena recursos estáticos em cache no marcador __WB_MANIFEST que usa o método injectManifest.

Respostas de streaming

A maior parte de todo esse esforço é fazer com que o service worker transmita respostas concatenadas. Mesmo assim, o Workbox e a workbox-streams tornam esse processo muito mais sucinto do que se você tivesse que fazer tudo isso sozinho:

// sw.js
import * as navigationPreload from 'workbox-navigation-preload';
import {NetworkFirst} from 'workbox-strategies';
import {registerRoute} from 'workbox-routing';
import {matchPrecache, precacheAndRoute} from 'workbox-precaching';
import {strategy as composeStrategies} from 'workbox-streams';

// ...
// Prior navigation preload and precaching code omitted...
// ...

// The strategy for retrieving content partials from the network:
const contentStrategy = new NetworkFirst({
  cacheName: 'content',
  plugins: [
    {
      // NOTE: This callback will never be run if navigation
      // preload is not supported, because the navigation
      // request is dispatched while the service worker is
      // booting up. This callback will only run if navigation
      // preload is _not_ supported.
      requestWillFetch: ({request}) => {
        const headers = new Headers();

        // If the browser doesn't support navigation preload, we need to
        // send a custom `X-Content-Mode` header for the back end to use
        // instead of the `Service-Worker-Navigation-Preload` header.
        headers.append('X-Content-Mode', 'partial');

        // Send the request with the new headers.
        // Note: if you're using a static site generator to generate
        // both full pages and content partials rather than a back end
        // (as this example assumes), you'll need to point to a new URL.
        return new Request(request.url, {
          method: 'GET',
          headers
        });
      },
      // What to do if the request fails.
      handlerDidError: async ({request}) => {
        return await matchPrecache('/offline.php');
      }
    }
  ]
});

// Concatenates precached partials with the content partial
// obtained from the network (or its fallback response).
const navigationHandler = composeStrategies([
  // Get the precached header markup.
  () => matchPrecache('/partial-header.php'),
  // Get the content partial from the network.
  ({event}) => contentStrategy.handle(event),
  // Get the precached footer markup.
  () => matchPrecache('/partial-footer.php')
]);

// Register the streaming route for all navigation requests.
registerRoute(({request}) => request.mode === 'navigate', navigationHandler);

// Your service worker can end here, or you can add more
// logic to suit your needs, such as runtime caching, etc.

Esse código consiste em três partes principais que atendem aos seguintes requisitos:

  1. Uma estratégia NetworkFirst é usada para lidar com solicitações de parciais de conteúdo. Usando essa estratégia, um nome de cache personalizado de content é especificado para conter as parciais do conteúdo, bem como um plug-in personalizado que processa a definição de um cabeçalho de solicitação X-Content-Mode para navegadores que não oferecem suporte ao pré-carregamento de navegação e, portanto, não enviam um cabeçalho Service-Worker-Navigation-Preload. Este plug-in também descobre se deve enviar a última versão em cache de uma parte do conteúdo ou enviar uma página substituta off-line caso nenhuma versão em cache da solicitação atual seja armazenada.
  2. O método strategy em workbox-streams (chamado de composeStrategies aqui) é usado para concatenar as parciais de cabeçalho e rodapé pré-cache com o conteúdo parcial solicitado da rede.
  3. Todo o esquema é configurado por registerRoute para solicitações de navegação.

Com essa lógica em vigor, configuramos as respostas de streaming. Entretanto, talvez você precise realizar algumas tarefas no back-end para garantir que o conteúdo da rede seja uma página parcial que possa ser mesclada com as parciais que foram armazenadas em cache.

Se o site tem um back-end

Lembre-se de que, quando o pré-carregamento da navegação está ativado, o navegador envia um cabeçalho Service-Worker-Navigation-Preload com um valor true. No entanto, no exemplo de código acima, enviamos um cabeçalho personalizado de X-Content-Mode porque o pré-carregamento de navegação não é compatível com um navegador. No back-end, você alteraria a resposta com base na presença desses cabeçalhos. Em um back-end PHP, o código pode ter a seguinte aparência para uma determinada página:

<?php
// Check if we need to render a content partial
$navPreloadSupported = isset($_SERVER['HTTP_SERVICE_WORKER_NAVIGATION_PRELOAD']) && $_SERVER['HTTP_SERVICE_WORKER_NAVIGATION_PRELOAD'] === 'true';
$partialContentMode = isset($_SERVER['HTTP_X_CONTENT_MODE']) && $_SERVER['HTTP_X_CONTENT_MODE'] === 'partial';
$isPartial = $navPreloadSupported || $partialContentMode;

// Figure out whether to render the header
if ($isPartial === false) {
  // Get the header include
  require_once($_SERVER['DOCUMENT_ROOT'] . '/includes/site-header.php');

  // Render the header
  siteHeader();
}

// Get the content include
require_once('./content.php');

// Render the content
content($isPartial);

// Figure out whether to render the footer
if ($isPartial === false) {
  // Get the footer include
  require_once($_SERVER['DOCUMENT_ROOT'] . '/includes/site-footer.php');

  // Render the footer
  siteFooter();
}
?>

No exemplo acima, as partes parciais do conteúdo são invocadas como funções, que usam o valor de $isPartial para mudar a forma como as parciais são renderizadas. Por exemplo, a função do renderizador content pode incluir apenas determinadas marcações em condições quando recuperadas como parciais. Vamos falar sobre isso em breve.

considerações

Antes de implantar um service worker para transmitir e agrupar parciais, é preciso considerar o seguinte. Embora seja verdade que usar um service worker dessa maneira não mude fundamentalmente o comportamento de navegação padrão do navegador, você provavelmente precisará resolver alguns problemas.

Atualizar elementos da página durante a navegação

A parte mais complicada dessa abordagem é que algumas coisas precisam ser atualizadas no cliente. Por exemplo, a marcação de cabeçalho com pré-armazenamento em cache significa que a página terá o mesmo conteúdo no elemento <title>. Também é necessário atualizar os estados de ativação/desativação de itens de navegação a cada navegação. Esses e outros itens podem precisar ser atualizados no cliente para cada solicitação de navegação.

Uma forma de contornar isso pode ser colocar um elemento <script> inline no conteúdo parcial que vem da rede para atualizar alguns itens importantes:

<!-- The JSON below contains information about the current page. -->
<script id="page-data" type="application/json">'{"title":"Sand Wasp &mdash; World of Wasps","description":"Read all about the sand wasp in this tidy little post."}'</script>
<script>
  const pageData = JSON.parse(document.getElementById('page-data').textContent);

  // Update the page title
  document.title = pageData.title;
</script>
<article>
  <!-- Page content omitted... -->
</article>

Esse é apenas um exemplo do que talvez você precise fazer se decidir manter a configuração do service worker. Para aplicativos mais complexos com informações do usuário, por exemplo, talvez seja necessário armazenar bits de dados relevantes em uma loja on-line, como a localStorage, e atualizar a página nessa loja.

Como lidar com redes lentas

Uma desvantagem das respostas de streaming que usam marcação do pré-cache pode ocorrer quando as conexões de rede estão lentas. O problema é que a marcação de cabeçalho do pré-cache chega instantaneamente, mas o conteúdo parcial da rede pode levar algum tempo para chegar após a exibição inicial da marcação de cabeçalho.

Isso pode criar uma experiência confusa e, se as redes estiverem muito lentas, pode até parecer que a página está corrompida e sem renderização alguma. Nesses casos, é possível inserir uma mensagem ou um ícone de carregamento na marcação do parcial do conteúdo, que você poderá ocultar quando o conteúdo for carregado.

Uma maneira de fazer isso é usando CSS. Digamos que a parte do cabeçalho termine com um elemento <article> de abertura que esteja vazio até que a parte do conteúdo chegue para preenchê-lo. É possível criar uma regra CSS semelhante a esta:

article:empty::before {
  text-align: center;
  content: 'Loading...';
}

Isso funciona, mas vai mostrar uma mensagem de carregamento no cliente, independente da velocidade da rede. Para evitar mensagens estranhas, tente essa abordagem em que aninhamos o seletor no snippet acima em uma classe slow:

.slow article:empty::before {
  text-align: center;
  content: 'Loading...';
}

A partir daqui, você pode usar o JavaScript na parte do cabeçalho para ler o tipo de conexão efetiva (pelo menos em navegadores Chromium) para adicionar a classe slow ao elemento <html> em alguns tipos de conexão:

<script>
  const effectiveType = navigator?.connection?.effectiveType;

  if (effectiveType !== '4g') {
    document.documentElement.classList.add('slow');
  }
</script>

Isso vai garantir que os tipos de conexão eficazes mais lentos do que o 4g recebam uma mensagem de carregamento. Em seguida, na parte parcial do conteúdo, você pode colocar um elemento <script> inline para remover a classe slow do HTML e eliminar a mensagem de carregamento:

<script>
  document.documentElement.classList.remove('slow');
</script>

Como fornecer uma resposta substituta

Digamos que você esteja usando uma estratégia que priorize a rede para suas partes parciais do conteúdo. Se o usuário estiver off-line e acessar uma página em que já esteve, ele estará coberto. No entanto, se ele for para uma página que ainda não tenha acessado, não receberá nada. Para evitar isso, é preciso veicular uma resposta substituta.

O código necessário para obter uma resposta de fallback é demonstrado em exemplos de código anteriores. O processo tem duas etapas:

  1. Pré-cache de uma resposta de fallback off-line.
  2. Configure um callback handlerDidError no plug-in para sua estratégia de priorização da rede e verifique o cache da última versão acessada da página. Se a página nunca tiver sido acessada, você vai precisar usar o método matchPrecache do módulo workbox-precaching para recuperar a resposta de fallback do pré-cache.

Armazenamento em cache e CDNs

Se você estiver usando esse padrão de streaming no service worker, avalie se o seguinte se aplica à sua situação:

  • Você usa uma CDN ou qualquer outro tipo de cache intermediário/público.
  • Você especificou um cabeçalho Cache-Control com diretivas max-age e/ou s-maxage diferentes de zero em combinação com a diretiva public.

Se ambos os casos se aplicam a você, o cache intermediário pode manter as respostas para solicitações de navegação. No entanto, lembre-se de que ao usar esse padrão, talvez você exiba duas respostas diferentes para um determinado URL:

  • A resposta completa, contendo a marcação de cabeçalho, conteúdo e rodapé.
  • A resposta parcial, que contém apenas o conteúdo.

Isso pode causar alguns comportamentos indesejados, resultando em marcações duplicadas de cabeçalho e rodapé, porque o service worker pode estar buscando uma resposta completa do cache da CDN e combinando-a com a marcação de cabeçalho e rodapé pré-cache.

Para contornar esse problema, use o cabeçalho Vary, que afeta o comportamento do armazenamento em cache chaveando respostas armazenáveis em cache para um ou mais cabeçalhos que estavam presentes na solicitação. Como estamos variando as respostas às solicitações de navegação com base nos cabeçalhos de solicitação Service-Worker-Navigation-Preload e X-Content-Mode personalizados, precisamos especificar este cabeçalho Vary na resposta:

Vary: Service-Worker-Navigation-Preload,X-Content-Mode

Com esse cabeçalho, o navegador vai diferenciar entre respostas completas e parciais para solicitações de navegação, evitando problemas com marcações de cabeçalho e rodapé duplicadas, assim como qualquer cache intermediário.

O resultado

A maioria das recomendações de desempenho do tempo de carregamento se resume a "mostrar o que você tem a fazer". Não tenha medo, não espere até ter tudo para mostrar algo ao usuário.

Jake Archibald em Fun Hacks for Faster Content

Os navegadores se destacam quando se trata de lidar com respostas a solicitações de navegação, mesmo para corpos de resposta HTML enormes. Por padrão, os navegadores transmitem e processam progressivamente marcações em blocos que evitam tarefas longas, o que é bom para o desempenho da inicialização.

Isso funciona a nosso favor quando usamos um padrão de service worker de streaming. Sempre que você responde a uma solicitação do cache do service worker desde o início, o início da resposta chega quase instantaneamente. Ao unir as marcações de cabeçalho e rodapé pré-carregadas com uma resposta da rede, você obtém algumas vantagens notáveis de desempenho:

Arquiteturas de várias páginas de streaming podem ser um pouco complicadas de configurar e iterar, mas a complexidade envolvida geralmente é mais onerosa do que os SPAs, em teoria. O principal benefício é que você não está substituindo o esquema de navegação padrão do navegador, apenas o aprimora.

Melhor ainda, o Workbox torna essa arquitetura possível e mais fácil do que se você implementá-la por conta própria. Faça um teste no seu próprio site e veja como um site de várias páginas pode ser mais rápido para os usuários em campo.

Recursos