Em aplicações JavaScript modernas, o tamanho do bundle é um dos principais fatores que afetam a performance. Carregar todo o código da aplicação de uma vez pode resultar em tempos de carregamento longos, especialmente em conexões lentas. Nesta aula, vamos explorar os imports dinâmicos e o code splitting como estratégias para otimizar o carregamento de módulos, carregando apenas o que é necessário no momento certo.

O import() é uma função que permite importar módulos de forma assíncrona, retornando uma Promise. Isso possibilita dividir o código em partes menores (chunks) que são carregadas somente quando necessárias, reduzindo o bundle inicial e melhorando a experiência do usuário.

import()

A sintaxe import() é uma forma dinâmica de importar módulos em JavaScript. Diferente do import estático, que deve ser usado no topo do arquivo e é resolvido em tempo de compilação, o import() pode ser chamado em qualquer lugar do código, como uma função comum, e retorna uma Promise que resolve para o módulo importado.

O uso típico é:

import('./modulo.js')
  .then((modulo) => {
    // usar o módulo
    modulo.funcao();
  })
  .catch((erro) => {
    console.error('Erro ao carregar módulo:', erro);
  });

Também é possível usar com async/await para um código mais limpo:

async function carregarModulo() {
  try {
    const modulo = await import('./modulo.js');
    modulo.funcao();
  } catch (erro) {
    console.error('Erro ao carregar módulo:', erro);
  }
}

Uma característica importante é que o import() cria um novo escopo para o módulo, e as exportações são acessíveis como propriedades do objeto resolvido. Por exemplo, se o módulo exporta uma função padrão, você pode acessá-la via modulo.default.

Carregamento sob demanda

O carregamento sob demanda (lazy loading) é uma técnica que adia o carregamento de recursos até que eles sejam realmente necessários. No contexto de módulos JavaScript, isso significa que podemos dividir a aplicação em partes menores e carregá-las apenas quando o usuário realizar uma ação que exija esse código (como navegar para uma rota específica, abrir um modal, etc.).

Um exemplo clássico é o carregamento de uma biblioteca de gráficos somente quando o usuário clicar em um botão para exibir um gráfico:

const botao = document.getElementById('mostrar-grafico');

botao.addEventListener('click', async () => {
  const { criarGrafico } = await import('./grafico.js');
  criarGrafico();
});

Em frameworks como React, é comum usar React.lazy() e Suspense para carregar componentes dinamicamente. Embora sejam específicos do React, eles utilizam o mesmo princípio de import() por baixo dos panos.

Outra aplicação é em rotas: em uma SPA, você pode carregar o código de cada rota somente quando o usuário navegar para ela. Isso reduz drasticamente o bundle inicial, pois o código de rotas não visitadas não é carregado.

Impacto em performance

O principal impacto positivo do code splitting é a redução do tempo de carregamento inicial. Ao dividir o bundle em chunks menores, o navegador precisa baixar menos bytes para exibir a primeira tela, o que melhora métricas como First Contentful Paint (FCP) e Largest Contentful Paint (LCP).

Além disso, o carregamento sob demanda pode reduzir o uso de memória, pois módulos que não são usados não são carregados na memória até serem necessários. Isso é especialmente útil em aplicações grandes com muitas funcionalidades.

No entanto, é preciso ter cuidado com o excesso de divisão: muitos chunks pequenos podem gerar muitas requisições HTTP, o que pode ter um impacto negativo. O ideal é encontrar um equilíbrio, criando chunks significativos que agrupem funcionalidades relacionadas. Ferramentas como webpack e Vite oferecem configurações para otimizar o code splitting automaticamente.

Outro ponto é o cache: chunks com nomes baseados em hash permitem que o navegador faça cache de partes que não mudaram, agilizando visitas subsequentes. Se um chunk é atualizado, apenas ele é rebaixado, enquanto os outros permanecem em cache.

Em termos de métricas, o code splitting pode melhorar significativamente o Lighthouse Performance Score, especialmente em sites com muitos scripts. No entanto, é importante monitorar o impacto real e ajustar a estratégia conforme necessário.

Boas práticas

Aqui estão algumas boas práticas ao usar imports dinâmicos:

  • Use para funcionalidades pesadas e não críticas: bibliotecas de terceiros, componentes de UI complexos, gráficos, etc.
  • Combine com React.lazy (em React) ou similar: para componentes que só aparecem em certas condições.
  • Evite imports dinâmicos excessivos em loops: isso pode causar múltiplas requisições. Em vez disso, carregue uma vez e reutilize.
  • Defina nomes de chunks significativos: use comentários mágicos em webpack (ex.: /* webpackChunkName: "meu-chunk" */) para ajudar na depuração.
  • Trate erros de carregamento: sempre use catch para lidar com falhas de rede ou módulos ausentes.

Referências

Exercícios

  1. Exercício 1: Escreva uma função carregarModulo que use import() para carregar um módulo chamado ./utils.js e chame uma função somar exportada por ele, com dois números fornecidos como argumentos. Use async/await e trate erros com try/catch.

    ✓ Resposta:
    async function carregarModulo(a, b) {
      try {
        const modulo = await import('./utils.js');
        return modulo.somar(a, b);
      } catch (erro) {
        console.error('Erro ao carregar módulo:', erro);
        return null;
      }
    }
  2. Exercício 2: Crie um exemplo de carregamento sob demanda: um botão que, ao ser clicado, carrega dinamicamente um módulo que exibe um alerta com "Módulo carregado!". Use import() dentro do evento de clique.

    ✓ Resposta:
    const botao = document.getElementById('meu-botao');
    botao.addEventListener('click', async () => {
      try {
        const { mostrarAlerta } = await import('./alerta.js');
        mostrarAlerta('Módulo carregado!');
      } catch (erro) {
        console.error('Falha no carregamento:', erro);
      }
    });
  3. Exercício 3: Explique a diferença entre import estático e import dinâmico em relação ao tempo de carregamento e à possibilidade de uso condicional. Dê um exemplo de situação em que o import dinâmico é mais adequado.

    ✓ Resposta: O import estático é resolvido em tempo de compilação e carregado junto com o bundle principal, aumentando o tamanho inicial. O import dinâmico é carregado em tempo de execução, apenas quando a chamada é feita, permitindo carregamento condicional e sob demanda. Um exemplo adequado é carregar uma biblioteca de gráficos somente quando o usuário clicar em um botão para gerar um gráfico, evitando carregar esse código desnecessariamente na carga inicial.
  4. Exercício 4: Suponha que você tem um módulo que exporta uma função calcular. Como você importaria esse módulo dinamicamente e usaria a função em uma expressão await? Mostre o código completo, incluindo o tratamento de erro.

    ✓ Resposta:
    async function executarCalculo(a, b) {
      try {
        const { calcular } = await import('./calculadora.js');
        return await calcular(a, b);
      } catch (erro) {
        console.error('Erro ao carregar ou executar:', erro);
        return null;
      }
    }
  5. Exercício 5: Cite duas métricas de performance que podem ser melhoradas com code splitting e explique como o carregamento dinâmico contribui para cada uma delas.

    ✓ Resposta: Duas métricas são First Contentful Paint (FCP) e Time to Interactive (TTI). O code splitting reduz o tamanho do bundle inicial, permitindo que o navegador baixe e execute menos JavaScript antes de renderizar o conteúdo, melhorando o FCP. Além disso, ao adiar a execução de scripts não essenciais, o thread principal fica livre mais cedo, reduzindo o TTI.