Accélérer la construction d'applications Web avec Webpack

Au fur et à mesure que votre application se développe et se développe, son temps de construction augmente également - de plusieurs minutes lors du remontage en mode développement à des dizaines de minutes lors du montage en production «à froid». C'est totalement inacceptable. Nous, les développeurs, n'aimons pas changer de contexte en attendant que le bundle soit prêt et souhaitons recevoir les commentaires de l'application le plus tôt possible - idéalement lors du passage de l'IDE au navigateur.


Comment y parvenir? Que pouvons-nous faire pour optimiser le temps de construction?


Cet article est un aperçu des outils existants dans l'écosystème webpack pour accélérer l'assemblage, leur expérience et leurs conseils.


L'optimisation de la taille du bundle et des performances de l'application elle-mĂŞme n'est pas prise en compte dans cet article.


Le projet, dont les références se trouvent dans le texte et concernant les mesures de la vitesse d'assemblage qui sont effectuées, est une application relativement petite écrite sur la pile JS + Flow + React + Redux à l'aide de webpack, Babel, PostCSS, Sass, etc. et composée d'environ 30 000 lignes de code et 1 500 modules. Les versions de dépendance sont à jour en avril 2019.


Les études ont été menées sur un ordinateur avec Windows 10, Node.js 8, un processeur à 4 cœurs, 8 Go de mémoire et SSD.


Terminologie


  • L'assemblage est le processus de conversion des fichiers source du projet en un ensemble d'actifs associĂ©s qui constituent ensemble une application Web.
  • dev-mode - assemblage avec le mode: 'development' option mode: 'development' , utilisant gĂ©nĂ©ralement webpack-dev-server et watch-mode.
  • prod-mode - assemblage avec le mode: 'production' option mode: 'production' , gĂ©nĂ©ralement avec un ensemble complet d'optimisations de bundle.
  • Build incrĂ©mental - en mode dev: reconstruisez uniquement les fichiers avec des modifications.
  • GĂ©nĂ©ration «à froid» - gĂ©nĂ©ration Ă  partir de zĂ©ro, sans cache, mais avec les dĂ©pendances installĂ©es.

Mise en cache


La mise en cache vous permet d'enregistrer les résultats des calculs pour une réutilisation ultérieure. Le premier assemblage peut être légèrement plus lent que d'habitude en raison de la surcharge de la mise en cache, mais les suivants seront beaucoup plus rapides en raison de la réutilisation des résultats de la compilation des modules inchangés.


Par défaut, webpack en mode veille met en cache les résultats de la construction intermédiaire en mémoire afin de ne pas réassembler le projet entier à chaque modification. Pour une version normale (pas en mode montre), ce paramètre n'a pas de sens. Vous pouvez également essayer d'activer la résolution du cache pour faciliter la recherche de modules par Webpack et voir si ce paramètre a un effet notable sur votre projet.


Il n'y a pas de cache persistant (enregistré sur disque ou autre stockage) dans webpack, bien qu'ils promettent de l'ajouter dans la version 5. En attendant, nous pouvons utiliser les outils suivants:


- Mise en cache dans les paramètres TerserWebpackPlugin


Désactivé par défaut. Même seul, il a un effet positif notable: 60,7 s → 39 s (-36%), va bien avec d'autres outils de mise en cache.


La mise sous tension et l'utilisation sont très simples:


 optimization: { minimizer: [ new TerserJsPlugin({ terserOptions: { ... }, cache: true }) ] } 

- cache-loader


Le chargeur de cache peut être placé dans n'importe quelle chaîne de chargeurs et mettre en cache les résultats des chargeurs précédents.


Par défaut, il enregistre le cache dans le dossier .cache-loader à la racine du projet. En utilisant l'option cacheDirectory dans les paramètres du chargeur, le chemin peut être redéfini.


Exemple d'utilisation:


 { test: /\.js$/, use: [ { loader: 'cache-loader', options: { cacheDirectory: path.resolve( __dirname, 'node_modules/.cache/cache-loader' ), }, }, 'babel-loader' ] } 

