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 o shell do aplicativo 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 pelos seus defensores:

  • O esquema de navegação que os navegadores oferecem por padrão é resiliente, porque as rotas não exigem que o JavaScript seja acessível. A renderização pelo cliente de marcação por meio de 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 atrasa porque o dispositivo está bloqueado para processar scripts que fornecem conteúdo.
  • Por outro lado, os aplicativos de página única (SPAs) podem oferecer navegações mais rápidas após o carregamento inicial. Em vez de depender do navegador para descarregar um documento totalmente novo (e repetir isso para cada navegação), eles podem oferecer o que parece uma versão mais rápida e semelhante a um aplicativo experiência, mesmo que isso exija JavaScript para funcionar.

Nesta postagem, falaremos sobre um terceiro método que equilibra as duas abordagens descritas acima: contar com 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 ao 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 o seu navegador da Web já faz quando faz solicitações. Isso é extremamente importante no contexto de solicitações de navegação, pois garante que o navegador não fique bloqueado esperando toda uma resposta 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, o payload de marcação inteiro não é processado até que ele chegue. No segundo, a marcação é processada de forma incremental à medida que chega em blocos da rede.

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

Essas solicitações podem interagir com o cache de várias maneiras. No entanto, um padrão comum de armazenamento em cache para marcação é favorecer o uso de uma resposta da rede primeiro, mas retornar 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 é um padrão testado por tempo para marcação que funciona bem, mas, 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 somente da 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 no seu site de várias páginas.

Detalhamento de uma página da Web típica

Em termos estruturais, os sites tendem a ter elementos comuns em todas as páginas. Em geral, uma organização típica dos elementos de página é semelhante a esta:

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

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

Um detalhamento dos elementos comuns no site web.dev. As áreas comuns delineadas são marcadas como "cabeçalho", "conteúdo" e "rodapé".

O objetivo 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, a marcação de cabeçalho e rodapé comum a todas as páginas — e a parte da página pela qual sempre vamos acessar a rede primeiro — o conteúdo neste caso.

Quando sabemos como segmentar as partes de uma página e identificar os elementos comuns, podemos criar um service worker que sempre recupere a marcação de cabeçalho e rodapé instantaneamente do cache enquanto 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 mudanças no streaming de conteúdo parcial em um service worker, mas cada etapa do processo será explorada em detalhes à medida que você avança, começando com a estrutura do seu site.

Segmentar seu site em partes parciais

Antes de começar a escrever um service worker de streaming, você precisa fazer três coisas:

  1. Crie um arquivo com apenas a marcação de cabeçalho do seu site.
  2. Crie um arquivo com apenas a marcação de rodapé do seu site.
  3. Extraia o conteúdo principal de cada página em 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.
.

Como você pode esperar, a última etapa é a mais difícil, especialmente se o site for estático. Se esse for o 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, somente o conteúdo.

Como criar um service worker de streaming

Se você não instalou o módulo workbox-streams, será necessário fazer isso além dos módulos da caixa de trabalho que você tem atualmente instalados. 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 partir daqui, a próxima etapa é criar seu novo service worker e pré-armazenar em cache suas partes de cabeçalho e rodapé.

Dados parciais de pré-armazenamento em cache

Primeiro, crie um service worker na raiz do seu projeto com o nome sw.js (ou o nome de arquivo que você preferir). Nele, você 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 em navegadores compatíveis.
  2. Pré-armazena a marcação de cabeçalho e rodapé em cache. Isso significa que as marcações de cabeçalho e rodapé de cada página serão recuperadas instantaneamente, já que não serão bloqueadas pela rede.
  3. Pré-armazena recursos estáticos em cache no marcador __WB_MANIFEST que usa o método injectManifest.
.

Respostas de streaming

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

// 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 é composto por três partes principais que atendem aos seguintes requisitos:

  1. Uma estratégia NetworkFirst é usada para processar solicitações de partes parciais de conteúdo. Usando essa estratégia, um nome de cache personalizado de content é especificado para conter as parciais de 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 em cache 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 (neste caso, com alias como composeStrategies) é usado para concatenar as parciais de cabeçalho e rodapé pré-armazenadas em cache com a parte de conteúdo solicitada 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. No entanto, pode ser necessário realizar algum trabalho no back-end para garantir que o conteúdo da rede seja uma página parcial que você pode mesclar com as parciais armazenadas em cache.

Caso seu site tenha um back-end

Lembre-se de que, quando o pré-carregamento de navegação está ativado, o navegador envia um cabeçalho Service-Worker-Navigation-Preload com um valor de true. No entanto, no exemplo de código acima, enviamos um cabeçalho personalizado de X-Content-Mode no pré-carregamento de navegação do evento não compatível com um navegador. No back-end, altere a resposta com base na presença desses cabeçalhos. Em um back-end de PHP, isso pode ser semelhante ao seguinte 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 parciais de 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 nas condições quando recuperada como parcial. Isso vai ser abordado em breve.

Considerações

Antes de implantar um service worker para transmitir e agrupar partes parciais, você precisa considerar algumas coisas. Embora seja verdade que o uso de um service worker dessa maneira não altera fundamentalmente o comportamento de navegação padrão do navegador, há algumas coisas que você provavelmente precisará resolver.

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

A parte mais complicada desta abordagem é que algumas coisas vão precisar ser atualizadas no cliente. Por exemplo, a marcação de cabeçalho de pré-armazenamento em cache significa que a página terá o mesmo conteúdo no elemento <title> ou até mesmo o gerenciamento de estados de ativação/desativação dos itens de navegação terá que ser atualizado em cada navegação. Esses e outros itens podem precisar ser atualizados no cliente para cada solicitação de navegação.

A maneira de contornar isso pode ser colocar um elemento <script> in-line na parcial de conteúdo que venha da rede para atualizar algumas coisas 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 você precisará fazer se decidir usar essa configuração de 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 o localStorage e atualizar a página a partir dela.

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 sã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 pintura 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 não está mais renderizando. Em casos como esse, você pode optar por colocar um ícone ou mensagem de carregamento na marcação parcial do conteúdo que pode ser ocultada assim que o conteúdo for carregado.

Uma maneira de fazer isso é através do CSS. Digamos que a parte do cabeçalho termine com um elemento <article> de abertura que ficará vazio até que a parcial de conteúdo chegue para preenchê-lo. Você pode escrever uma regra CSS semelhante a esta:

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

Isso funciona, mas mostra uma mensagem de carregamento no cliente, independentemente da velocidade da rede. Se você quiser evitar mensagens estranhas, tente esta 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 JavaScript no cabeçalho parcial 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 garante que tipos de conexão eficazes mais lentos que o tipo 4g recebam uma mensagem de carregamento. Em seguida, na parte de conteúdo parcial, você pode colocar um elemento <script> in-line 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 prioriza a rede para partes parciais do seu conteúdo. Se o usuário estiver off-line e acessar uma página em que já esteve, ele não será incluído. No entanto, se eles acessarem uma página que não acessaram ainda, não receberão nada. Para evitar isso, você precisará exibir uma resposta substituta.

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

  1. Pré-armazenar em cache uma resposta substituta off-line.
  2. Configure um callback handlerDidError no plug-in para sua estratégia que prioriza a rede para verificar o cache da última versão acessada por último. Se a página nunca tiver sido acessada, será necessário usar o método matchPrecache do módulo workbox-precaching para recuperar a resposta substituta 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 forem seu caso, o cache intermediário poderá reter respostas para solicitações de navegação. No entanto, ao usar esse padrão, você poderá exibir duas respostas diferentes para qualquer URL:

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

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

Para contornar esse problema, use o cabeçalho Vary, que afeta o comportamento de armazenamento em cache ao codificar respostas armazenáveis em cache para um ou mais cabeçalhos 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 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.

Resultado

A maior parte das recomendações sobre desempenho no tempo de carregamento se resume a "mostrar o que você ofereceu". 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 em HTML enormes. Por padrão, os navegadores transmitem e processam progressivamente a marcação em blocos que evitam tarefas longas, o que é bom para o desempenho da inicialização.

Isso é vantajoso para nós 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 juntar as marcações de cabeçalho e rodapé pré-armazenadas em cache com uma resposta da rede, você obtém algumas vantagens notáveis de desempenho:

  • O Tempo até o primeiro byte (TTFB, na sigla em inglês) é bastante reduzido, já que o primeiro byte da resposta a uma solicitação de navegação é instantâneo.
  • A First Contentful Paint (FCP, na sigla em inglês) será muito rápida, porque a marcação de cabeçalho pré-armazenada em cache conterá uma referência a uma folha de estilo armazenada em cache, o que significa que a página será exibida muito rapidamente.
  • Em alguns casos, a Largest Contentful Paint (LCP) também pode ser mais rápida, especialmente se o maior elemento na tela for fornecido pela parcial do cabeçalho pré-armazenada. Mesmo assim, apenas disponibilizar algo fora do cache do service worker o mais rápido possível em conjunto com payloads de marcação menores pode resultar em uma LCP melhor.

Arquiteturas de streaming de várias páginas podem ser um pouco complicadas de configurar e iterar, mas a complexidade envolvida muitas vezes não é 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, mas sim aprimorando.

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

Recursos