Rendimiento mejorado de carga de páginas de Next.js y Gatsby con fragmentación detallada

Una estrategia de fragmentación de webpack más reciente en Next.js y Gatsby minimiza el código duplicado para mejorar el rendimiento de carga de la página.

Chrome colabora con herramientas y marcos de trabajo en el ecosistema de código abierto de JavaScript. Recientemente, se agregaron varias optimizaciones más nuevas para mejorar el rendimiento de carga de Next.js y Gatsby. En este artículo, se describe una estrategia mejorada de fragmentación detallada que ahora se envía de forma predeterminada en ambos frameworks.

Al igual que muchos frameworks web, Next.js y Gatsby usan webpack como su agrupador principal. webpack v3 introdujo CommonsChunkPlugin para permitir la salida de módulos compartidos entre diferentes puntos de entrada en un solo (o varios) fragmentos "comunes". El código compartido se puede descargar por separado y almacenar en la caché del navegador desde el principio, lo que puede generar un mejor rendimiento de carga.

Este patrón se hizo popular con muchos frameworks de aplicaciones de una sola página que adoptaron una configuración de punto de entrada y paquete que se veía de la siguiente manera:

Configuración común de paquetes y puntos de entrada

Aunque es práctico, el concepto de agrupar todo el código del módulo compartido en un solo fragmento tiene sus limitaciones. Los módulos que no se comparten en todos los puntos de entrada se pueden descargar para las rutas que no los usan, lo que genera que se descargue más código del necesario. Por ejemplo, cuando page1 carga el fragmento common, carga el código de moduleC, aunque page1 no use moduleC. Por este motivo, junto con algunos otros, webpack v4 quitó el complemento en favor de uno nuevo: SplitChunksPlugin.

División en fragmentos mejorada

La configuración predeterminada de SplitChunksPlugin funciona bien para la mayoría de los usuarios. Se crean varios fragmentos divididos según una serie de condiciones para evitar la recuperación de código duplicado en varias rutas.

Sin embargo, muchos frameworks web que usan este complemento aún siguen un enfoque de "comunes únicos" para la división de fragmentos. Next.js, por ejemplo, generaría un paquete commons que contendría cualquier módulo que se use en más del 50% de las páginas y todas las dependencias del framework (react, react-dom, etcétera).