Solution sûre et fiable. Il fonctionne sans problème avec presque tous les chargeurs: pour les scripts (babel-loader, ts-loader), les styles (scss-, less-, postcss-, css-loader), les images et les polices (image-webpack-loader, react-svg- chargeur, chargeur de fichiers), etc.


Veuillez noter:


  • Lorsque vous utilisez cache-loader en conjonction avec style-loader ou MiniCssExtractPlugin.loader, il doit ĂŞtre placĂ© après eux:
    ['style-loader', 'cache-loader', 'css-loader', ...] .
  • Contrairement aux recommandations de la documentation d'utiliser ce chargeur pour mettre en cache uniquement les rĂ©sultats de calculs laborieux, il peut très bien donner une augmentation de performances faible mais mesurable pour les chargeurs «plus lĂ©gers» - vous devez essayer de mesurer.

Résultats:


  • dev: 35,5 s → (activer le chargeur de cache) → 36,2 s (+ 2%) → (rĂ©assemblage) → 7,9 s (-78%)
  • prod: 60,6 s → (activer le chargeur de cache) → 61,5 s (+ 1,5%) → (rĂ©assemblage) → 30,6 s (-49%) → (activer le cache pour Terser) → 15, 4 s (-75%)

- HardSourceWebpackPlugin


Une solution plus massive et «intelligente» pour la mise en cache au niveau de l'ensemble du processus d'assemblage, plutôt que des chaînes individuelles de chargeurs. Dans le cas d'utilisation de base, il suffit d'ajouter le plugin à la configuration du webpack, les paramètres standard devraient être suffisants pour le bon fonctionnement. Convient à ceux qui souhaitent atteindre des performances maximales et n'ont pas peur de faire face à des difficultés.


 plugins: [ ..., new HardSourceWebpackPlugin() ] 

La documentation contient des exemples d'utilisation avec des paramètres avancés et des conseils pour résoudre d'éventuels problèmes. Avant de mettre le plug-in en service de manière continue, il convient de tester minutieusement son fonctionnement dans diverses situations et modes d'assemblage.


Résultats:


  • dev: 35,5 s → (activer le plugin) → 36,5 s (+ 3%) → (remontage) → 3,7 s (-90%)
  • prod: 60,6 s → (activer le plugin) → 69,5 s (+ 15%) → (remonter) → 25 s (-59%) → (activer le cache pour Terser) → 10 s (-83%)

Avantages:


  • ComparĂ© au cache-chargeur, il accĂ©lère encore plus le rĂ©assemblage;
  • Il ne nĂ©cessite pas de dĂ©clarations en double Ă  diffĂ©rents endroits de la configuration, comme dans le chargeur de cache

Inconvénients:


  • ComparĂ© au chargeur de cache, il ralentit davantage la première gĂ©nĂ©ration (lorsqu'il n'y a pas de cache disque);
  • peut augmenter lĂ©gèrement le temps de reconstruction incrĂ©mentiel;
  • peut provoquer des problèmes lors de l'utilisation de webpack-dev-server et nĂ©cessiter une configuration dĂ©taillĂ©e de la sĂ©paration du cache et de l'invalidation (voir la documentation );
  • pas mal de problèmes avec les bugs sur GitHub.

- Mise en cache dans les paramètres du chargeur de babel . Désactivé par défaut. L'effet est de plusieurs pour cent pire que celui du chargeur de cache.


- Mise en cache dans les paramètres du chargeur eslint . Désactivé par défaut. Si vous utilisez ce chargeur, le cache vous aidera à ne pas perdre de temps à aligner des fichiers inchangés lors du réassemblage.




Lorsque vous utilisez le chargeur de cache ou HardSourceWebpackPlugin, vous devez désactiver les mécanismes de mise en cache intégrés dans d'autres plugins ou chargeurs (à l'exception de TerserWebpackPlugin), car ils cesseront d'être utiles dans les versions répétées et incrémentielles, et les versions «froides» ralentiront même. La même chose s'applique au cache-chargeur lui-même si HardSourceWebpackPlugin est déjà utilisé.




