12k
All articles

Estratégias de code splitting para sua aplicação frontend

Compare estratégias de divisão de código frontend: separe rotas e componentes pesados, antecipe o carregamento na interação, configure chunks de dependências e meça o desempenho.

OpenReplay Team
OpenReplay Team
Estratégias de code splitting para sua aplicação frontend

O code splitting divide um bundle JavaScript em chunks separados que o navegador baixa somente quando o código contido neles é necessário. Assim, o primeiro carregamento da página paga apenas pela primeira página, e não pela aplicação inteira.

O Lighthouse aponta uma montanha de JavaScript não utilizado, você já adicionou React.lazy a um componente e, ainda assim, a produção continua entregando um único chunk de entrada enorme. A solução não é espalhar mais chamadas lazy() por todo lado. Você precisa saber quais divisões valem a pena, quanto cada uma custa e como verificar se ela funcionou.

Este artigo aborda cinco estratégias na ordem em que costumam trazer retorno, como medi-las e as três formas pelas quais o code splitting deixa uma aplicação mais lenta.

Principais conclusões

  • A divisão por rotas é a primeira a ser feita em praticamente qualquer single-page app, porque cada rota vira seu próprio chunk e os visitantes baixam apenas a rota em que chegam.
  • O webpack 5 e o Rolldown (o bundler do Vite 8) já movem o código compartilhado por chunks carregados sob demanda para chunks comuns, então um vendor.js montado manualmente hoje é útil principalmente para estabilidade de cache, e não para reduzir tamanho.
  • Se você dispara o import() no hover ou no foco em vez de no clique, o chunk é baixado enquanto o usuário ainda está decidindo se vai clicar.
  • Um chunk de entrada menor, por si só, não prova nada. A prova é uma cascata de rede com throttling em que o código da primeira tela termina de carregar mais cedo do que antes.
  • Dividir o código de que a primeira tela precisa não elimina a espera. Apenas a empurra para depois.

Por que um único bundle deixa a primeira página lenta?

Sem divisão, uma single-page app entrega um único bundle de entrada contendo todas as rotas, modais e bibliotecas que a aplicação poderia usar. Quem chega à sua página de marketing baixa, faz o parsing e executa a tela de configurações, o painel administrativo e a biblioteca de gráficos, mesmo que nunca abra nenhum deles. Esse código não utilizado disputa banda e tempo da main thread com o código que de fato renderiza a página.

Cada uma das cinco estratégias de code splitting abaixo tem um gatilho diferente, um custo diferente e uma forma diferente de verificar se funcionou:

EstratégiaQuando se aplicaQuanto custaComo verificar se funcionou
Divisão por rotasQualquer SPA com várias rotasUm fallback a cada navegaçãoO chunk de entrada não contém mais as outras rotas
Divisão por componentesUI pesada atrás de uma interação ou abaixo da dobraUm estado de carregamento e uma requisição extraO chunk só carrega quando acionado
Chunks de vendor manuaisDependências estáveis que você quer em cache por muito tempoManutenção da configuração e possíveis mudanças na ordem de execuçãoO hash permanece o mesmo entre deploys
Prefetch por interaçãoCliques com alta probabilidadeOcasionalmente buscar código que ninguém usaA requisição começa antes do clique
Não dividirCódigo de que a primeira tela precisaNenhumNenhuma nova cadeia de requisições

O code splitting por rotas vem primeiro

A divisão por rotas é a primeira que vale a pena fazer em praticamente qualquer single-page app. Cada rota vira seu próprio chunk, e o visitante baixa apenas a rota em que chega. Exige pouco código, e as mudanças de rota são momentos naturais para um breve estado de carregamento. Understanding React dynamic imports explica o padrão com React.lazy e Suspense. Meta-frameworks como Next.js e SvelteKit dividem por rota automaticamente.

Divida componentes pesados atrás de uma interação

A divisão no nível de componente é indicada para UI pesada que não fica visível na primeira renderização: um modal, um editor de rich text, um widget de gráficos, uma seção de comentários abaixo da dobra. O custo é um estado de carregamento, além de uma requisição de rede no momento em que o usuário quer usar o recurso.

Todos os principais frameworks oferecem a mesma primitiva sobre o import() dinâmico:

