12k
All articles

Estrategias de code splitting para tu aplicación frontend

Compara estrategias de división de código frontend: separa rutas y componentes pesados, precarga al interactuar, configura chunks de dependencias y mide el rendimiento.

OpenReplay Team
OpenReplay Team
Estrategias de code splitting para tu aplicación frontend

El code splitting divide un bundle de JavaScript en chunks independientes que el navegador descarga solo cuando necesita el código que contienen. Así, la primera carga solo paga el coste de la primera página y no el de toda la aplicación.

Lighthouse te muestra un montón de JavaScript sin usar. Ya añadiste React.lazy a un componente, pero producción sigue sirviendo un único chunk de entrada de gran tamaño. La solución no es repartir más llamadas a lazy() por todas partes. Necesitas saber qué divisiones merecen la pena, cuánto cuesta cada una y cómo comprobar si funcionaron.

Este artículo presenta cinco estrategias en el orden en que suelen dar resultado, explica cómo medirlas y describe las tres formas en que el code splitting puede ralentizar una aplicación.

Puntos clave

  • La división por rutas es la primera que conviene hacer en casi cualquier aplicación de página única (SPA). Cada ruta se convierte en su propio chunk y los visitantes descargan solo la ruta en la que aterrizan.
  • webpack 5 y Rolldown (el bundler de Vite 8) ya mueven a chunks comunes el código compartido por los chunks cargados de forma diferida. Por eso, hoy un vendor.js creado a mano sirve sobre todo para estabilizar la caché, no para reducir el tamaño.
  • Si inicias import() al pasar el ratón por encima (hover) o al recibir el foco en lugar de al hacer clic, el chunk se descarga mientras el usuario todavía decide si hacer clic.
  • Un chunk de entrada más pequeño no demuestra nada por sí solo. La prueba es una cascada de red con throttling en la que el código de la primera pantalla termina de cargarse antes que en la versión anterior.
  • Dividir el código que necesita la primera pantalla no elimina la espera. Solo la retrasa.

¿Por qué un único bundle ralentiza la primera página?

Sin code splitting, una SPA sirve un único bundle de entrada con todas las rutas, modales y librerías que la aplicación pueda llegar a usar. Quien aterriza en tu página de marketing descarga, analiza y ejecuta la pantalla de configuración, el panel de administración y la librería de gráficos, aunque nunca abra ninguno de ellos. Ese código sin usar compite por el ancho de banda y por el tiempo del hilo principal con el código que realmente renderiza la página.

Cada una de las cinco estrategias de code splitting siguientes tiene un desencadenante, un coste y una forma de verificación distintos:

EstrategiaCuándo aplicaQué cuestaCómo comprobar que funcionó
División por rutasCualquier SPA con varias rutasUn fallback en cada navegaciónEl chunk de entrada ya no contiene otras rutas
División por componentesUI pesada tras una interacción o debajo del pliegue (below the fold)Un estado de carga y una petición adicionalEl chunk se carga solo cuando se activa
Chunks de vendor manualesDependencias estables que quieres cachear a largo plazoMantenimiento de la configuración y posibles cambios en el orden de ejecuciónEl hash se mantiene entre despliegues
Prefetch por interacciónClics con alta probabilidadDescargar de vez en cuando código que nadie usaLa petición empieza antes del clic
No dividirCódigo que necesita la primera pantallaNingunoNo aparecen nuevas cadenas de peticiones

Empieza por el code splitting basado en rutas

La división por rutas es la primera que merece la pena hacer en casi cualquier SPA. Cada ruta se convierte en su propio chunk y el visitante descarga solo la ruta en la que aterriza. Requiere poco código, y los cambios de ruta son el lugar natural para un breve estado de carga. Understanding React dynamic imports explica el patrón con React.lazy y Suspense. Metaframeworks como Next.js y SvelteKit dividen por rutas automáticamente.

Divide los componentes pesados detrás de una interacción

