Utiliser la mise en cache à long terme

Comment webpack facilite le stockage en cache des composants

Après avoir optimisé la taille de l'application, la mise en cache est la deuxième chose qui permet d'améliorer le temps de chargement de l'application. Utilisez-le pour conserver des parties de l'application sur le client et éviter de les télécharger à nouveau à chaque fois.

Utiliser la gestion des versions des bundles et les en-têtes de cache

L'approche courante pour le mise en cache est la suivante :

  1. demander au navigateur de mettre en cache un fichier pendant une très longue période (par exemple, un an) :

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

    Si vous ne savez pas ce que fait Cache-Control, consultez l'excellent article de Jake Archibald sur les bonnes pratiques de mise en cache.

  2. et renommez le fichier lorsqu'il a été modifié pour forcer le téléchargement de nouveau :

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

Cette approche indique au navigateur de télécharger le fichier JS, de le mettre en cache et d'utiliser la copie mise en cache. Le navigateur n'accède au réseau que si le nom du fichier change (ou si une année s'écoule).

Avec webpack, vous procédez de la même manière, mais au lieu d'un numéro de version, vous spécifiez le hachage du fichier. Pour inclure le hachage dans le nom du fichier, utilisez [chunkhash] :

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

Si vous avez besoin du nom du fichier pour l'envoyer au client, utilisez HtmlWebpackPlugin ou WebpackManifestPlugin.

HtmlWebpackPlugin est une approche simple, mais moins flexible. Lors de la compilation, ce plug-in génère un fichier HTML qui inclut toutes les ressources compilées. Si la logique de votre serveur n'est pas complexe, cela devrait suffire :

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

WebpackManifestPlugin est une approche plus flexible, utile si vous disposez d'une partie serveur complexe. Lors de la compilation, il génère un fichier JSON avec un mappage entre les noms de fichiers sans hachage et les noms de fichiers avec hachage. Utilisez ce fichier JSON sur le serveur pour déterminer avec quel fichier travailler :

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

Documentation complémentaire

Extraire les dépendances et l'environnement d'exécution dans un fichier distinct

Dépendances

Les dépendances des applications ont tendance à changer moins souvent que le code réel de l'application. Si vous les déplacez dans un fichier distinct, le navigateur pourra les mettre en cache séparément et ne les téléchargera pas à nouveau chaque fois que le code de l'application changera.

Pour extraire les dépendances dans un bloc distinct, procédez comme suit :

  1. Remplacez le nom de fichier de sortie par [name].[chunkname].js:

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

    Lorsque webpack compile l'application, il remplace [name] par le nom d'un bloc. Si nous n'ajoutons pas la partie [name], nous devrons différencier les blocs par leur hachage, ce qui est assez difficile.

  2. Convertissez le champ entry en objet :

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

    Dans cet extrait, "main" est le nom d'un fragment. Ce nom sera remplacé à la place de [name] de l'étape 1.

    À ce stade, si vous compilez l'application, ce bloc inclura l'intégralité du code de l'application, comme si nous n'avions pas effectué ces étapes. Mais cela va changer dans une seconde.

  3. Dans webpack 4, ajoutez l'option optimization.splitChunks.chunks: 'all' à votre configuration webpack :

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

    Cette option active la division intelligente du code. Avec elle, webpack extrait le code du fournisseur s'il dépasse 30 ko (avant la minification et le gzip). Il extrait également le code commun. Cela est utile si votre compilation produit plusieurs bundles (par exemple, si vous divisez votre application en routes).

    Dans webpack 3, ajoutez 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'),
        })
      ]
    };
    

    Ce plug-in prend tous les modules dont les chemins d'accès incluent node_modules et les déplace dans un fichier distinct appelé vendor.[chunkhash].js.

Après ces modifications, chaque build générera deux fichiers au lieu d'un: main.[chunkhash].js et vendor.[chunkhash].js (vendors~main.[chunkhash].js pour webpack 4). Dans le cas de webpack 4, le bundle du fournisseur peut ne pas être généré si les dépendances sont faibles, et ce n'est pas un problème :

$ 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

Le navigateur met en cache ces fichiers séparément et ne télécharge à nouveau que le code qui change.

Code d'exécution Webpack

Malheureusement, il ne suffit pas d'extraire le code du fournisseur. Si vous essayez de modifier quelque chose dans le code de l'application:

// index.js



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

Vous remarquerez que le hachage vendor change également :

                           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