// React
import { lazy } from 'react';
const Chart = lazy(() => import('./Chart'));
// Next.js
import dynamic from 'next/dynamic';
const Chart = dynamic(() => import('./Chart'));
// Vue
import { defineAsyncComponent } from 'vue';
const Chart = defineAsyncComponent(() => import('./Chart.vue'));

Um componente lazy do React é renderizado dentro de um boundary de Suspense, que exibe um fallback enquanto o chunk carrega. No Next.js, o next/dynamic encapsula React.lazy() e Suspense em uma única chamada, e o Vue oferece o defineAsyncComponent. O Svelte usa import() diretamente com renderização condicional.

Para conteúdo abaixo da dobra, dispare o carregamento quando o elemento se aproximar da viewport. Crie um único Intersection Observer, desconecte-o após a primeira interseção e então renderize o componente lazy. Lazy loading in JavaScript aborda os padrões com observers.

O que o seu bundler já divide por você

As configurações padrão de splitChunks do webpack 5, assim como o Rollup (Rolldown no Vite 8), movem automaticamente o código compartilhado por vários chunks carregados sob demanda para um chunk comum. Por padrão, o webpack faz isso apenas para chunks assíncronos (chunks: 'async') e somente quando o novo chunk ultrapassa um tamanho mínimo. Seu cache group embutido defaultVendors já corresponde a node_modules. The current state of JavaScript bundlers traz um panorama de onde cada bundler está hoje.

Como o bundler já extrai o código compartilhado, um único vendor.js montado manualmente basicamente duplica o que o webpack ou o Rolldown já fazem. O principal motivo que resta para definir chunks de vendor manualmente é a estabilidade de cache. Se dependências que raramente mudam mantiverem o mesmo content hash entre deploys, os visitantes recorrentes não precisarão baixá-las novamente.

// webpack 5
module.exports = {
  optimization: {
    splitChunks: {
      cacheGroups: {
        framework: {
          test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
          name: 'framework',
          chunks: 'all',
        },
      },
    },
  },
};

Aqui, chunks: 'all' é necessário. Sem ele, o código do framework importado pela entrada permanece dentro do chunk de entrada, e o hash da entrada muda a cada deploy.

No Vite 7 e anteriores, a mesma ideia era escrita como build.rollupOptions.output.manualChunks. O Vite 8 faz o build com o Rolldown. Ele não oferece mais suporte à forma de objeto de manualChunks e torna obsoleta a forma de função em favor dos grupos de codeSplitting:

// Vite 8 (Rolldown)
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    rolldownOptions: {
      output: {
        codeSplitting: {
          groups: [{ name: 'vendor', test: /node_modules[\\/](react|react-dom)[\\/]/ }],
        },
      },
    },
  },
});

Mantenha os grupos restritos. A documentação do Rolldown alerta que grupos criados manualmente podem alterar a ordem em que os módulos são executados, o que importa para módulos com efeitos colaterais.

Comece a carregar no hover, não no clique

O prefetch por interação dispara o import() quando o ponteiro entra em um botão ou quando o botão recebe foco, em vez de no clique, de modo que o chunk é baixado enquanto o usuário ainda está decidindo. O download aproveita o intervalo entre a intenção e a ação, então o fallback aparece por um instante ou nem chega a aparecer.

import { lazy, Suspense, useState } from 'react';

const loadEditor = () => import('./RichTextEditor');
const RichTextEditor = lazy(loadEditor);

function ReplyBox() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onMouseEnter={loadEditor} onFocus={loadEditor} onClick={() => setOpen(true)}>
        Reply
      </button>
      {open && <Suspense fallback={<p>Loading editor…</p>}><RichTextEditor /></Suspense>}
    </>
  );
}

O lazy e os event handlers compartilham uma única função de carregamento. O sistema de módulos armazena em cache o resultado do primeiro import(), então a renderização após o clique reaproveita a requisição que já está em andamento. O custo é um pouco de banda desperdiçada quando o usuário passa o mouse, mas não clica.

Hints como webpackPrefetch são magic comments do webpack. O Turbopack no Next.js lê alguns magic comments compatíveis com o webpack, mas sua documentação lista apenas comentários de controle de import, como webpackIgnore, e não webpackPrefetch; outros bundlers geralmente não os leem. Consulte a documentação do seu bundler antes de depender de algum deles. Disparar o import() a partir de um event handler funciona com qualquer bundler.

Meça a cascata, não o tamanho do bundle