La división a nivel de componente encaja con UI pesada que no es visible en el primer renderizado: un modal, un editor de texto enriquecido, un widget de gráficos o una sección de comentarios debajo del pliegue. El coste es un estado de carga, más una petición de red justo cuando el usuario quiere usar la funcionalidad.

Todos los frameworks principales ofrecen la misma primitiva sobre el 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'));

Un componente lazy de React se renderiza dentro de un límite de Suspense, que muestra un fallback mientras se carga el chunk. En Next.js, next/dynamic combina React.lazy() y Suspense en una sola llamada, y Vue ofrece defineAsyncComponent. Svelte usa import() directamente junto con renderizado condicional.

Para el contenido debajo del pliegue, lanza la carga cuando el elemento se acerque al viewport. Crea un único Intersection Observer, desconéctalo tras la primera intersección y renderiza entonces el componente lazy. Lazy loading in JavaScript describe los patrones con observers.

Lo que tu bundler ya divide por ti

La configuración por defecto de splitChunks en webpack 5, al igual que Rollup (Rolldown en Vite 8), mueve automáticamente a un chunk común el código compartido por varios chunks cargados de forma diferida. Por defecto, webpack lo hace solo con chunks asíncronos (chunks: 'async') y solo cuando el nuevo chunk supera un tamaño mínimo. Su cache group integrado defaultVendors ya incluye node_modules. The current state of JavaScript bundlers ofrece un panorama de la situación actual de cada bundler.

Como el bundler ya extrae el código compartido, un único vendor.js creado a mano duplica en gran medida lo que hacen webpack o Rolldown. El principal motivo que queda para definir chunks de vendor manualmente es la estabilidad de la caché. Si las dependencias que rara vez cambian conservan el mismo hash de contenido entre despliegues, los visitantes recurrentes no tienen que volver a descargarlas.

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

Aquí necesitas chunks: 'all'. Sin esa opción, el código del framework que importa la entrada se queda dentro del chunk de entrada, y el hash de ese chunk cambia en cada despliegue.

En Vite 7 y versiones anteriores, la misma idea se expresaba con build.rollupOptions.output.manualChunks. Vite 8 compila con Rolldown, que ya no admite la forma de objeto de manualChunks y marca como obsoleta la forma de función en favor de los 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)[\\/]/ }],
        },
      },
    },
  },
});

Mantén los grupos acotados. La documentación de Rolldown advierte que los grupos definidos a mano pueden cambiar el orden de ejecución de los módulos, algo importante en módulos con efectos secundarios.

Empieza a cargar al pasar el ratón, no al hacer clic

El prefetch por interacción inicia import() cuando el puntero entra en un botón o cuando el botón recibe el foco, en lugar de esperar al clic. Así, el chunk se descarga mientras el usuario todavía está decidiendo. La descarga se solapa con el intervalo entre la intención y la acción, de modo que el fallback aparece solo un instante o no llega 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>}
    </>
  );
}

lazy y los manejadores de eventos comparten una misma función de carga. El sistema de módulos cachea el resultado del primer import(), así que el renderizado posterior al clic reutiliza la petición que ya está en curso. El coste es algo de ancho de banda desperdiciado cuando un usuario pasa el ratón por encima pero no hace clic.

Las pistas como webpackPrefetch son magic comments de webpack. Turbopack en Next.js interpreta algunos magic comments compatibles con webpack, pero su documentación solo enumera comentarios de control de importación como webpackIgnore, no webpackPrefetch, y el resto de bundlers por lo general no los interpretan. Consulta la documentación de tu bundler antes de depender de uno. Lanzar import() desde un manejador de eventos funciona con cualquier bundler.

Mide la cascada de red, no el tamaño del bundle

Un chunk de entrada más pequeño no demuestra nada por sí solo. La evidencia de que una división ha ayudado es una cascada de red con throttling en la que el código de la primera pantalla termina de cargarse antes que antes.

  1. Ejecuta un visualizador de bundles para tu bundler y confirma qué contiene cada chunk. Para webpack, la guía de Webpack Bundle Analyzer muestra cómo detectar módulos pesados y código que la página no usa.
  2. Abre el panel Network de Chrome DevTools, filtra por JS y aplica un perfil de red móvil con throttling.
  3. Compara el antes y el después. Comprueba cuándo termina el último chunk necesario para el primer renderizado y si han aparecido nuevas cadenas de peticiones secuenciales.