En effet, le bundle webpack, en plus du code des modules, comporte un environnement d'exécution, un petit morceau de code qui gère l'exécution du module. Lorsque vous divisez le code en plusieurs fichiers, ce fragment de code commence à inclure un mappage entre les ID de bloc et les fichiers correspondants :

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

Webpack inclut ce runtime dans le dernier bloc généré, qui est vendor dans notre cas. Et chaque fois qu'un bloc change, ce morceau de code change également, ce qui entraîne la modification de l'ensemble du bloc vendor.

Pour résoudre ce problème, déplaçons l'environnement d'exécution dans un fichier distinct. Dans webpack 4, cela se fait en activant l'option optimization.runtimeChunk :

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

Dans webpack 3, créez un bloc vide supplémentaire avec 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
    })
  ]
};

Après ces modifications, chaque compilation générera trois fichiers :

$ 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

Incluez-les dans index.html dans l'ordre inverse, et le tour est joué :

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

Documentation complémentaire

Intégration du runtime webpack pour économiser une requête HTTP supplémentaire

Pour améliorer encore les performances, essayez d'intégrer le runtime webpack dans la réponse HTML. C'est-à-dire, au lieu de ceci :

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

Procédez comme suit :

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

L'environnement d'exécution est petit, et l'intégration vous aidera à économiser une requête HTTP (assez important avec HTTP/1, moins important avec HTTP/2, mais cela peut toujours avoir un impact).

Voici comment procéder.

Si vous générez du code HTML avec HtmlWebpackPlugin

Si vous utilisez le plug-in HtmlWebpackPlugin pour générer un fichier HTML, le plug-in InlineSourcePlugin est suffisant:

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 vous générez du code HTML à l'aide d'une logique de serveur personnalisée

