Saltar al contenido

Estrategias de Code Splitting para aplicaciones web más rápidas

Técnicas prácticas de code splitting con Webpack e importaciones dinámicas para reducir el bundle inicial y mejorar el rendimiento de carga.

5 min de lectura
Diagrama que muestra un gran bundle de JavaScript dividido en chunks más pequeños cargados bajo demanda

Publicar un bundle de JavaScript de 2MB significa que cada usuario paga el costo completo por adelantado, aunque solo visite una página. El code splitting divide tu aplicación en chunks más pequeños que se cargan bajo demanda, haciendo que la carga inicial sea rápida y difiriendo el resto hasta que realmente se necesite.

El concepto es simple. La ejecución tiene sus trampas. Esta guía cubre las técnicas que funcionan de forma fiable y los patrones que causan regresiones sutiles de rendimiento.

División por rutas: la base

La división de mayor impacto ocurre en los límites de las rutas. Cada página se convierte en su propio chunk, cargado solo cuando el usuario navega hacia ella.

tsxtsx
// ❌ Importing everything upfront — one massive bundle
import Home from './pages/Home';
import Dashboard from './pages/Dashboard';
import Settings from './pages/Settings';
import Analytics from './pages/Analytics';
import AdminPanel from './pages/AdminPanel';
 
function App() {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/dashboard" element={<Dashboard />} />
      <Route path="/settings" element={<Settings />} />
      <Route path="/analytics" element={<Analytics />} />
      <Route path="/admin" element={<AdminPanel />} />
    </Routes>
  );
}
tsxtsx
// ✅ Lazy-loaded routes — each page in its own chunk
import { lazy, Suspense } from 'react';
 
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() => import('./pages/Analytics'));
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
 
function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/analytics" element={<Analytics />} />
        <Route path="/admin" element={<AdminPanel />} />
      </Routes>
    </Suspense>
  );
}

Con la división por rutas, un usuario que visita la página de inicio solo descarga el chunk de inicio. Los chunks del dashboard, la configuración y el panel de administración se cargan al navegar. Para aplicaciones con diez o más rutas, esto por sí solo puede reducir el bundle inicial entre un 60 y un 80%.

Importaciones dinámicas para librerías pesadas

Las librerías grandes usadas en funcionalidades específicas no deberían estar en el bundle principal. El import() dinámico las difiere hasta que la funcionalidad se activa.

tstypescript
// ❌ Chart library loaded for every user, even those who never view charts
import { Chart } from 'chart.js';
import { marked } from 'marked';
import hljs from 'highlight.js';
 
export function renderAnalytics(data: AnalyticsData) {
  const chart = new Chart(canvas, { type: 'line', data });
  return chart;
}
tstypescript
// ✅ Heavy libraries loaded only when the feature is used
export async function renderAnalytics(data: AnalyticsData) {
  const { Chart } = await import('chart.js');
  const chart = new Chart(canvas, { type: 'line', data });
  return chart;
}
 
export async function renderMarkdown(content: string) {
  const [{ marked }, hljs] = await Promise.all([
    import('marked'),
    import('highlight.js'),
  ]);
 
  marked.setOptions({
    highlight: (code, lang) => hljs.highlight(code, { language: lang }).value,
  });
 
  return marked(content);
}

Promise.all carga varias librerías en paralelo cuando siempre se usan juntas. Esto evita la carga secuencial en cascada.

Comentarios mágicos de Webpack

Webpack ofrece comentarios mágicos para controlar el nombre de los chunks, la estrategia de carga y el prefetching. Son fundamentales para afinar la experiencia de carga.

tstypescript
// Named chunks — easier debugging and cache management
const Editor = lazy(() =>
  import(/* webpackChunkName: "editor" */ './components/Editor')
);
 
// Prefetch — load in background after main resources finish
const AdminPanel = lazy(() =>
  import(
    /* webpackChunkName: "admin" */
    /* webpackPrefetch: true */
    './pages/AdminPanel'
  )
);
 
// Preload — load in parallel with the current navigation
const CriticalWidget = lazy(() =>
  import(
    /* webpackChunkName: "critical-widget" */
    /* webpackPreload: true */
    './components/CriticalWidget'
  )
);

La diferencia entre prefetch y preload importa:

htmlhtml
<!-- Prefetch: downloaded during idle time, low priority -->
<!-- Browser fetches this AFTER the current page finishes loading -->
<link rel="prefetch" href="/static/js/admin.chunk.js" />
 
<!-- Preload: downloaded immediately, high priority -->
<!-- Browser fetches this IN PARALLEL with the current page -->
<link rel="preload" href="/static/js/critical-widget.chunk.js" as="script" />

Usa prefetch para las rutas que el usuario probablemente visitará a continuación. Usa preload para componentes que se renderizan en la página actual pero que se dividieron por razones de caché.

Estrategia de chunks de vendor

