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

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

O Chrome colabora com ferramentas e frameworks no ecossistema de código aberto do JavaScript. Várias otimizações mais recentes foram adicionadas recentemente para melhorar o desempenho de carregamento do Next.js e do Gatsby. Este artigo aborda uma estratégia de divisão 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 bundler principal. O webpack v3 introduziu CommonsChunkPlugin para possibilitar a saída de módulos compartilhados entre diferentes pontos de entrada em um (ou poucos) blocos "comuns". É possível fazer o download de código compartilhado separadamente e armazenar no cache do navegador antecipadamente, 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 adotam uma configuração de ponto de entrada e de pacotes semelhante a esta:

Configuração de pacote e ponto de entrada comum

Embora prático, o conceito de agrupar todo o código do módulo compartilhado em um único bloco tem suas limitações. Módulos não compartilhados em todos os pontos de entrada podem ser transferidos por download para rotas que não os usam, resultando em 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 motivo, junto com alguns outros, o webpack v4 removeu o plug-in em favor de um novo: SplitChunksPlugin.

Divisão aprimorada

As configurações padrão de SplitChunksPlugin funcionam bem para a maioria dos usuários. Vários blocos divididos são criados dependendo de várias 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 "comuns" única para a divisão de blocos. O Next.js, por exemplo, gera um pacote commons que contém qualquer módulo usado em mais de 50% das páginas e todas as dependências do framework (react, react-dom e assim por diante).

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 no uso de incluir módulos comuns usados em mais de metade das páginas não é muito eficaz. Modificar essa proporção só resultaria em um destes dois resultados:

  • Se você reduzir a proporção, mais código desnecessário será transferido.
  • Se você aumentar a proporção, mais códigos vão ser duplicados em vários trajetos.

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

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

Essa estratégia de divisão granular oferece os seguintes benefícios:

  • O tempo de carregamento da página é melhorado. 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 a navegação. Dividir bibliotecas e dependências de framework grandes em partes separadas reduz a possibilidade de invalidação de cache, já que é improvável que ambas sejam alteradas até que um upgrade seja feito.

É possível conferir toda a configuração adotada pelo Next.js em webpack-config.ts.

Mais solicitações HTTP

SplitChunksPlugin definiu a base para o agrupamento granular, e aplicar essa abordagem a um framework como o Next.js não era um conceito totalmente novo. No entanto, muitos frameworks ainda continuavam usando uma única heurística e estratégia de pacote "commons" 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 no 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 oferecem suporte a HTTP/2. As equipes do Chrome e do Next.js queriam saber se aumentar o número de solicitações dividindo o pacote "commons" único do Next.js em vários blocos compartilhados afetaria a performance de carregamento de alguma forma. Eles começaram medindo a performance de um único site enquanto modificavam o número máximo de solicitações paralelas usando a propriedade maxInitialRequests.

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

Em média de três execuções de vários testes em uma única página da Web, os tempos de load, start-render e First Contentful Paint permaneceram iguais ao variar a contagem máxima de solicitações inicial (de 5 a 15). Curiosamente, notamos uma leve sobrecarga no desempenho somente após uma divisão agressiva para centenas de solicitações.

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

Isso mostrou que permanecer abaixo de um limite confiável (20 a 25 solicitações) atingiu o equilíbrio certo entre a performance 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ção de payloads do JavaScript com aumento do agrupamento

O objetivo do experimento era modificar o número de solicitações para saber se haveria algum efeito negativo no desempenho de carregamento da 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 diminuir a velocidade da página. 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 redução da quantidade de código.

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

Redução de tamanho com blocos granulares

Muitos frameworks, incluindo o Next.js, dependem do roteamento do lado do cliente (gerenciado pelo JavaScript) para injetar tags de 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 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 resumido 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 nova estratégia de divisão granular foi lançada pela primeira vez no Next.js com uma flag, onde foi testada em vários usuários iniciais. Muitos tiveram reduções significativas no total de JavaScript usado em todo o site:

Site Mudança total do JS Diferença %
https://www.barnebys.com/ -238 KB 23%
https://sumup.com/ (link em inglês) -220 KB 30%
https://www.hashicorp.com/ (link em inglês) 11 MB -71%
Redução do tamanho do JavaScript em todas as rotas (compactado)

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

Gatsby

O Gatsby costumava seguir a mesma abordagem de usar uma heurística baseada no 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 divisão granular semelhante, a equipe também notou reduções consideráveis do JavaScript em muitos sites grandes:

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

Confira o PR para entender como eles implementaram essa lógica na configuração do webpack, que é fornecido 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 do webpack. Todos precisam considerar melhorar a estratégia de divisão do aplicativo se ela seguir uma grande abordagem de pacote "comum", independente do framework ou do bundler de módulo usado.

  • Se você quiser ver as mesmas otimizações de divisão aplicadas a um aplicativo básico do React, consulte este exemplo de app do React. Ele usa uma versão simplificada da estratégia de divisão granular e pode ajudar você a começar a aplicar o mesmo tipo de lógica ao seu site.
  • Para o agrupamento, os blocos são criados de forma granular por padrão. Confira manualChunks se quiser configurar o comportamento manualmente.