Um chunk de entrada menor, por si só, não prova nada. A evidência de que uma divisão ajudou é uma cascata de rede com throttling em que o código da primeira tela termina de carregar mais cedo do que antes.

  1. Rode um visualizador de bundle para o seu bundler e confirme o que cada chunk contém. Para o webpack, o passo a passo do Webpack Bundle Analyzer mostra como identificar módulos pesados e código que a página não usa.
  2. Abra o painel Network do Chrome DevTools, filtre por JS e aplique um perfil de rede móvel com throttling.
  3. Compare o antes e o depois. Verifique quando o último chunk necessário para a primeira renderização termina de carregar e se surgiram novas cadeias de requisições sequenciais.

Uma cadeia tem a aparência de uma escada: cada requisição de chunk só começa depois que a anterior termina. Imports lazy aninhados produzem esse padrão.

Quando o code splitting deixa as coisas mais lentas?

O code splitting sai pela culatra de três formas previsíveis.

Chunks pequenos demais. Cada chunk é uma requisição separada. Dividir componentes pequenos adiciona requisições e round trips que custam mais do que os bytes economizados.

Dividir o caminho crítico. Dividir o código de que a primeira tela precisa não elimina a espera. Apenas a desloca. O navegador agora precisa baixar e executar o chunk de entrada, chegar ao import() dinâmico e só então requisitar o chunk que renderiza o conteúdo.

Um spinner onde antes havia conteúdo. Um fallback que substitui um conteúdo que os usuários antes viam instantaneamente pode fazer a aplicação parecer mais lenta, mesmo que o tempo total de carregamento tenha diminuído.

Combine cada boundary lazy com um error boundary. Uma requisição de chunk pode falhar, e uma aba aberta antes de um deploy pode requisitar arquivos com hash que não existem mais. Session replays de aplicações com code splitting mostram coisas que um relatório de bundle não mostra: usuários parados em fallbacks de Suspense ou se deparando com um ChunkLoadError após um deploy no meio da sessão.

Escolhendo quais divisões manter

Comece pelas rotas e, depois, divida componentes pesados atrás de interações. Faça prefetch no hover ou no foco e deixe o bundler cuidar do código compartilhado, a menos que você queira especificamente hashes de cache estáveis. Para cada divisão, compare a cascata com throttling antes e depois. Se a primeira tela não renderizar mais cedo, ou se uma interação não responder mais rápido, reverta a divisão.

Perguntas frequentes

O React.lazy funciona com named exports?

Não diretamente. O React.lazy precisa que a promise do seu loader seja resolvida com um módulo que tenha o componente como default export, então um named export precisa ser remapeado: lazy(() => import('./Charts').then((m) => ({ default: m.BarChart }))). O chunk é dividido da mesma forma. Reexportar o componente como default a partir de um pequeno arquivo wrapper também funciona e mantém a chamada lazy curta.

Como me recupero quando um chunk lazy falha ao carregar após um deploy?

Recarregue a página para que o navegador busque o novo chunk de entrada e as URLs atuais dos chunks. O React guarda a primeira promise de um loader lazy e nunca chama o loader uma segunda vez, então apenas renderizar novamente não fará uma nova tentativa. Quando um import dinâmico falha, o Vite dispara um evento 'vite:preloadError' em window, e chamar event.preventDefault() no listener suprime o erro. Limite os recarregamentos automáticos para evitar um loop de reload.

Qual é a diferença entre webpackPrefetch e webpackPreload?

O webpackPrefetch baixa um chunk no tempo ocioso do navegador depois que o chunk pai termina de carregar, para código que provavelmente será necessário mais tarde, como a próxima tela. O webpackPreload busca o chunk ao mesmo tempo que o pai, com prioridade média, para código que o pai vai solicitar imediatamente. Ambos são magic comments do webpack que emitem link hints. O preload é a escolha menos comum, porque usá-lo em excesso pode prejudicar a performance.

O Vite evita cascatas de chunks automaticamente?

Em parte. Quando um chunk assíncrono depende de um chunk comum compartilhado, o Vite reescreve o import dinâmico com uma etapa de preload para que ambos sejam buscados em paralelo, e não um após o outro. Ele também emite links modulepreload para os chunks de entrada e seus imports diretos. Isso cobre apenas dependências estáticas. Um componente lazy que chama import() novamente ao renderizar ainda cria uma requisição sequencial que aparece na cascata.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.