Aprovechar el almacenamiento en caché a largo plazo

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

El siguiente paso (después de optimizar el tamaño de la app 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.

Usa el control de versiones de paquetes y los encabezados de caché

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

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

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

    Si no sabes qué hace Cache-Control, consulta la excelente publicación de Jake Archibald sobre las prácticas recomendadas de almacenamiento en caché.

  2. y cambiar el nombre del archivo cuando se modifique para forzar la nueva descarga:

    <!-- 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 la 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 del servidor no es compleja, debería ser suficiente por ti:

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

WebpackManifestPlugin es un enfoque más flexible que resulta útil si tienes una parte compleja del servidor. Durante la compilación, genera un archivo JSON con una asignación entre los nombres de archivos sin hash y los nombres de archivos con hash. Usa este JSON en el servidor para averiguar 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 separado, 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 las dependencias en un bloque separado, realiza 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 reemplazará en lugar de [name] del paso 1.

    Por ahora, si compilas la app, este fragmento incluirá todo el código de la app, de la misma manera que no completamos 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 tu 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á el código del proveedor si supera los 30 KB (antes de la reducción y gzip). También extraerá el código común, lo que resulta útil si tu compilación produce varios paquetes (p.ej., si divides tu app en rutas).

    En webpack 3, agrega lo siguiente 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 de acceso incluyen node_modules y los mueve a un archivo separado 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 el paquete del proveedor no se genere 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 cambia.

Código de entorno de ejecución de Webpack

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

// index.js
…
…

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

Verás que el hash 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 webpack, además del código de los módulos, tiene un entorno de ejecución, es decir, un pequeño fragmento de código que administra la ejecución del módulo. Cuando divides el código en varios archivos, este fragmento comienza a incluir una asignación entre los IDs de fragmento 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. Cada vez que se modifica un fragmento, este fragmento del código también cambia, lo que hace que cambie todo el fragmento vendor.

Para resolver esto, muevamos el entorno de ejecución a un archivo separado. 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 el 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úyelas en index.html en 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

Entorno 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 de webpack en la respuesta HTML. Es decir, en lugar de esto:

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

hacer esto:

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

El entorno de ejecución es pequeño, y su intercalado te ayudará a guardar una solicitud HTTP (bastante importante con HTTP/1; menos importante con HTTP/2, pero podría tener un efecto de todos modos).

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

Si generas HTML con el complemento HtmlWebpackPlugin

Si usas HtmlWebpackPlugin para generar un archivo HTML, solo necesitas 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 mediante una lógica de servidor personalizada

Con webpack 4:

  1. Agrega WebpackManifestPlugin para conocer el nombre generado del bloque 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á un archivo similar al siguiente:

    // manifest.json
    {
      "runtime~main.js": "runtime~main.8e0d62a03.js"
    }
    
  2. Intercala el contenido del fragmento de entorno de ejecución de forma conveniente. P.ej., 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. Especifica filename para hacer que el nombre del entorno de ejecución sea estático:

    module.exports = {
      plugins: [
        new webpack.optimize.CommonsChunkPlugin({
          name: 'runtime',
          minChunks: Infinity,
          filename: 'runtime.js'
        })
      ]
    };
    
  2. Intercala el contenido de runtime.js de manera conveniente. P.ej., 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>
        …
      `);
    });
    

Código de carga diferida que no necesitas en este momento

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

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

En esos casos, para mejorar el rendimiento de carga inicial, descarga solo los elementos más importantes primero y, luego, realiza una carga diferida de las partes restantes. Usa la función import() y la división de código para lo siguiente:

// 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 quieres 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 lo descarga solo cuando la ejecución alcanza la función import().

De esta manera, se reducirá el paquete main y se mejorará el tiempo de carga inicial. Aun más, mejorará el almacenamiento en caché. Si cambias el código en el bloque principal, el bloque 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 archivo JS con el código (un solo fragmento main), es probable que estés entregando bytes adicionales en cada solicitud. Por ejemplo, cuando un usuario visita una página principal de tu sitio:

Página principal de WebFundamentals

no necesita cargar 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 la app completa.

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

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 las 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 del artículo y la de la cuenta de usuario, debería 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 independiente y generará un paquete que incluya solo los módulos que usa 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 dependencia separados tienen sus desventajas. Si dos puntos de entrada usan Lodash y no moviste tus dependencias a un paquete de proveedores, ambos puntos de entrada incluirán una copia de Lodash. Para resolver esto, en webpack 4,agrega la opción optimization.splitChunks.chunks: 'all' a la configuración de tu paquete web:

// 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á automáticamente el código común y lo extraerá en archivos separados.

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

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

Juega con el valor de minChunks para encontrar el mejor. En general, se recomienda que sea pequeño, pero aumentarlo si la cantidad de fragmentos aumenta. Por ejemplo, para 3 fragmentos, minChunks podría ser 2, pero para 30, podría ser 8. Esto se debe a que, si se mantiene en 2, se ingresarán demasiados módulos en el archivo común y se infla demasiado.

Lecturas adicionales

Cómo mejorar la estabilidad de los ID de módulo

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

$ 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 un ID 0, el segundo tiene un ID 1, y así sucesivamente). El problema es que, cuando agregas un módulo nuevo, es posible que este aparezca en el 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 logró. comments.js ahora tiene el ID 5 en lugar del 4

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

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

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

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

Para resolver esto, cambia la manera en que se calculan los IDs de módulos con HashedModuleIdsPlugin. Reemplaza los ID basados en contadores con hashes de rutas de acceso 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 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
  • Intercala el entorno de ejecución para guardar una solicitud HTTP
  • Carga diferida de código no crítico con import
  • Divide el código por rutas o páginas para evitar cargar elementos innecesarios.