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

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

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

Introducción

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

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

Punto de entrada común y configuración de paquetes

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 lo usan, lo que hace que se descargue más código de lo necesario. Por ejemplo, cuando page1 carga el fragmento common, carga el código para moduleC aunque page1 no use moduleC. Por este motivo, junto con algunos otros, webpack v4 quitó el complemento y se reemplazó por uno nuevo: SplitChunksPlugin.

Fragmentación 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 conditions para evitar recuperar el código duplicado en varias rutas.

Sin embargo, muchos frameworks web que usan este complemento aún siguen un enfoque de “comunes comunes” para la división de fragmentos. Por ejemplo, Next.js generaría un paquete commons que contuviera cualquier módulo que se usa 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)[\\/]/,
      },
    },
  },

Aunque incluir el código que depende 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 los módulos comunes utilizados en más de la mitad de las páginas no es muy eficaz. Modificar esta proporción solo obtendría uno de 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.

A fin de resolver este problema, Next.js adoptó una configuración diferente para SplitChunksPlugin que reduce el código innecesario para cualquier ruta.

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

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

  • Mejoran los tiempos de carga de la página. La emisión de 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 las bibliotecas grandes y las dependencias del framework en fragmentos separados reduce la posibilidad de invalidar 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ó la base para la fragmentación detallada, y aplicar este enfoque a un framework como Next.js no fue un concepto completamente nuevo. Sin embargo, muchos frameworks seguían usando una única estrategia de paquetes heurístico y “comunes” por varios 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 agrupador 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 transmitir múltiples solicitudes en paralelo mediante una sola conexión a través de un solo origen. En otras palabras, por lo general, no debemos preocuparnos por limitar la cantidad de fragmentos que emite nuestro agrupador.

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 paquete “commons” de Next.js en varios fragmentos compartidos afectaría el rendimiento de carga de alguna manera. Comenzaron midiendo el rendimiento de un solo sitio y modificando la cantidad máxima de solicitudes paralelas con la propiedad maxInitialRequests.

Rendimiento de carga de página con un mayor número 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 First Contentful Paint se mantuvieron casi iguales cuando se modificó el recuento máximo de solicitudes iniciales (de 5 a 15). Curiosamente, notamos una leve sobrecarga de rendimiento solo después de realizar una división agresiva en cientos de solicitudes.

Rendimiento de carga de página con cientos de solicitudes

Esto demostró que mantenerse por debajo de un umbral confiable (20 a 25 solicitudes) encontró el equilibrio adecuado entre el rendimiento de carga y la eficiencia del almacenamiento en caché. Después de algunas pruebas de referencia, se seleccionaron 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 separarlas adecuadamente para cada punto de entrada redujo significativamente la cantidad de código innecesario para la misma página.

Reducciones de la carga útil de JavaScript con mayor fragmentación

Este experimento solo consistía en 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 la configuración de maxInitialRequests en 25 en la página de prueba fue óptima porque redujo el tamaño de la carga útil de JavaScript sin ralentizar la página. La cantidad total de JavaScript necesaria para hidratar la página se mantuvo casi igual, lo que explica por qué el rendimiento de carga de la página no mejoró necesariamente con la menor cantidad de código.

webpack usa 30 KB como tamaño mínimo predeterminado para que se genere un bloque. Sin embargo, el acoplamiento de un valor maxInitialRequests de 25 con un tamaño mínimo de 20 KB en su lugar dio como resultado un mejor almacenamiento en caché.

Reducciones de tamaño con fragmentos detallados

Muchos frameworks, incluido Next.js, dependen del enrutamiento del cliente (controlado por JavaScript) para incorporar etiquetas de secuencia de comandos más nuevas para cada transición de ruta. Pero ¿cómo 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 resultantes usan los diferentes puntos de entrada. Para proporcionar esta información también al cliente, se creó un archivo de manifiesto de compilación del lado del cliente a fin de asignar todas las dependencias para 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}`)) || []
  )
}
Resultado 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 un número de usuarios pioneros. Muchos observaron reducciones significativas en el JavaScript total que se usa 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%
Reducciones del 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

Se utilizó Gatsby para seguir el mismo enfoque que el 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)[\\/]/,
      },

Al optimizar la configuración de su webpack para adoptar una estrategia de fragmentación detallada similar, también notaron reducciones considerables 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%
Reducciones del 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 incluye 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 webpack. Todos deberían considerar mejorar la estrategia de fragmentación de su aplicación si sigue un enfoque grande de paquetes “comunes”, sin importar el framework o el agrupador de módulos que se use.

  • Si deseas ver las mismas optimizaciones de fragmentación aplicadas a una aplicación de React estándar, consulta esta app de ejemplo de React. 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 detallada de forma predeterminada. Consulta manualChunks si deseas configurar el comportamiento de forma manual.