Lors de la configuration de la mise en cache, les questions suivantes peuvent se poser:


Où les résultats de mise en cache doivent-ils être stockés?


node_modules/.cache/<_>/ sont généralement stockés dans le node_modules/.cache/<_>/ . La plupart des outils utilisent ce chemin par défaut et vous permettent de le remplacer si vous souhaitez stocker le cache ailleurs.


Quand et comment invalider le cache?


Il est très important de vider le cache lorsque des modifications sont apportées à la configuration de génération, ce qui affectera la sortie. L'utilisation de l'ancien cache dans de tels cas est nuisible et peut entraîner des erreurs de nature inconnue.


Facteurs à considérer:


  • liste des dĂ©pendances et de leurs versions: package.json, package-lock.json, yarn.lock, .yarn-Integrity;
  • le contenu de webpack, Babel, PostCSS, Browserslist et d'autres fichiers de configuration qui sont explicitement ou implicitement utilisĂ©s par les chargeurs et les plugins.

Si vous n'utilisez pas le chargeur de cache ou HardSourceWebpackPlugin, qui vous permettent de redéfinir la liste des sources pour former l'empreinte digitale de l'assembly, les scripts npm qui effacent le cache lors de l'ajout, de la mise à jour ou de la suppression des dépendances vous aideront un peu plus facilement:


 "prunecaches": "rimraf ./node_modules/.cache/", "postinstall": "npm run prunecaches", "postuninstall": "npm run prunecaches" 

Nodemon configuré pour effacer le cache et redémarrer webpack-dev-server lors de la détection des changements dans les fichiers de configuration aidera également:


 "start": "cross-env NODE_ENV=development nodemon --exec \"webpack-dev-server --config webpack.config.dev.js\"" 

nodemon.json


 { "watch": [ "webpack.config.dev.js", "babel.config.js", "more configs...", ], "events": { "restart": "yarn prunecaches" } } 

Dois-je enregistrer le cache dans le référentiel du projet?


Étant donné que le cache est, en fait, un artefact d'assembly, il n'est pas nécessaire de le valider dans le référentiel. L'emplacement du cache dans le dossier node_modules, qui, en règle générale, est inclus dans .gitignore, vous y aidera.


Il convient de noter que s'il existait un système de mise en cache qui pourrait déterminer de manière fiable la validité du cache dans toutes les conditions, y compris le changement du système d'exploitation et de la version de Node.js, le cache pourrait être réutilisé entre des machines de développement ou dans CI, ce qui réduirait considérablement le temps même de la toute première construction après commutation entre les branches.


Dans quels modes de construction cela vaut-il et dans lequel cela ne vaut-il pas la peine d'utiliser un cache?


Il n'y a pas de réponse définitive ici: tout dépend de l'intensité avec laquelle vous utilisez les modes dev et prod pendant le développement et basculez entre eux. En général, rien n'empêche d'activer la mise en cache partout, mais n'oubliez pas que cela ralentit généralement la première génération. Dans CI, vous avez probablement toujours besoin d'une version «propre», auquel cas la mise en cache peut être désactivée à l'aide de la variable d'environnement appropriée.




Matériaux intéressants sur la mise en cache dans le webpack:



Parallélisation


En utilisant la parallélisation, vous pouvez obtenir une amélioration des performances en utilisant tous les cœurs de processeur disponibles. L'effet final est individuel pour chaque voiture.


À propos, voici un code Node.js simple pour obtenir le nombre de cœurs de processeur disponibles (il peut être utile lors de la configuration des outils répertoriés ci-dessous):


 const os = require('os'); const cores = os.cpus().length; 

- Parallélisation dans les paramètres TerserWebpackPlugin


Désactivé par défaut. En plus de sa propre mise en cache, il s'allume facilement et accélère sensiblement l'assemblage.


 optimization: { minimizer: [ new TerserJsPlugin({ terserOptions: { ... }, parallel: true }) ] } 

- chargeur de fil


Le chargeur de threads peut être placé dans une chaîne de chargeurs qui effectuent des calculs lourds, après quoi les chargeurs précédents utiliseront le pool de sous-processus Node.js (processeurs).


