Bundle analysis e code splitting estratégico

1. Fundamentos do Bundle Analysis

A análise de bundle é o primeiro passo para entender o que realmente está sendo enviado ao navegador. Sem essa visibilidade, otimizações são feitas no escuro, muitas vezes resultando em esforço desperdiçado. O objetivo principal é responder perguntas como: "Por que meu bundle tem 2MB?", "Essa biblioteca de 500KB está sendo usada de fato?" e "O tree-shaking está funcionando corretamente?".

As ferramentas mais relevantes para essa análise são:

  • Webpack Bundle Analyzer: Gera um mapa interativo do bundle, mostrando o tamanho relativo de cada módulo
  • Source Map Explorer: Permite navegar pelo código original mapeado no bundle final
  • Rollup Plugin Visualizer: Equivalente para projetos que usam Rollup ou Vite

As métricas-chave que devemos monitorar incluem: tamanho total do bundle (gzipped e não comprimido), dependências duplicadas entre chunks, e a eficácia do tree-shaking (módulos mortos que ainda são incluídos).

2. Configurando Análise de Bundle no Projeto

Integrar o Webpack Bundle Analyzer em um projeto React é direto:

// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'static',
      reportFilename: 'bundle-report.html',
      openAnalyzer: false,
    }),
  ],
};

Para definir orçamentos de bundle e falhar o build se excedidos, usamos size-limit:

// package.json
{
  "size-limit": [
    {
      "path": "dist/main.js",
      "limit": "200 KB",
      "running": false
    },
    {
      "path": "dist/chunk-vendors.js",
      "limit": "300 KB"
    }
  ]
}

Ao analisar o relatório gerado, os "low-hanging fruits" mais comuns são:
- Bibliotecas inteiras importadas quando apenas funções específicas são usadas (ex: import moment from 'moment' vs import { format } from 'date-fns')
- Componentes visuais pesados (chart libraries, editores rich text) carregados em todas as páginas
- Polyfills desnecessários para navegadores modernos

3. Code Splitting Automático com React.lazy e Suspense

O React.lazy permite dividir componentes pesados em chunks separados que são carregados sob demanda:

import React, { Suspense } from 'react';

const HeavyChart = React.lazy(() => import('./HeavyChart'));
const DataTable = React.lazy(() => import('./DataTable'));

function Dashboard() {
  return (
    <Suspense fallback={<div className="skeleton-chart" />}>
      <HeavyChart />
      <Suspense fallback={<div className="skeleton-table" />}>
        <DataTable />
      </Suspense>
    </Suspense>
  );
}

Boas práticas importantes:
- Use fallbacks específicos (skeletons, spinners) em vez de um loader genérico
- Evite aninhar muitos Suspense sem necessidade — cada um adiciona overhead de renderização
- Para evitar "flash de loading", considere pré-carregar componentes críticos após o primeiro render:

import React, { useEffect } from 'react';

const HeavyChart = React.lazy(() => import('./HeavyChart'));

function Dashboard() {
  useEffect(() => {
    // Pré-carrega o componente após o carregamento inicial
    import('./HeavyChart');
  }, []);

  return <Suspense fallback={<Spinner />}><HeavyChart /></Suspense>;
}

4. Code Splitting Baseado em Rota (Route-based Splitting)

Em aplicações React Router v6, o code splitting por rota é a estratégia mais impactante:

import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Reports = lazy(() => import('./pages/Reports'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<PageSkeleton />}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/reports" element={<Reports />} />
          <Route path="/settings" element={<Settings />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

Para melhorar a experiência do usuário, podemos usar IntersectionObserver para pré-carregar rotas previstas:

import { useEffect, useRef } from 'react';

function usePreloadRoute(linkRef, routeImporter) {
  useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting) {
        routeImporter(); // Inicia o carregamento do chunk
        observer.disconnect();
      }
    }, { rootMargin: '200px' });

    if (linkRef.current) {
      observer.observe(linkRef.current);
    }

    return () => observer.disconnect();
  }, [linkRef, routeImporter]);
}

5. Code Splitting por Componente e Biblioteca

Bibliotecas pesadas como moment.js (330KB) ou lodash (530KB) podem ser carregadas apenas quando necessário:

// Em vez de import estático
// import moment from 'moment';

// Import dinâmico sob demanda
async function formatDate(dateString) {
  const { format } = await import('date-fns');
  return format(new Date(dateString), 'dd/MM/yyyy');
}

// Para Chart.js em um modal
function ChartModal({ isOpen, data }) {
  const [ChartComponent, setChartComponent] = useState(null);

  useEffect(() => {
    if (isOpen) {
      import('react-chartjs-2').then(mod => {
        setChartComponent(() => mod.Chart);
      });
    }
  }, [isOpen]);

  if (!ChartComponent) return null;
  return <ChartComponent type="line" data={data} />;
}

Para vendor chunk splitting no Webpack:

// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/](react|react-dom|react-router)[\\/]/,
          name: 'vendor-stable',
          chunks: 'all',
          priority: 10,
        },
        chartLibs: {
          test: /[\\/]node_modules[\\/](chart\.js|d3|echarts)[\\/]/,
          name: 'chart-libs',
          chunks: 'async',
          priority: 5,
        },
      },
    },
  },
};

6. Técnicas Avançadas de Splitting no Node.js

No backend Node.js com Express, podemos aplicar lazy loading similar:

const express = require('express');
const app = express();

app.get('/api/reports/generate', async (req, res) => {
  // Só carrega o módulo pesado quando a rota é chamada
  const pdfGenerator = await import('./services/pdfGenerator.js');
  const report = await pdfGenerator.generateReport(req.query);

  res.setHeader('Content-Type', 'application/pdf');
  res.send(report);
});

app.post('/api/images/process', async (req, res) => {
  const sharp = await import('sharp');
  const processed = await sharp(req.file.buffer)
    .resize(800, 600)
    .jpeg({ quality: 80 })
    .toBuffer();

  res.send(processed);
});

Para caching inteligente de módulos divididos:

const moduleCache = new Map();

async function getLazyModule(modulePath) {
  if (moduleCache.has(modulePath)) {
    return moduleCache.get(modulePath);
  }

  const module = await import(modulePath);
  moduleCache.set(modulePath, module);

  // Warm-up: executa funções de inicialização
  if (module.warmUp) {
    await module.warmUp();
  }

  return module;
}

7. Monitoramento Contínuo e Otimização Iterativa

Para automatizar a análise no CI/CD:

// .github/workflows/bundle-check.yml
name: Bundle Size Check

on: [pull_request]

jobs:
  bundle-size:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: npm ci
      - run: npm run build
      - name: Check bundle size
        run: npx size-limit --json > bundle-report.json
      - name: Fail if over budget
        run: |
          if grep -q '"passed": false' bundle-report.json; then
            echo "Bundle size exceeds budget!"
            exit 1
          fi

Para validar o impacto real no usuário, meça Web Vitals antes e depois:

// No navegador
import { onLCP, onFCP, onCLS } from 'web-vitals';

onLCP(console.log);
onFCP(console.log);
onCLS(console.log);

Em um caso real, aplicamos code splitting em gráficos Chart.js e reduzimos o bundle inicial de 1.2MB para 720KB (40% de redução). O LCP (Largest Contentful Paint) caiu de 4.2s para 2.1s, e a primeira interação ficou 60% mais rápida.

Referências