Melhoria no desempenho do carregamento de páginas do Next.js e do Gatsby com agrupamento granular

Uma estratégia mais recente de divisão em blocos do webpack no Next.js e no Gatsby minimiza o código duplicado para melhorar o carregamento de página.

O Chrome está colaborando com ferramentas e frameworks no ecossistema de código aberto do JavaScript. Várias otimizações mais recentes foram adicionadas para melhorar o desempenho de carregamento do Next.js e do Gatsby. Este artigo aborda uma estratégia de agrupamento granular aprimorada que agora é enviada por padrão nos dois frameworks.

Introdução

Como muitos frameworks da Web, o Next.js e o Gatsby usam o webpack como o principal empacotador. O webpack v3 introduziu o CommonsChunkPlugin para possibilitar a saída de módulos compartilhados entre diferentes pontos de entrada em um único (ou poucos) bloco (ou blocos) "comum". O código compartilhado pode ser baixado separadamente e armazenado no processo de cache do navegador logo no início, o que pode resultar em um melhor desempenho de carregamento.

Esse padrão se tornou popular com muitos frameworks de aplicativos de página única que adotaram um ponto de entrada e uma configuração de pacote como esta:

Configuração comum de ponto de entrada e pacote

Embora seja prático, o conceito de agrupar todo o código do módulo compartilhado em um único bloco tem limitações. Os módulos não compartilhados em todos os pontos de entrada podem ser baixados para rotas que não os usam, resultando no download de mais código do que o necessário. Por exemplo, quando page1 carrega o bloco common, ele carrega o código para moduleC, mesmo que page1 não use moduleC. Por esse e outros motivos, o webpack v4 removeu o plug-in e o substituiu por um novo: SplitChunksPlugin.

Divisão em partes aprimorada

As configurações padrão do SplitChunksPlugin funcionam bem para a maioria dos usuários. Vários blocos divididos são criados dependendo de uma série de condições para evitar a busca de código duplicado em várias rotas.

No entanto, muitos frameworks da Web que usam esse plug-in ainda seguem uma abordagem de "commons único" para a divisão de blocos. O Next.js, por exemplo, geraria um pacote commons que conteria qualquer módulo usado em mais de 50% das páginas e todas as dependências do framework (react, react-dom etc.).