Una cadena tiene forma de escalera: cada petición de chunk empieza solo cuando termina la anterior. Los imports lazy anidados producen este patrón.

¿Cuándo hace el code splitting que todo vaya más lento?

El code splitting resulta contraproducente de tres formas predecibles.

Demasiados chunks pequeños. Cada chunk es una petición independiente. Dividir componentes pequeños añade peticiones e idas y vueltas (round trips) que cuestan más que los bytes que ahorran.

Dividir la ruta crítica. Dividir el código que necesita la primera pantalla no elimina la espera, solo la desplaza. Ahora el navegador debe descargar y ejecutar el chunk de entrada, llegar al import() dinámico y solo entonces solicitar el chunk que renderiza el contenido.

Un spinner donde antes había contenido. Un fallback que sustituye a contenido que los usuarios veían al instante puede hacer que la aplicación parezca más lenta aunque el tiempo total de carga haya bajado.

Acompaña cada límite lazy de un error boundary. Una petición de chunk puede fallar, y una pestaña abierta antes de un despliegue puede solicitar archivos con hash que ya no existen. Las grabaciones de sesión (session replays) de aplicaciones con code splitting muestran lo que un informe de bundle no puede: usuarios atascados en fallbacks de Suspense o que se topan con un ChunkLoadError tras un despliegue en mitad de la sesión.

Cómo elegir qué divisiones mantener

Empieza por las rutas y después divide los componentes pesados detrás de interacciones. Haz prefetch al pasar el ratón o al recibir el foco, y deja que el bundler gestione el código compartido salvo que quieras específicamente hashes de caché estables. Para cada división, compara la cascada de red con throttling antes y después. Si la primera pantalla no se renderiza antes, o una interacción no responde antes, revierte la división.

Preguntas frecuentes

¿Funciona React.lazy con exportaciones con nombre?

No directamente. React.lazy necesita que la promesa de su función de carga se resuelva con un módulo cuya exportación por defecto sea el componente, así que una exportación con nombre debe reasignarse: lazy(() => import('./Charts').then((m) => ({ default: m.BarChart }))). El chunk se divide igual. También funciona reexportar el componente como exportación por defecto desde un pequeño archivo envoltorio, lo que mantiene corta la llamada a lazy.

¿Cómo me recupero cuando un chunk lazy no se carga tras un despliegue?

Recarga la página para que el navegador obtenga el nuevo chunk de entrada y las URL actuales de los chunks. React conserva la primera promesa de una función de carga lazy y nunca vuelve a llamarla, así que volver a renderizar no basta para reintentar. Cuando falla un import dinámico, Vite emite un evento 'vite:preloadError' en window, y llamar a event.preventDefault() en el listener suprime el error. Limita las recargas automáticas para evitar un bucle de recargas.

¿Qué diferencia hay entre webpackPrefetch y webpackPreload?

webpackPrefetch descarga un chunk en el tiempo de inactividad del navegador después de que termine de cargarse el chunk padre, para código que probablemente se necesitará más adelante, como la siguiente pantalla. webpackPreload obtiene el chunk a la vez que su padre, con prioridad media, para código que el padre solicitará de inmediato. Ambos son magic comments de webpack que generan link hints. Preload es la opción menos habitual, porque abusar de ella puede perjudicar el rendimiento.

¿Evita Vite las cascadas de chunks automáticamente?

En parte. Cuando un chunk asíncrono depende de un chunk común compartido, Vite reescribe el import dinámico con un paso de precarga para que ambos se obtengan en paralelo y no uno tras otro. También genera enlaces modulepreload para los chunks de entrada y sus importaciones directas. Esto solo cubre las dependencias estáticas. Un componente lazy que vuelve a llamar a import() al renderizarse sigue creando una petición secuencial que aparece en la cascada de red.

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.