Il dispose d'un ensemble d'options qui vous permettent d'affiner le travail du pool de travailleurs, bien que les valeurs de base semblent tout à fait adéquates. poolTimeout et les workers méritent une attention particulière - voir un exemple .


Il peut être utilisé avec cache-loader comme suit (l'ordre est important): ['cache-loader', 'thread-loader', 'babel-loader'] . Si le préchauffage est activé pour le chargeur de threads, vous devez vérifier la stabilité des assemblys répétés qui utilisent le cache - le webpack peut se bloquer et ne pas terminer le processus une fois l'assembly terminé avec succès. Dans ce cas, désactivez simplement l'échauffement.


Si vous rencontrez un blocage de build après avoir ajouté un chargeur de threads à la chaîne de compilation de style Sass, cette astuce pourrait vous aider.


- happypack


Un plugin qui intercepte les appels des chargeurs et distribue leur travail sur plusieurs threads. Pour le moment, il est en mode support (c'est-à-dire que le développement n'est pas prévu), et son créateur recommande le thread-loader en remplacement. Ainsi, si votre projet est à jour, il vaut mieux s'abstenir d'utiliser HappyPack, même s'il vaut certainement la peine d'essayer et de comparer les résultats avec le chargeur de threads.


HappyPack possède une documentation de configuration compréhensible, ce qui est d'ailleurs assez inhabituel en soi: il est proposé de déplacer les configurations du chargeur vers l'appel du constructeur du plug-in et de remplacer les chaînes de chargeur elles-mêmes par leur propre chargeur happypack. Une telle approche non standard peut entraîner des désagréments lors de la création d'une configuration Webpack personnalisée «à partir de pièces».


HappyPack prend en charge une liste limitée de chargeurs ; les principaux et les plus utilisés dans cette liste sont présents, mais les performances des autres ne sont pas garanties en raison d'une éventuelle incompatibilité de l'API. Plus d'informations peuvent être trouvées dans les numéros du projet.


Refus de calculs


Tout travail prend du temps. Pour passer moins de temps, vous devez éviter les travaux de peu d'utilité, qui peuvent être reportés à plus tard, ou pas du tout nécessaires dans cette situation.


- Appliquer des chargeurs sur le moins de modules possible


Les propriétés test, exclude et include spécifient les conditions d'inclusion du module dans le processus de traitement par le chargeur. Le but est d'éviter de transformer des modules qui n'ont pas besoin de cette transformation.


Un exemple populaire est l'exception de node_modules de la transpilation via Babel:


 rules: [ { test: /\.jsx?$/, exclude: /node_modules/, loader: 'babel-loader' } ] 

Un autre exemple est que les fichiers CSS ordinaires n'ont pas besoin d'être traités par un préprocesseur:


 rules: [ { test: /\.scss$/, use: ['style-loader', 'css-loader', 'sass-loader'] }, { test: /\.css$/, use: ['style-loader', 'css-loader'] } ] 

- Ne pas activer les optimisations de taille de bundle en mode dev


Sur une machine de développeur puissante avec un Internet stable, une application déployée localement démarre généralement rapidement, même si elle pèse quelques mégaoctets. L'optimisation d'un faisceau lors de l'assemblage peut prendre beaucoup plus de temps que d'économiser sur la charge.


Les conseils concernent JS (Terser, Uglify , etc. ), CSS (cssnano, optimiser-css-assets-webpack-plugin), SVG et images (SVGO, Imagemin, image-webpack-loader), HTML (html-minifier, option dans html-webpack-plugin), etc.


- Ne pas inclure les polyfills et les transformations en mode dev


Si vous utilisez babel-preset-env, postcss-preset-env ou Autoprefixer - ajoutez une configuration Browserslist distincte pour le mode dev, y compris uniquement les navigateurs que vous utilisez pendant le développement. Il s'agit très probablement des dernières versions de Chrome ou Firefox qui prennent parfaitement en charge les normes modernes sans polyfills et transformations. Cela évitera un travail inutile.


Exemple .browserslistrc:


 [production] your supported browsers go here... [development] last 2 Chrome versions last 2 Firefox versions last 1 Safari version 

- Revoir l'utilisation des cartes sources


La génération des cartes sources les plus précises et complètes prend beaucoup de temps (sur notre projet - environ 30% du temps de génération de prod avec l' devtool: 'source-map' ). Réfléchissez si vous avez besoin de cartes sources dans l'assemblage prod (localement et dans CI). Il peut être utile de les générer uniquement lorsque cela est nécessaire - par exemple, en fonction d'une variable d'environnement ou d'une balise lors de la validation.


En mode dev, dans la plupart des cas, il y aura une option plutôt légère - 'cheap-eval-source-map' ou 'cheap-module-eval-source-map' . Voir la documentation du webpack pour plus de détails.


- Configurer la compression dans Terser


Selon la documentation Terser (il en va de même pour Uglify), lors de la réduction du code, la majeure partie du temps est consommée par les options mangle et compress . En les affinant, vous pouvez obtenir une accélération de l'assemblage au prix d'une légère augmentation de la taille du faisceau. Il y a un exemple dans les sources de vue-cli et un autre exemple d'un ingénieur de Slack. Dans notre projet, le réglage Terser dans le premier mode de réalisation réduit le temps d'assemblage d'environ 7% en échange d'une augmentation de 2,5% de la taille du faisceau. Que le jeu en vaille la chandelle dépend de vous.


- Exclure les dépendances externes de l'analyse


En utilisant les resolve.alias module.noParse et module.noParse resolve.alias vous pouvez rediriger l'importation des modules de bibliothèque vers des versions déjà compilées et simplement les insérer dans le bundle sans perdre de temps à analyser. En mode dev, cela devrait augmenter considérablement la vitesse d'assemblage, y compris incrémentale.


L'algorithme est approximativement le suivant:


(1) Faites une liste des modules qui doivent être ignorés lors de l'analyse.


Idéalement, ce sont toutes des dépendances d'exécution qui tombent dans le bundle (ou du moins les plus massives d'entre elles, telles que react-dom ou lodash), et non seulement les leurs (premier niveau), mais aussi transitives (dépendances de dépendance). À l'avenir, vous devrez maintenir cette liste vous-même.


(2) Pour les modules sélectionnés, écrivez les chemins d'accès à leurs versions compilées.


Au lieu d'ignorer les dépendances, vous devez fournir au collecteur une alternative, et cette alternative ne devrait pas dépendre de l'environnement - avoir des appels à module.exports , require , process , import , etc. Les modules de fichier unique précompilés (pas nécessairement minifiés), qui se trouvent généralement dans le dossier dist à l'intérieur des sources de dépendance, conviennent à ce rôle. Pour les trouver, vous devez vous rendre sur node_modules. Par exemple, pour axios, le chemin d'accès au module compilé ressemble à ceci: node_modules/axios/dist/axios.js .


(3) Dans la configuration du webpack, utilisez l'option resol.alias pour remplacer les importations par des noms de dépendances par des importations directes de fichiers dont les chemins ont été écrits à l'étape précédente.


Par exemple:


 { resolve: { alias: { axios: path.resolve( __dirname, 'node_modules/dist/axios.min.js' ), ... } } } 

Il y a un gros défaut ici: si votre code ou le code de vos dépendances n'accède pas au point d'entrée standard (fichier d'index, champ main dans package.json ), mais un fichier spécifique à l'intérieur des sources de dépendances, ou si la dépendance est exportée en tant que module ES, ou si le processus de résolution interfère avec quelque chose (par exemple, babel-plugin-transform-imports), l'idée peut échouer. Le bundle se réunira, mais l'application sera cassée.


(4) Dans la configuration du webpack, utilisez l'option module.noParse pour ignorer l'analyse des modules précompilés demandés par les chemins de l'étape 2 à l'aide d'expressions régulières.


Par exemple:


 { module: { noParse: [ new RegExp('node_modules/dist/axios.min.js'), ... ] } } 

Conclusion: sur le papier, la méthode semble prometteuse, mais une configuration non triviale avec des pièges augmente au moins les coûts de mise en œuvre, et à tout le moins réduit les avantages.


Une alternative avec un principe de fonctionnement similaire consiste à utiliser l'option externals . Dans ce cas, vous devrez insérer indépendamment des liens vers des scripts externes dans le fichier HTML, et même avec les versions de dépendance nécessaires correspondant à package.json.


- Séparez rarement le code changeant dans un bundle séparé et compilez-le une seule fois


Vous avez sûrement entendu parler de DllPlugin . Avec lui, vous pouvez distribuer du code changeant activement (votre application) et rarement du code (par exemple, des dépendances) dans différents assemblys. Une fois que le bundle de dépendances assemblé (la même DLL) est ensuite simplement connecté à l'assembly d'application, il fait gagner du temps.


Cela ressemble à ceci en termes généraux:


  1. Pour créer la DLL, une configuration Webpack distincte est créée, les modules nécessaires sont connectés en tant que points d'entrée.
  2. La construction commence avec cette configuration. DllPlugin génère un bundle DLL et un fichier manifeste avec des noms de carte et des chemins de module.
  3. DllReferencePlugin est ajouté à la configuration de l'assembly principal, dans lequel le manifeste est passé.
  4. Les importations de dépendances rendues dans les DLL pendant l'assemblage sont mappées sur des modules déjà compilés à l'aide du manifeste.

Vous pouvez en lire un peu plus dans l'article ici .


En commençant à utiliser cette approche, vous trouverez rapidement un certain nombre d'inconvénients:


  • L'assembly DLL est isolĂ© de l'assembly principal et doit ĂŞtre gĂ©rĂ© sĂ©parĂ©ment: prĂ©parez une configuration spĂ©ciale, redĂ©marrez-la chaque fois qu'une branche est commutĂ©e ou qu'une dĂ©pendance change.
  • Étant donnĂ© que la DLL n'est pas liĂ©e aux artefacts de l'assembly principal, elle devra ĂŞtre copiĂ©e manuellement dans le dossier avec les autres actifs et incluse dans le fichier HTML Ă  l'aide de l'un de ces plugins: 1 , 2 .
  • Il est nĂ©cessaire de maintenir manuellement Ă  jour la liste des dĂ©pendances destinĂ©es Ă  ĂŞtre incluses dans le bundle DLL.
  • La chose la plus triste: l'arborescence n'est pas appliquĂ©e au bundle DLL. En thĂ©orie, l'option entryOnly est destinĂ©e Ă  cela, mais ils ont oubliĂ© de la documenter.

Vous pouvez vous débarrasser du passe-partout et résoudre le premier problème (ainsi que le second, si vous utilisez html-webpack-plugin v3 - cela ne fonctionne pas avec la version 4) en utilisant AutoDllPlugin . Cependant, il ne prend toujours pas en charge l'option entryOnly pour le entryOnly utilisé "sous le capot", et l'auteur du plugin doute de l'opportunité d'utiliser son idée à la lumière du prochain webpack 5.


Divers


Mettez régulièrement à jour votre logiciel et vos dépendances. Node.js, npm / yarn (webpack, Babel .) . , changelog, issues, , .


PostCSS postcss-preset-env stage, . , stage-3, Custom Properties, stage-4 13%.


Sass (node-sass, sass-loader), Dart Sass ( Sass Dart, JS) fast-sass-loader . , . — dart-sass , node-sass, JS, libsass.


Dart Sass sass-loader . Sass fibers.


CSS-, dev-. - , , , .


Un exemple:


 { loader: 'css-loader', options: { modules: true, localIdentName: isDev ? '[path][name][local]' : '[hash:base64:5]' } } 

, , : .


, - webpack PrefetchPlugin , , — . webpack issues , . ?


  1. . CLI- --json , . . , , dev- .
  2. - Hints.
  3. , “Long module build chains”. , — PrefetchPlugin .
  4. PrefetchPlugin. . StackOverflow .

: .



, (TypeScript, Angular .) — !


Les sources


, , , .


Source: https://habr.com/ru/post/fr451146/


All Articles