const splitChunksConfigs = {
  
  prod: {
    chunks: 'all',
    cacheGroups: {
      default: false,
      vendors: false,
      commons: {
        name: 'commons',
        chunks: 'all',
        minChunks: totalPages > 2 ? totalPages * 0.5 : 2,
      },
      react: {
        name: 'commons',
        chunks: 'all',
        test: /[\\/]node_modules[\\/](react|react-dom|scheduler|use-subscription)[\\/]/,
      },
    },
  },

Si bien incluir código dependiente del framework en un fragmento compartido significa que se puede descargar y almacenar en caché para cualquier punto de entrada, la heurística basada en el uso de incluir módulos comunes que se usan en más de la mitad de las páginas no es muy eficaz. Si modificas esta proporción, solo se producirá uno de los siguientes dos resultados:

  • Si reduces la proporción, se descargará más código innecesario.
  • Si aumentas la proporción, se duplicará más código en varias rutas.

Para resolver este problema, Next.js adoptó una configuración diferente paraSplitChunksPlugin que reduce el código innecesario para cualquier ruta.

  • Cualquier módulo de terceros lo suficientemente grande (superior a 160 KB) se divide en su propio fragmento individual.
  • Se crea un fragmento frameworks independiente para las dependencias del framework (react, react-dom, etc.)
  • Se crean tantos fragmentos compartidos como sea necesario (hasta 25)
  • El tamaño mínimo para que se genere un fragmento se cambió a 20 KB.

Esta estrategia de división detallada ofrece los siguientes beneficios:

  • Se mejoran los tiempos de carga de la página. Emitir varios fragmentos compartidos, en lugar de uno solo, minimiza la cantidad de código innecesario (o duplicado) para cualquier punto de entrada.
  • Se mejoró el almacenamiento en caché durante las navegaciones. Dividir bibliotecas grandes y dependencias de framework en fragmentos separados reduce la posibilidad de invalidación de la caché, ya que es poco probable que ambas cambien hasta que se realice una actualización.

Puedes ver toda la configuración que adoptó Next.js en webpack-config.ts.

Más solicitudes HTTP

SplitChunksPlugin definió las bases para el fragmentación detallada, y aplicar este enfoque a un framework como Next.js no era un concepto completamente nuevo. Sin embargo, muchos frameworks seguían usando una sola heurística y una estrategia de paquetes "comunes" por algunos motivos. Esto incluye la preocupación de que muchas más solicitudes HTTP puedan afectar negativamente el rendimiento del sitio.

Los navegadores solo pueden abrir una cantidad limitada de conexiones TCP a un solo origen (6 para Chrome), por lo que minimizar la cantidad de fragmentos que genera un empaquetador puede garantizar que la cantidad total de solicitudes permanezca por debajo de este umbral. Sin embargo, esto solo es válido para HTTP/1.1. La multiplexación en HTTP/2 permite que se transmitan varias solicitudes en paralelo con una sola conexión a través de un solo origen. En otras palabras, por lo general, no tenemos que preocuparnos por limitar la cantidad de fragmentos que emite nuestro empaquetador.

Todos los navegadores principales admiten HTTP/2. Los equipos de Chrome y Next.js querían ver si aumentar la cantidad de solicitudes dividiendo el único paquete “común” de Next.js en varios fragmentos compartidos afectaría el rendimiento de la carga de alguna manera. Comenzó midiendo el rendimiento de un solo sitio mientras modificaba la cantidad máxima de solicitudes simultáneas con la propiedad maxInitialRequests.

Rendimiento de carga de la página con una mayor cantidad de solicitudes

En un promedio de tres ejecuciones de varias pruebas en una sola página web, los tiempos de load, inicio de renderización y Primer procesamiento de imagen con contenido permanecieron aproximadamente iguales cuando se varió el recuento máximo de solicitudes iniciales (de 5 a 15). Curiosamente, notamos una ligera sobrecarga de rendimiento solo después de dividirlo de forma agresiva en cientos de solicitudes.

Rendimiento de carga de la página con cientos de solicitudes

Esto demostró que mantenerse por debajo de un umbral confiable (entre 20 y 25 solicitudes) lograba el equilibrio correcto entre el rendimiento de carga y la eficiencia del almacenamiento en caché. Después de algunas pruebas de referencia, se seleccionó 25 como el recuento de maxInitialRequest.

Modificar la cantidad máxima de solicitudes que se realizan en paralelo generó más de un paquete compartido, y separarlos de forma adecuada para cada punto de entrada redujo significativamente la cantidad de código innecesario para la misma página.

Reducción de la carga útil de JavaScript con un mayor fragmentación

El objetivo de este experimento era solo modificar la cantidad de solicitudes para ver si habría algún efecto negativo en el rendimiento de la carga de la página. Los resultados sugieren que establecer maxInitialRequests en 25 en la página de prueba fue lo más óptimo, ya que redujo el tamaño de la carga útil de JavaScript sin ralentizar la página. La cantidad total de JavaScript que se necesitaba para hidratar la página seguía siendo aproximadamente la misma, lo que explica por qué el rendimiento de carga de la página no mejoró necesariamente con la cantidad reducida de código.

Webpack usa 30 KB como tamaño mínimo predeterminado para que se genere un fragmento. Sin embargo, combinar un valor de maxInitialRequests de 25 con un tamaño mínimo de 20 KB generó un mejor almacenamiento en caché.

Reducción de tamaño con fragmentos detallados

Muchos frameworks, incluido Next.js, dependen del enrutamiento del cliente (controlado por JavaScript) para insertar etiquetas de secuencia de comandos más nuevas para cada transición de ruta. Pero, ¿cómo se predeterminan estos fragmentos dinámicos en el tiempo de compilación?

Next.js usa un archivo de manifiesto de compilación del servidor para determinar qué fragmentos generados usan los diferentes puntos de entrada. Para proporcionar esta información al cliente, se creó un archivo de manifiesto de compilación resumido del cliente para asignar todas las dependencias de cada punto de entrada.

// Returns a promise for the dependencies for a particular route
getDependencies (route) {
  return this.promisedBuildManifest.then(
    man => (man[route] && man[route].map(url => `/_next/${url}`)) || []
  )
}
Salida de varios fragmentos compartidos en una aplicación de Next.js.

Esta nueva estrategia de fragmentación detallada se lanzó por primera vez en Next.js detrás de una marca, donde se probó en una serie de usuarios pioneros. Muchos observaron reducciones significativas en el JavaScript total que se usaba en todo su sitio:

Sitio web Cambio total de JS % de diferencia
https://www.barnebys.com/ -238 KB -23%
https://sumup.com/ -220 KB -30%
https://www.hashicorp.com/ -11 MB -71%
Reducción de tamaño de JavaScript en todas las rutas (comprimidas)

La versión final se envió de forma predeterminada en la versión 9.2.

Gatsby

Gatsby solía seguir el mismo enfoque de usar una heurística basada en el uso para definir módulos comunes:

config.optimization = {
  
  splitChunks: {
    name: false,
    chunks: `all`,
    cacheGroups: {
      default: false,
      vendors: false,
      commons: {
        name: `commons`,
        chunks: `all`,
        // if a chunk is used more than half the components count,
        // we can assume it's pretty global
        minChunks: componentsCount > 2 ? componentsCount * 0.5 : 2,
      },
      react: {
        name: `commons`,
        chunks: `all`,
        test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
      },

Cuando optimizaron su configuración de Webpack para adoptar una estrategia de fragmentación detallada similar, también observaron reducciones significativas de JavaScript en muchos sitios grandes:

Sitio web Cambio total de JS % de diferencia
https://www.gatsbyjs.org/ -680 KB -22%
https://www.thirdandgrove.com/ -390 KB -25%
https://ghost.org/ -1.1 MB -35%
https://reactjs.org/ -80 Kb -8%
Reducción de tamaño de JavaScript en todas las rutas (comprimidas)

Consulta la PR para comprender cómo implementaron esta lógica en su configuración de Webpack, que se envía de forma predeterminada en la versión 2.20.7.

Conclusión

El concepto de envío de fragmentos detallados no es específico de Next.js, Gatsby ni siquiera de webpack. Todos deberían considerar mejorar la estrategia de fragmentación de su aplicación si sigue un enfoque de paquete "común" grande, independientemente del framework o el empaquetador de módulos que se use.

  • Si deseas ver las mismas optimizaciones de fragmentación aplicadas a una aplicación React sin modificaciones, consulta esta app de React de ejemplo. Usa una versión simplificada de la estrategia de fragmentación detallada y puede ayudarte a comenzar a aplicar el mismo tipo de lógica a tu sitio.
  • En el caso de Rollup, los fragmentos se crean de forma granular de forma predeterminada. Consulta manualChunks si deseas configurar el comportamiento de forma manual.