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.
Usa el control de versiones de paquetes y los encabezados de caché
El enfoque común para almacenar en caché es el siguiente:
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é.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
- Jake Archibald sobre las prácticas recomendadas de almacenamiento en caché
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:
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.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.
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 llamadovendor.[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
- Guía de Webpack sobre el almacenamiento en caché a largo plazo
- Documentación de Webpack sobre el entorno de ejecución y el manifiesto de webpack
- “Cómo aprovechar al máximo CommonsChunkPlugin”
- Cómo funcionan
optimization.splitChunks
yoptimization.runtimeChunk
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:
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" }
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:
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' }) ] };
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
- Documentación de Webpack para la función
import()
- La propuesta de JavaScript para implementar la sintaxis
import()
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:
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:
- "Code Splitting" en la documentación de
react-router
(para React) - "Carga diferida de rutas" en los documentos de
vue-router
(para Vue.js)
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
- Documentación de Webpack sobre el concepto de puntos de entrada
- Documentación de Webpack sobre CommonsChunkPlugin
- "Aprovechar al máximo CommonsChunkPlugin"
- Cómo funcionan
optimization.splitChunks
yoptimization.runtimeChunk
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
- Documentos de Webpack sobre el plugin HashedModuleIds
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.