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

Uma estratégia de agrupamento de webpacks mais recente no Next.js e no Gatsby minimiza o código duplicado para melhorar o desempenho do 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 recentemente para melhorar o desempenho de carregamento do Next.js e do Gatsby (links em inglês). Neste artigo, abordamos 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 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) bloco (ou blocos) "comuns". O download do código compartilhado pode ser feito separadamente e armazenado no cache do navegador logo no início, o que pode resultar em um melhor desempenho de carregamento.

Esse padrão se tornou conhecido em muitos frameworks de aplicativos de página única, adotando um ponto de entrada e uma configuração de pacote que se parece com isto:

Configuração de pacote e ponto de entrada comum

Embora seja prático, o conceito de agrupar todo o código do módulo compartilhado em um único bloco tem suas limitações. É possível fazer o download de módulos não compartilhados em todos os pontos de entrada para rotas que não os utilizam, 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 motivo, e alguns outros, o webpack v4 removeu o plug-in em favor de um novo: SplitChunksPlugin.

Pequenas partes aprimoradas

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 conditions 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 "single-commons" para a divisão de partes. O Next.js, por exemplo, geraria um pacote commons que continha qualquer módulo usado em mais de 50% das páginas e em 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 do código dependente de framework em um bloco compartilhado signifique que ele pode ser transferido por download e armazenado em cache para qualquer ponto de entrada, a heurística baseada em uso de incluir módulos comuns usados em mais de metade das páginas não é muito eficaz. A modificação dessa proporção só resultaria em um destes dois resultados:

  • Se você reduzir a proporção, códigos mais desnecessários serão transferidos por download.
  • Se você aumentar a proporção, mais códigos serão duplicados em várias rotas.

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

  • Qualquer módulo de terceiros grande o suficiente (com mais de 160 KB) é dividido no próprio bloco individual
  • Um bloco frameworks separado é criado para dependências de framework (react, react-dom e assim por diante).
  • quantos blocos compartilhados forem necessários (até 25);
  • O tamanho mínimo de um bloco a ser gerado é alterado para 20 KB

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

  • Melhorias no tempo de carregamento das páginas. A emissão de 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 partes separadas reduz a possibilidade de invalidação de cache, já que é improvável que ambos sejam alterados até que um upgrade seja feito.

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

Mais solicitações HTTP

SplitChunksPlugin definiu a base para a divisão granular, e a aplicação dessa abordagem a um framework como o Next.js não era um conceito totalmente novo. No entanto, muitos frameworks, no entanto, ainda continuaram usando uma única estratégia de pacote heurística e "comum" por alguns motivos. Isso inclui a preocupação de que muito mais solicitações HTTP possam afetar negativamente o desempenho do site.

Os navegadores só podem abrir um número limitado de conexões TCP com uma única origem (6 no 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 se aplica apenas a 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 bundler.

Todos os principais navegadores são compatíveis com HTTP/2. As equipes do Chrome e do Next.js queriam ver se o aumento do número de solicitações dividindo o único pacote "commons" do Next.js em vários blocos compartilhados afetaria o desempenho do carregamento. Eles começaram medindo o desempenho de um único site e modificando 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 média, as vezes de três execuções de vários testes em uma única página da Web, os tempos de load, start-render e Primeira exibição de conteúdo permaneceram praticamente os mesmos ao variar a contagem máxima de solicitações inicial (de 5 para 15). É interessante notar uma pequena sobrecarga de desempenho somente depois de dividir agressivamente 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) estabeleceu o equilíbrio certo entre o desempenho do carregamento e a eficiência do armazenamento em cache. Após alguns testes de referência, 25 foi selecionado como a contagem de maxInitialRequest.

A modificação do número máximo de solicitações que acontecem em paralelo resultou em mais de um único pacote compartilhado. Separá-las corretamente 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 maior agrupamento

Este experimento visava apenas modificar o número de solicitações para verificar se haveria algum efeito negativo no desempenho do carregamento de página. Os resultados sugerem que definir maxInitialRequests como 25 na página de teste foi o ideal, porque reduziu o tamanho do payload do JavaScript sem desacelerar a página. A quantidade total de JavaScript necessária para hidratar a página ainda permaneceu a mesma, o que explica por que o desempenho do carregamento de 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 que um bloco seja gerado. No entanto, acoplar um valor 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, dependem do roteamento do lado do cliente (gerenciado pelo JavaScript) para injetar tags de script mais recentes para cada transição de rota. Mas como eles predeterminam esses blocos dinâmicos no tempo de compilaçã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 também 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 estratégia de agrupamento granular mais recente foi lançada no Next.js por trás de uma sinalização, em que foi testada em vários usuários iniciais. Muitos perceberam reduções significativas no JavaScript total usado em todo o site:

Site Mudança total do 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 (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 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 agrupamento granular semelhante, também perceberam reduções consideráveis de JavaScript em vários sites grandes:

Site Mudança total do 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 (compactado)

Confira o PR para entender como essa lógica foi implementada 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, Gatsby ou mesmo webpack. Todos precisam melhorar a estratégia de agrupamento do aplicativo se seguirem uma abordagem de pacote "comum", independentemente do framework ou bundler de módulo usado.

  • Se você quiser ver as mesmas otimizações de agrupamento aplicadas a um aplicativo React básico, consulte este app React de exemplo (link em inglês). Ele usa uma versão simplificada da estratégia de agrupamento granular e 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 maneira granular por padrão. Consulte manualChunks se quiser configurar o comportamento manualmente.