Avec webpack 4:

  1. Ajoutez WebpackManifestPlugin pour connaître le nom généré du bloc d'exécution :

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

    Une compilation avec ce plug-in génère un fichier qui se présente comme suit:

    // manifest.json
    {
      "runtime~main.js": "runtime~main.8e0d62a03.js"
    }
    
  2. Insérer le contenu du bloc d'exécution de manière pratique. Par exemple, avec Node.js et 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>
    
      `);
    });
    

Ou avec webpack 3:

  1. Rendez le nom de l'environnement d'exécution statique en spécifiant filename:

    module.exports = {
      plugins: [
        new webpack.optimize.CommonsChunkPlugin({
          name: 'runtime',
          minChunks: Infinity,
          filename: 'runtime.js'
        })
      ]
    };
    
  2. Insérer le contenu runtime.js de manière pratique. Par exemple, avec Node.js et Express :

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

Code de chargement différé dont vous n'avez pas besoin pour le moment

Il arrive parfois qu'une page comporte des parties plus ou moins importantes :

  • Si vous chargez une page de vidéo sur YouTube, vous êtes plus intéressé par la vidéo que par les commentaires. Ici, la vidéo est plus importante que les commentaires.
  • Si vous ouvrez un article sur un site d'actualités, vous êtes plus intéressé par le texte de l'article que par les annonces. Ici, le texte est plus important que les annonces.

Dans ce cas, améliorez les performances de chargement initial en ne téléchargeant que les éléments les plus importants en premier, puis en chargeant de manière paresseuse les parties restantes plus tard. Pour ce faire, utilisez la fonction import() et le fractionnement du code :

// 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() indique que vous souhaitez charger un module spécifique de manière dynamique. Lorsque webpack voit import('./module.js'), il déplace ce module dans un bloc distinct :

$ 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

et ne le télécharge que lorsque l'exécution atteint la fonction import().

Le bundle main sera ainsi plus petit, ce qui améliore le temps de chargement initial. De plus, cela améliore la mise en cache : si vous modifiez le code du bloc principal, le bloc de commentaires ne sera pas affecté.

Documentation complémentaire

Diviser le code en routes et en pages

Si votre application comporte plusieurs routes ou pages, mais qu'il n'y a qu'un seul fichier JS avec le code (un seul bloc main), il est probable que vous diffusiez des octets supplémentaires sur chaque requête. Par exemple, lorsqu'un utilisateur visite une page d'accueil de votre site:

Page d&#39;accueil de WebFundamentals

Ils n'ont pas besoin de charger le code pour afficher un article sur une autre page, mais ils le chargeront. De plus, si l'utilisateur ne consulte que la page d'accueil et que vous modifiez le code de l'article, webpack invalidera l'ensemble du bundle et devra télécharger à nouveau l'application dans son intégralité.

Si nous divisons l'application en pages (ou en routes, s'il s'agit d'une application monopage), l'utilisateur ne téléchargera que le code pertinent. De plus, le navigateur met mieux en cache le code de l'application: si vous modifiez le code de la page d'accueil, webpack n'invalide que le fragment correspondant.

Pour les applications monopages

Pour diviser les applications monopages par routes, utilisez import() (consultez la section Code de chargement différé dont vous n'avez pas besoin pour le moment). Si vous utilisez un framework, il est possible qu'il propose une solution existante pour ce problème :

Pour les applications multipages traditionnelles

Pour diviser les applications traditionnelles par pages, utilisez les points d'entrée de webpack. Si votre application comporte trois types de pages : la page d'accueil, la page de l'article et la page du compte utilisateur, elle doit comporter trois entrées :

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

Pour chaque fichier d'entrée, webpack crée un arbre de dépendances distinct et génère un bundle qui n'inclut que les modules utilisés par cette entrée :

$ 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

Par conséquent, si seule la page de l'article utilise Lodash, les bundles home et profile ne l'incluront pas. L'utilisateur n'aura donc pas à télécharger cette bibliothèque lorsqu'il accédera à la page d'accueil.

Toutefois, les arborescences de dépendances distinctes présentent des inconvénients. Si deux points d'entrée utilisent Lodash d'entrée et que vous n'avez pas déplacé vos dépendances dans un bundle de fournisseurs, les deux points d'entrée incluront une copie de Lodash. Pour résoudre ce problème, dans webpack 4,ajoutez l'option optimization.splitChunks.chunks: 'all' à votre configuration webpack:

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

Cette option active la division intelligente du code. Avec cette option, webpack recherche automatiquement le code commun et l'extrait dans des fichiers distincts.

Dans webpack 3, vous pouvez également utiliser CommonsChunkPlugin. Il déplacera les dépendances communes vers un nouveau fichier spécifié :

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

N'hésitez pas à jouer avec la valeur minChunks pour trouver la meilleure. En général, vous devez le maintenir à une taille réduite, mais l'augmenter si le nombre de segments augmente. Par exemple, pour trois blocs, minChunks peut être défini sur 2, mais pour 30 blocs, il peut être défini sur 8. En effet, si vous le laissez à 2, trop de modules seront inclus dans le fichier commun, ce qui le gonflera trop.

Documentation complémentaire

Rendre les ID de module plus stables

Lors de la compilation du code, webpack attribue un ID à chaque module. Par la suite, ces ID sont utilisés dans les require() du bundle. Les ID apparaissent généralement dans la sortie de compilation juste avant les chemins d'accès des modules :

$ 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

↓ Ici

[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

Par défaut, les ID sont calculés à l'aide d'un compteur (c'est-à-dire que le premier module a l'ID 0, le deuxième l'ID 1, etc.). Le problème est que lorsque vous ajoutez un module, il peut apparaître au milieu de la liste des modules, ce qui modifie tous les ID des modules suivants :

$ 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]

↓ Nous avons ajouté un nouveau module…

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

↓ Et regardez ce qu'il a fait ! comments.js a désormais l'ID 5 au lieu de 4

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

ads.js a désormais l'ID 6 au lieu de 5

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

Cela invalide tous les fragments qui incluent ou dépendent de modules dont les ID ont été modifiés, même si leur code réel n'a pas changé. Dans notre cas, le bloc 0 (le bloc avec comments.js) et le bloc main (le bloc avec l'autre code d'application) sont invalidés, alors que seul le bloc main aurait dû l'être.

Pour résoudre ce problème, modifiez la façon dont les ID de module sont calculés à l'aide de HashedModuleIdsPlugin. Il remplace les ID basés sur des compteurs par des hachages de chemins de module :

$ 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

↓ Ici

[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

Avec cette approche, l'ID d'un module ne change que si vous le renommez ou le déplacez. Les nouveaux modules n'affecteront pas les ID des autres modules.

Pour activer le plug-in, ajoutez-le à la section plugins de la configuration :

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

Documentation complémentaire

Récapitulatif

  • Mettre en cache le bundle et différencier les versions en modifiant son nom
  • Diviser le bundle en code d'application, code du fournisseur et environnement d'exécution
  • Insérer le runtime pour enregistrer une requête HTTP
  • Chargement paresseux du code non critique avec import
  • Divisez le code par routes/pages pour éviter de charger des éléments inutiles