const splitChunksConfigs = {
  …
  prod: {
    chunks: 'all',
    cacheGroups: {
      default: false,
      vendors: false,
      commons: {
        name: 'commons',
        chunks: 'all',
        minChunks: totalPages > 2 ? totalPages * 0.5 : 2,
      },
      react: {
        name: 'commons',
        chunks: 'all',
        test: /[\\/]node_modules[\\/](react|react-dom|scheduler|use-subscription)[\\/]/,
      },
    },
  },

Embora a inclusão de código dependente do framework em um bloco compartilhado signifique que ele pode ser baixado e armazenado em cache para qualquer ponto de entrada, a heurística baseada em uso de incluir módulos comuns usados em mais da metade das páginas não é muito eficaz. Modificar essa proporção resultaria em apenas um de dois resultados:

  • Se você reduzir a proporção, mais código desnecessário será transferido por download.
  • Se você aumentar a proporção, mais código será duplicado em várias rotas.

Para resolver esse problema, o Next.js adotou uma configuração diferente para o SplitChunksPlugin que reduz o código desnecessário para qualquer rota.

  • Qualquer módulo de terceiros suficientemente grande (maior que 160 KB) é dividido em um bloco individual.
  • Um bloco frameworks separado é criado para dependências de framework (react, react-dom etc.).
  • São criados quantos blocos compartilhados forem necessários (até 25)
  • O tamanho mínimo para gerar um bloco foi alterado para 20 KB

Essa estratégia de agrupamento granular oferece os seguintes benefícios:

  • Os tempos de carregamento da página são reduzidos. Emitir vários blocos compartilhados, em vez de um único, minimiza a quantidade de código desnecessário (ou duplicado) para qualquer ponto de entrada.
  • Melhoria no armazenamento em cache durante as navegações. Dividir grandes bibliotecas e dependências de framework em blocos separados reduz a possibilidade de invalidação do cache, já que é improvável que ambos mudem até que um upgrade seja feito.

Confira toda a configuração adotada pelo Next.js em webpack-config.ts.

Mais solicitações HTTP

O SplitChunksPlugin definiu a base para a fragmentação granular, e aplicar essa abordagem a um framework como o Next.js não era um conceito totalmente novo. No entanto, muitos frameworks ainda usavam uma única heurística e uma estratégia de pacote "comum" por alguns motivos. Isso inclui a preocupação de que muitas outras solicitações HTTP possam afetar negativamente o desempenho do site.

Os navegadores só podem abrir um número limitado de conexões TCP para uma única origem (6 para o Chrome). Portanto, minimizar o número de blocos gerados por um bundler pode garantir que o número total de solicitações permaneça abaixo desse limite. No entanto, isso só é válido para HTTP/1.1. A multiplexação em HTTP/2 permite que várias solicitações sejam transmitidas em paralelo usando uma única conexão em uma única origem. Em outras palavras, geralmente não precisamos nos preocupar em limitar o número de blocos emitidos pelo nosso bundler.

Todos os principais navegadores são compatíveis com HTTP/2. As equipes do Chrome e do Next.js queriam saber se o aumento do número de solicitações dividindo o pacote "commons" único do Next.js em vários blocos compartilhados afetaria o desempenho de carregamento de alguma forma. Eles começaram medindo o desempenho de um único site enquanto modificavam o número máximo de solicitações paralelas usando a propriedade maxInitialRequests.

Desempenho de carregamento de página com maior número de solicitações

Em uma média de três execuções de vários testes em uma única página da Web, os tempos de load, início da renderização e First Contentful Paint permaneceram os mesmos ao variar o número máximo de solicitações iniciais (de 5 a 15). Curiosamente, notamos uma pequena sobrecarga de desempenho somente depois de dividir agressivamente em centenas de solicitações.

Desempenho de carregamento de página com centenas de solicitações

Isso mostrou que ficar abaixo de um limite confiável (20 a 25 solicitações) atingiu o equilíbrio certo entre o desempenho de carregamento e a eficiência do armazenamento em cache. Após alguns testes de referência, 25 foi selecionado como a contagem de maxInitialRequest.

Modificar o número máximo de solicitações que acontecem em paralelo resultou em mais de um pacote compartilhado, e separá-los adequadamente para cada ponto de entrada reduziu significativamente a quantidade de código desnecessário para a mesma página.

Reduções de payload do JavaScript com aumento de agrupamento

Esse experimento foi apenas para modificar o número de solicitações e ver se haveria algum efeito negativo no desempenho de carregamento de página. Os resultados sugerem que definir maxInitialRequests como 25 na página de teste foi ideal porque reduziu o tamanho do payload do JavaScript sem deixar a página mais lenta. A quantidade total de JavaScript necessária para hidratar a página permaneceu aproximadamente a mesma, o que explica por que o desempenho de carregamento da página não melhorou necessariamente com a quantidade reduzida de código.

O webpack usa 30 KB como tamanho mínimo padrão para gerar um bloco. No entanto, acoplar um valor de maxInitialRequests de 25 a um tamanho mínimo de 20 KB resultou em um armazenamento em cache melhor.

Reduções de tamanho com blocos granulares

Muitos frameworks, incluindo o Next.js, usam o roteamento do lado do cliente (processado pelo JavaScript) para injetar tags script mais recentes em cada transição de rota. Mas como eles predeterminam esses blocos dinâmicos no momento da criação?

O Next.js usa um arquivo de manifesto de build do lado do servidor para determinar quais blocos de saída são usados por diferentes pontos de entrada. Para fornecer essas informações ao cliente, um arquivo de manifesto de build do lado do cliente foi criado para mapear todas as dependências de cada ponto de entrada.

// Returns a promise for the dependencies for a particular route
getDependencies (route) {
  return this.promisedBuildManifest.then(
    man => (man[route] && man[route].map(url => `/_next/${url}`)) || []
  )
}
Saída de vários blocos compartilhados em um aplicativo Next.js.

Essa estratégia de agrupamento granular mais recente foi lançada pela primeira vez no Next.js por trás de uma flag, onde foi testada em vários usuários iniciais. Muitos observaram reduções significativas no total de JavaScript usado em todo o site:

Site Mudança total de JS Diferença %
https://www.barnebys.com/ -238 KB -23%
https://sumup.com/ -220 KB -30%
https://www.hashicorp.com/ -11 MB -71%
Reduções de tamanho do JavaScript em todas as rotas (compactadas)

A versão final foi enviada por padrão na versão 9.2.

Gatsby

O Gatsby (link em inglês) costumava seguir a mesma abordagem de usar uma heurística baseada em uso para definir módulos comuns:

config.optimization = {
  …
  splitChunks: {
    name: false,
    chunks: `all`,
    cacheGroups: {
      default: false,
      vendors: false,
      commons: {
        name: `commons`,
        chunks: `all`,
        // if a chunk is used more than half the components count,
        // we can assume it's pretty global
        minChunks: componentsCount > 2 ? componentsCount * 0.5 : 2,
      },
      react: {
        name: `commons`,
        chunks: `all`,
        test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
      },

Ao otimizar a configuração do webpack para adotar uma estratégia de fragmentação granular semelhante, eles também notaram reduções consideráveis de JavaScript em muitos sites grandes:

Site Mudança total de JS Diferença %
https://www.gatsbyjs.org/ -680 KB -22%
https://www.thirdandgrove.com/ -390 KB -25%
https://ghost.org/ -1,1 MB -35%
https://reactjs.org/ -80 Kb -8%
Reduções de tamanho do JavaScript em todas as rotas (compactadas)

Confira o PR (em inglês) para entender como eles implementaram essa lógica na configuração do webpack, que é enviada por padrão na v2.20.7.

Conclusão

O conceito de envio de blocos granulares não é específico do Next.js, do Gatsby ou mesmo do webpack. Todos devem considerar melhorar a estratégia de agrupamento do aplicativo se ele seguir uma abordagem de pacote "comum" grande, independentemente do framework ou do empacotador de módulos usado.

  • As mesmas otimizações de agrupamento também podem ser aplicadas a um aplicativo React simples usando uma versão simplificada da estratégia de agrupamento granular na configuração do webpack, o que pode ajudar você a começar a aplicar o mesmo tipo de lógica ao seu site.
  • Para o Rollup, os blocos são criados de forma granular por padrão. Confira manualChunks se quiser configurar manualmente o comportamento.