Separar el código de vendor del código de la aplicación mejora el uso de caché. Tu código de aplicación cambia con frecuencia, pero react, lodash y date-fns casi nunca. Separarlos significa que los usuarios solo vuelven a descargar lo que cambió.

jsjavascript
// webpack.config.js — vendor splitting configuration
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        // Core framework — changes very rarely
        framework: {
          test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
          name: 'framework',
          priority: 40,
          enforce: true,
        },
        // Large libraries — split individually for granular caching
        chartjs: {
          test: /[\\/]node_modules[\\/]chart\.js[\\/]/,
          name: 'chartjs',
          priority: 30,
        },
        // Remaining vendor code
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          priority: 20,
          minSize: 30000,
        },
        // Shared application code used by 2+ chunks
        commons: {
          minChunks: 2,
          name: 'commons',
          priority: 10,
          reuseExistingChunk: true,
        },
      },
    },
  },
};

Esto crea una jerarquía: chunk del framework (en caché durante meses), chunks de librerías grandes (en caché individualmente), chunk general de vendors (en caché hasta que se actualice alguna dependencia) y un chunk commons para el código compartido de la aplicación.

División a nivel de componente

Para componentes detrás de una interacción del usuario —modales, desplegables o formularios complejos— divide a nivel de componente en lugar de a nivel de ruta.

tsxtsx
import { lazy, Suspense, useState } from 'react';
 
// Heavy modal with rich text editor, only loaded when opened
const RichTextModal = lazy(() =>
  import(
    /* webpackChunkName: "rich-text-modal" */
    /* webpackPrefetch: true */
    './components/RichTextModal'
  )
);
 
function DocumentPage() {
  const [showEditor, setShowEditor] = useState(false);
 
  return (
    <div>
      <h2>Document Viewer</h2>
      <DocumentContent />
 
      <button onClick={() => setShowEditor(true)}>
        Edit Document
      </button>
 
      {showEditor && (
        <Suspense fallback={<ModalSkeleton />}>
          <RichTextModal onClose={() => setShowEditor(false)} />
        </Suspense>
      )}
    </div>
  );
}

La pista webpackPrefetch: true le indica al navegador que descargue el chunk del modal durante el tiempo de inactividad. Cuando el usuario hace clic en "Edit Document", es probable que el chunk ya esté en caché, haciendo que el modal aparezca al instante.

Analizar y medir las divisiones

Dividir sin medir lleva a una división excesiva o a duplicaciones accidentales. Usa el análisis de bundles para verificar que tu estrategia funciona como se espera.

shbash
# Install the analyzer
npm install --save-dev webpack-bundle-analyzer
 
# Generate stats and visualize
npx webpack --profile --json > stats.json
npx webpack-bundle-analyzer stats.json
 
# Or add to webpack config for automatic analysis
# const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
# plugins: [new BundleAnalyzerPlugin()]
tstypescript
// Runtime check: log chunk loading for debugging
if (process.env.NODE_ENV === 'development') {
  const originalFetch = window.fetch;
  window.fetch = function (...args) {
    const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
    if (url?.includes('.chunk.js')) {
      console.log(`[Chunk loaded] ${url}`);
    }
    return originalFetch.apply(this, args);
  };
}

Métricas clave a seguir después de dividir:

  • Tamaño inicial de JS: Debería estar por debajo de 200KB comprimido con gzip para la mayoría de las aplicaciones
  • Largest Contentful Paint: Debería mejorar con una división adecuada
  • JavaScript sin usar: La pestaña Coverage de Chrome DevTools muestra cuánto código queda sin usar por página

Errores comunes

La división excesiva crea demasiadas peticiones de red. Cada chunk tiene sobrecarga de HTTP: cabeceras, establecimiento de conexión, parseo. Dividir una utilidad de 5KB en su propio chunk empeora el rendimiento en lugar de mejorarlo.

La división insuficiente ocurre cuando las dependencias compartidas se duplican entre chunks. Dos chunks de rutas que importan la misma librería de gráficos de 100KB sin una configuración adecuada de splitChunks significan que el usuario la descarga dos veces.

Olvidar los error boundaries alrededor de los componentes lazy significa que un fallo en la carga de un chunk rompe la aplicación en lugar de mostrar una opción de reintento. Envuelve siempre los límites de Suspense con error boundaries en producción.

Conclusiones clave

  1. Empieza con la división a nivel de ruta: ofrece el mayor impacto con el menor esfuerzo
  2. Importa dinámicamente las librerías pesadas: chart.js, monaco-editor y similares nunca deberían estar en el bundle principal
  3. Usa prefetch para las páginas siguientes probables: la carga en segundo plano elimina los retrasos de navegación
  4. Configura la división de vendors para la caché: separa el framework, las librerías grandes y el código de la aplicación
  5. Mide con análisis de bundles: dividir sin datos lleva a resultados peores que no dividir en absoluto
  6. Evita la micro-división: los chunks de menos de 30KB generan más sobrecarga de la que ahorran
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX