Aprovechar el almacenamiento en caché a largo plazo

Cómo ayuda webpack con el almacenamiento en caché de recursos

Después de optimizar el tamaño de la app, lo siguiente que mejora el tiempo de carga de la app es el almacenamiento en caché. Úsalo para mantener partes de la app en el cliente y evitar volver a descargarlas cada vez.

El enfoque común para almacenar en caché es el siguiente:

  1. Indica al navegador que almacene en caché un archivo durante mucho tiempo (p. ej., un año):

    # Server header
    Cache-Control: max-age=31536000
    

    Si no conoces las funciones de Cache-Control, consulta la excelente entrada de Jake Archibald sobre las prácticas recomendadas de almacenamiento en caché.

  2. y cambia el nombre del archivo cuando se cambie para forzar la reinstalación:

    <!-- Before the change -->
    <script src="./index-v15.js"></script>
    
    <!-- After the change -->
    <script src="./index-v16.js"></script>
    

Este enfoque le indica al navegador que descargue el archivo JS, lo almacene en caché y use la copia almacenada en caché. El navegador solo accederá a la red si cambia el nombre del archivo (o si pasa un año).

Con webpack, haces lo mismo, pero en lugar de un número de versión, especificas el hash del archivo. Para incluir el hash en el nombre del archivo, usa [chunkhash]:

// webpack.config.js
module.exports = {
  entry: './index.js',
  output: {
    filename: 'bundle.[chunkhash].js' // → bundle.8e0d62a03.js
  }
};

Si necesitas el nombre del archivo para enviarlo al cliente, usa HtmlWebpackPlugin o WebpackManifestPlugin.

HtmlWebpackPlugin es un enfoque simple, pero menos flexible. Durante la compilación, este complemento genera un archivo HTML que incluye todos los recursos compilados. Si la lógica de tu servidor no es compleja, esto debería ser suficiente:

<!-- index.html -->
<!DOCTYPE html>
<!-- ... -->
<script src="bundle.8e0d62a03.js"></script>

WebpackManifestPlugin es un enfoque más flexible que resulta útil si tienes una parte de servidor compleja. Durante la compilación, genera un archivo JSON con una asignación entre los nombres de archivo sin hash y los nombres de archivo con hash. Usa este JSON en el servidor para saber con qué archivo trabajar:

// manifest.json
{
  "bundle.js": "bundle.8e0d62a03.js"
}

Lecturas adicionales

Extrae las dependencias y el entorno de ejecución en un archivo separado

Dependencias

Las dependencias de la app tienden a cambiar con menos frecuencia que el código real de la app. Si los mueves a un archivo independiente, el navegador podrá almacenarlos en caché por separado y no los volverá a descargar cada vez que cambie el código de la app.

Para extraer dependencias en un fragmento separado, sigue tres pasos:

  1. Reemplaza el nombre del archivo de salida por [name].[chunkname].js:

    // webpack.config.js
    module.exports = {
      output: {
        // Before
        filename: 'bundle.[chunkhash].js',
        // After
        filename: '[name].[chunkhash].js'
      }
    };
    

    Cuando webpack compila la app, reemplaza [name] por el nombre de un fragmento. Si no agregamos la parte [name], tendremos que diferenciar los fragmentos por su hash, lo que es bastante difícil.

  2. Convierte el campo entry en un objeto:

    // webpack.config.js
    module.exports = {
      // Before
      entry: './index.js',
      // After
      entry: {
        main: './index.js'
      }
    };
    

    En este fragmento, “main” es el nombre de un fragmento. Este nombre se sustituirá en lugar de [name] del paso 1.

    En este momento, si compilas la app, este fragmento incluirá todo el código de la app, como si no hubiéramos realizado estos pasos. Pero esto cambiará en un segundo.

  3. En webpack 4, agrega la opción optimization.splitChunks.chunks: 'all' a la configuración de webpack:

    // webpack.config.js (for webpack 4)
    module.exports = {
      optimization: {
        splitChunks: {
          chunks: 'all'
        }
      }
    };
    

    Esta opción habilita la división de código inteligente. Con él, webpack extraería el código del proveedor si supera los 30 KB (antes de la reducción y el gzip). También extraería el código común, lo que es útil si tu compilación produce varios paquetes (p. ej., si divides tu app en rutas).

    En webpack 3, agrega CommonsChunkPlugin:

    // webpack.config.js (for webpack 3)
    module.exports = {
      plugins: [
        new webpack.optimize.CommonsChunkPlugin({
        // A name of the chunk that will include the dependencies.
        // This name is substituted in place of [name] from step 1
        name: 'vendor',
    
        // A function that determines which modules to include into this chunk
        minChunks: module => module.context && module.context.includes('node_modules'),
        })
      ]
    };
    

    Este complemento toma todos los módulos cuyas rutas incluyen node_modules y los mueve a un archivo independiente llamado vendor.[chunkhash].js.

Después de estos cambios, cada compilación generará dos archivos en lugar de uno: main.[chunkhash].js y vendor.[chunkhash].js (vendors~main.[chunkhash].js para webpack 4). En el caso de webpack 4, es posible que no se genere el paquete del proveedor si las dependencias son pequeñas, y eso está bien:

$ webpack
Hash: ac01483e8fec1fa70676
Version: webpack 3.8.1
Time: 3816ms
                        Asset      Size  Chunks             Chunk Names
 ./main.00bab6fd3100008a42b0.js   82 kB       0  [emitted]  main
./vendor.d9e134771799ecdf9483.js  47 kB       1  [emitted]  vendor

El navegador almacenaría en caché estos archivos por separado y volvería a descargar solo el código que cambie.

Código del entorno de ejecución de Webpack

Lamentablemente, no basta con extraer solo el código del proveedor. Si intentas cambiar algo en el código de la app, ocurrirá lo siguiente:

// index.js



// E.g. add this:
console.log('Wat');

Notarás que el hash de vendor también cambia:

                           Asset   Size  Chunks             Chunk Names
./vendor.d9e134771799ecdf9483.js  47 kB       1  [emitted]  vendor

                            Asset   Size  Chunks             Chunk Names
./vendor.e6ea4504d61a1cc1c60b.js  47 kB       1  [emitted]  vendor

Esto sucede porque el paquete del paquete web, además del código de los módulos, tiene un entorno de ejecución, que es un pequeño fragmento de código que administra la ejecución del módulo. Cuando divides el código en varios archivos, esta parte de código comienza a incluir una asignación entre los IDs de los fragmentos y los archivos correspondientes:

// vendor.e6ea4504d61a1cc1c60b.js
script.src = __webpack_require__.p + chunkId + "." + {
    "0": "2f2269c7f0a55a5c1871"
}[chunkId] + ".js";

Webpack incluye este entorno de ejecución en el último fragmento generado, que es vendor en nuestro caso. Y cada vez que cambia un fragmento, también cambia esta parte del código, lo que hace que cambie todo el fragmento vendor.

Para resolver esto, movamos el entorno de ejecución a un archivo independiente. En webpack 4, esto se logra habilitando la opción optimization.runtimeChunk:

// webpack.config.js (for webpack 4)
module.exports = {
  optimization: {
    runtimeChunk: true
  }
};

En webpack 3, crea un fragmento vacío adicional con CommonsChunkPlugin:

// webpack.config.js (for webpack 3)
module.exports = {
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor',
      minChunks: module => module.context && module.context.includes('node_modules')
    }),
    // This plugin must come after the vendor one (because webpack
    // includes runtime into the last chunk)
    new webpack.optimize.CommonsChunkPlugin({
      name: 'runtime',
      // minChunks: Infinity means that no app modules
      // will be included into this chunk
      minChunks: Infinity
    })
  ]
};

Después de estos cambios, cada compilación generará tres archivos:

$ webpack
Hash: ac01483e8fec1fa70676
Version: webpack 3.8.1
Time: 3816ms
                            Asset     Size  Chunks             Chunk Names
   ./main.00bab6fd3100008a42b0.js    82 kB       0  [emitted]  main
 ./vendor.26886caf15818fa82dfa.js    46 kB       1  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime

Inclúyelos en index.html en el orden inverso y listo:

<!-- index.html -->
<script src="./runtime.79f17c27b335abc7aaf4.js"></script>
<script src="./vendor.26886caf15818fa82dfa.js"></script>
<script src="./main.00bab6fd3100008a42b0.js"></script>

Lecturas adicionales

Tiempo de ejecución de webpack intercalado para guardar una solicitud HTTP adicional

Para mejorar aún más, intenta integrar el entorno de ejecución del paquete web en la respuesta HTML. Es decir, en lugar de esto:

<!-- index.html -->
<script src="./runtime.79f17c27b335abc7aaf4.js"></script>

haz lo siguiente:

<!-- index.html -->
<script>
!function(e){function n(r){if(t[r])return t[r].exports;…}} ([]);
</script>

El tiempo de ejecución es pequeño y, si lo incorporas, podrás ahorrar una solicitud HTTP (muy importante con HTTP/1, menos importante con HTTP/2, pero aún podría tener un efecto).

o crear a partir de ellos. Te mostramos cómo.

Si generas HTML con HtmlWebpackPlugin

Si usas HtmlWebpackPlugin para generar un archivo HTML, solo necesitas el InlineSourcePlugin:

const HtmlWebpackPlugin = require('html-webpack-plugin');
const InlineSourcePlugin = require('html-webpack-inline-source-plugin');

module.exports = {
  plugins: [
    new HtmlWebpackPlugin({
      inlineSource: 'runtime~.+\\.js',
    }),
    new InlineSourcePlugin()
  ]
};

Si generas HTML con una lógica de servidor personalizada

Con webpack 4:

  1. Agrega WebpackManifestPlugin para conocer el nombre generado del fragmento del entorno de ejecución:

    // webpack.config.js (for webpack 4)
    const ManifestPlugin = require('webpack-manifest-plugin');
    
    module.exports = {
      plugins: [
        new ManifestPlugin()
      ]
    };
    

    Una compilación con este complemento crearía un archivo que se ve de la siguiente manera:

    // manifest.json
    {
      "runtime~main.js": "runtime~main.8e0d62a03.js"
    }
    
  2. Incorpora el contenido del fragmento del entorno de ejecución de una manera conveniente. Por ejemplo, con Node.js y Express:

    // server.js
    const fs = require('fs');
    const manifest = require('./manifest.json');
    const runtimeContent = fs.readFileSync(manifest['runtime~main.js'], 'utf-8');
    
    app.get('/', (req, res) => {
      res.send(`
    
        <script>${runtimeContent}</script>
    
      `);
    });
    

O con webpack 3:

  1. Haz que el nombre del entorno de ejecución sea estático mediante la especificación de filename:

    module.exports = {
      plugins: [
        new webpack.optimize.CommonsChunkPlugin({
          name: 'runtime',
          minChunks: Infinity,
          filename: 'runtime.js'
        })
      ]
    };
    
  2. Incorpora el contenido de runtime.js de forma conveniente. Por ejemplo, con Node.js y Express:

    // server.js
    const fs = require('fs');
    const runtimeContent = fs.readFileSync('./runtime.js', 'utf-8');
    
    app.get('/', (req, res) => {
      res.send(`
    
        <script>${runtimeContent}</script>
    
      `);
    });
    

Carga diferida del código que no necesitas en este momento

A veces, una página tiene partes cada vez más y menos importantes:

  • Si cargas una página de video en YouTube, te importa más el video que los comentarios. En este caso, el video es más importante que los comentarios.
  • Si abres un artículo en un sitio de noticias, te importa más el texto del artículo que los anuncios. Aquí, el texto es más importante que los anuncios.

En esos casos, mejora el rendimiento de carga inicial descargando primero solo el contenido más importante y cargando de forma diferida las partes restantes más adelante. Para ello, usa la función import() y la code-splitting:

// videoPlayer.js
export function renderVideoPlayer() {  }

// comments.js
export function renderComments() {  }

// index.js
import {renderVideoPlayer} from './videoPlayer';
renderVideoPlayer();

// …Custom event listener
onShowCommentsClick(() => {
  import('./comments').then((comments) => {
    comments.renderComments();
  });
});

import() especifica que deseas cargar un módulo específico de forma dinámica. Cuando webpack ve import('./module.js'), mueve este módulo a un fragmento independiente:

$ webpack
Hash: 39b2a53cb4e73f0dc5b2
Version: webpack 3.8.1
Time: 4273ms
                            Asset     Size  Chunks             Chunk Names
      ./0.8ecaf182f5c85b7a8199.js  22.5 kB       0  [emitted]
   ./main.f7e53d8e13e9a2745d6d.js    60 kB       1  [emitted]  main
 ./vendor.4f14b6326a80f4752a98.js    46 kB       2  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime

y la descarga solo cuando la ejecución llega a la función import().

De esta manera, el paquete main se reducirá, lo que mejorará el tiempo de carga inicial. Además, mejorará la caché: si cambias el código del fragmento principal, el fragmento de comentarios no se verá afectado.

Lecturas adicionales

Divide el código en rutas y páginas

Si tu app tiene varias rutas o páginas, pero solo hay un único archivo JS con el código (un solo bloque main), es probable que entregues bytes adicionales en cada solicitud. Por ejemplo, cuando un usuario visita la página principal de tu sitio, ocurre lo siguiente:

Página principal de WebFundamentals

No es necesario que cargue el código para renderizar un artículo que está en una página diferente, pero lo cargará. Además, si el usuario siempre visita solo la página principal y realizas un cambio en el código del artículo, webpack invalidará todo el paquete, y el usuario deberá volver a descargar toda la app.

Si dividimos la app en páginas (o rutas, si es una app de una sola página), el usuario solo descargará el código relevante. Además, el navegador almacenará en caché mejor el código de la app: si cambias el código de la página principal, webpack invalidará solo el fragmento correspondiente.

Para apps de una sola página

Para dividir las apps de una sola página por rutas, usa import() (consulta la sección "Código de carga diferida que no necesitas en este momento"). Si usas un framework, es posible que tenga una solución existente para esto:

Para apps tradicionales de varias páginas

Para dividir apps tradicionales por páginas, usa los puntos de entrada de webpack. Si tu app tiene tres tipos de páginas: la página principal, la página del artículo y la página de la cuenta de usuario, debe tener tres entradas:

// webpack.config.js
module.exports = {
  entry: {
    home: './src/Home/index.js',
    article: './src/Article/index.js',
    profile: './src/Profile/index.js'
  }
};

Para cada archivo de entrada, webpack compilará un árbol de dependencias separado y generará un paquete que incluya solo los módulos que utiliza esa entrada:

$ webpack
Hash: 318d7b8490a7382bf23b
Version: webpack 3.8.1
Time: 4273ms
                            Asset     Size  Chunks             Chunk Names
      ./0.8ecaf182f5c85b7a8199.js  22.5 kB       0  [emitted]
   ./home.91b9ed27366fe7e33d6a.js    18 kB       1  [emitted]  home
./article.87a128755b16ac3294fd.js    32 kB       2  [emitted]  article
./profile.de945dc02685f6166781.js    24 kB       3  [emitted]  profile
 ./vendor.4f14b6326a80f4752a98.js    46 kB       4  [emitted]  vendor
./runtime.318d7b8490a7382bf23b.js  1.45 kB       5  [emitted]  runtime

Por lo tanto, si solo la página del artículo usa Lodash, los paquetes home y profile no lo incluirán, y el usuario no tendrá que descargar esta biblioteca cuando visite la página principal.

Sin embargo, los árboles de dependencias separados tienen sus inconvenientes. Si dos puntos de entrada usan Lodash y no trasladaste tus dependencias a un paquete de proveedores, ambos puntos de entrada incluirán una copia de Lodash. Para solucionar este problema, en webpack 4, agrega la opción optimization.splitChunks.chunks: 'all' a tu configuración de webpack:

// webpack.config.js (for webpack 4)
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all'
    }
  }
};

Esta opción habilita la división de código inteligente. Con esta opción, webpack buscaría automáticamente el código común y lo extraería en archivos separados.

O bien, en webpack 3, usa CommonsChunkPlugin, que moverá las dependencias comunes a un archivo nuevo especificado:

module.exports = {
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: 'common',
      minChunks: 2    // 2 is the default value
    })
  ]
};

No dudes en experimentar con el valor de minChunks para encontrar el mejor. Por lo general, es conveniente mantenerlo pequeño, pero aumentarlo si aumenta la cantidad de fragmentos. Por ejemplo, para 3 fragmentos, minChunks podría ser 2, pero para 30 fragmentos, podría ser 8, ya que, si lo mantienes en 2, se ingresarán demasiados módulos en el archivo común, lo que lo aumentará demasiado.

Lecturas adicionales

Se hicieron más estables los IDs de los módulos.

Cuando se compila el código, webpack asigna un ID a cada módulo. Más adelante, estos IDs se usan en require() dentro del paquete. Por lo general, ves los IDs en el resultado de la compilación justo antes de las rutas de acceso del módulo:

$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
                           Asset      Size  Chunks             Chunk Names
      ./0.8ecaf182f5c85b7a8199.js  22.5 kB       0  [emitted]
   ./main.4e50a16675574df6a9e9.js    60 kB       1  [emitted]  main
 ./vendor.26886caf15818fa82dfa.js    46 kB       2  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime

↓ Aquí

[0] ./index.js 29 kB {1} [built]
[2] (webpack)/buildin/global.js 488 bytes {2} [built]
[3] (webpack)/buildin/module.js 495 bytes {2} [built]
[4] ./comments.js 58 kB {0} [built]
[5] ./ads.js 74 kB {1} [built]
+ 1 hidden module

De forma predeterminada, los IDs se calculan con un contador (es decir, el primer módulo tiene el ID 0, el segundo tiene el ID 1, y así sucesivamente). El problema con esto es que, cuando agregas un módulo nuevo, es posible que aparezca en medio de la lista de módulos y cambie todos los IDs de los siguientes módulos:

$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
                           Asset      Size  Chunks             Chunk Names
      ./0.5c82c0f337fcb22672b5.js    22 kB       0  [emitted]
   ./main.0c8b617dfc40c2827ae3.js    82 kB       1  [emitted]  main
 ./vendor.26886caf15818fa82dfa.js    46 kB       2  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime
   [0] ./index.js 29 kB {1} [built]
   [2] (webpack)/buildin/global.js 488 bytes {2} [built]
   [3] (webpack)/buildin/module.js 495 bytes {2} [built]

↓ Agregamos un módulo nuevo…

[4] ./webPlayer.js 24 kB {1} [built]

↓ ¡Mira lo que hizo! comments.js ahora tiene el ID 5 en lugar de 4.

[5] ./comments.js 58 kB {0} [built]

ads.js ahora tiene el ID 6 en lugar de 5

[6] ./ads.js 74 kB {1} [built]
       + 1 hidden module

Esto invalida todos los fragmentos que incluyen o dependen de módulos con IDs modificados, incluso si su código real no cambió. En nuestro caso, el fragmento 0 (el fragmento con comments.js) y el fragmento main (el fragmento con el otro código de la app) se invalidan, mientras que solo debería haberse invalidado el fragmento main.

Para solucionar este problema, cambia la forma en que se calculan los IDs de módulo con HashedModuleIdsPlugin. Reemplaza los IDs basados en contadores por hashes de rutas de módulos:

$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
                           Asset      Size  Chunks             Chunk Names
      ./0.6168aaac8461862eab7a.js  22.5 kB       0  [emitted]
   ./main.a2e49a279552980e3b91.js    60 kB       1  [emitted]  main
 ./vendor.ff9f7ea865884e6a84c8.js    46 kB       2  [emitted]  vendor
./runtime.25f5d0204e4f77fa57a1.js  1.45 kB       3  [emitted]  runtime

↓ Aquí

[3IRH] ./index.js 29 kB {1} [built]
[DuR2] (webpack)/buildin/global.js 488 bytes {2} [built]
[JkW7] (webpack)/buildin/module.js 495 bytes {2} [built]
[LbCc] ./webPlayer.js 24 kB {1} [built]
[lebJ] ./comments.js 58 kB {0} [built]
[02Tr] ./ads.js 74 kB {1} [built]
    + 1 hidden module

Con este enfoque, el ID de un módulo solo cambia si le cambias el nombre o lo mueves. Los módulos nuevos no afectarán los IDs de otros módulos.

Para habilitar el complemento, agrégalo a la sección plugins de la configuración:

// webpack.config.js
module.exports = {
  plugins: [
    new webpack.HashedModuleIdsPlugin()
  ]
};

Lecturas adicionales

En resumen

  • Almacena en caché el paquete y diferencia entre las versiones cambiando el nombre del paquete
  • Divide el paquete en código de la app, código del proveedor y entorno de ejecución
  • Cómo intercalar el entorno de ejecución para guardar una solicitud HTTP
  • Carga diferida de código no esencial con import
  • Divide el código por rutas o páginas para evitar cargar elementos innecesarios.