Cooking Perfect CSS

Bonjour, Habr!

Il n'y a pas si longtemps, j'ai réalisé que travailler avec CSS dans toutes mes applications est une douleur pour le développeur et l'utilisateur.

Sous la coupe se trouvent mes problèmes, un tas de code étrange et des pièges sur la façon de travailler correctement avec les styles.


Problème CSS


Dans les projets React et Vue que j'ai réalisés, l'approche des styles était à peu près la même. Le projet est collecté par webpack, un fichier CSS est importé du point d'entrée principal. Ce fichier importe en lui-même le reste des fichiers CSS qui utilisent BEM pour nommer les classes.

styles/ indes.css blocks/ apps-banner.css smart-list.css ... 

Est-ce familier? J'ai utilisé cette implémentation presque partout. Et tout allait bien jusqu'à ce que l'un des sites atteigne un état tel que les problèmes de style ont commencé à énerver mes yeux.

1. Le problème du rechargement à chaud
L'importation des styles les uns des autres s'est faite via le plugin postcss ou le chargeur de stylets.
Le hic est le suivant:

Lorsque nous résolvons les importations via le plugin postcss ou le chargeur de stylets, la sortie est un grand fichier CSS. Maintenant, même avec un léger changement dans l'une des feuilles de style, tous les fichiers CSS seront à nouveau traités.

Il tue vraiment la vitesse de rechargement Ă  chaud: il faut environ 4 secondes pour traiter ~ 950 Ko de fichiers de stylet.

Remarque sur css-loader
Si l'importation de fichiers CSS a été résolue via css-loader, un tel problème ne se serait pas posé:
css-loader transforme CSS en JavaScript. Il remplacera toutes les importations de style par require. La modification d'un fichier CSS n'affectera pas les autres fichiers et le rechargement Ă  chaud se fera rapidement.

Vers css-loader

 /* main.css */ @import './test.css'; html, body { margin: 0; padding: 0; width: 100%; height: 100%; } body { /* background-color: #a1616e; */ background-color: red; } 

Après

 /* main.css */ // imports exports.i(require("-!../node_modules/css-loader/index.js!./test.css"), ""); // module exports.push([module.id, "html, body {\n margin: 0;\n padding: 0;\n width: 100%;\n height: 100%;\n}\n\nbody {\n /* background-color: #a1616e; */\n background-color: red;\n}\n", ""]); // exports 


2. Le problème du fractionnement de code

Lorsque les styles sont chargés à partir d'un dossier séparé, nous ne connaissons pas le contexte d'utilisation de chacun d'eux. Avec cette approche, il n'est pas possible de diviser CSS en plusieurs parties et de les charger selon les besoins.

3. Grands noms de classe CSS

Chaque nom de classe BEM ressemble à ceci: nom-bloc__nom-élément. Un nom aussi long affecte fortement la taille finale du fichier CSS: sur le site Web Habr, par exemple, les noms de classe CSS occupent 36% de la taille du fichier de style.

Google est conscient de ce problème et utilise depuis longtemps la minification des noms dans tous ses projets:

Un morceau de google.com

Un morceau de google.com

Tous ces problèmes m'ont remis en ordre, j'ai finalement décidé d'y mettre fin et d'obtenir le résultat parfait.

Sélection de décision


Pour se débarrasser de tous les problèmes ci-dessus, j'ai trouvé deux solutions: CSS In JS (styled-components) et modules CSS.

Je n'ai pas vu de défauts critiques dans ces solutions, mais au final mon choix s'est porté sur les modules CSS pour plusieurs raisons:

  • Vous pouvez mettre CSS dans un fichier sĂ©parĂ© pour une mise en cache sĂ©parĂ©e de JS et CSS.
  • Plus d'options pour les styles de frittage.
  • Il est plus courant de travailler avec des fichiers CSS.

Le choix est fait, il est temps de commencer la cuisine!

Réglage de base


Configurez un peu le webpack. Ajoutez css-loader et activez-y les modules CSS:

 /* webpack.config.js */ module.exports = { /* … */ module: { rules: [ /* … */ { test: /\.css$/, use: [ 'style-loader', { loader: 'css-loader', options: { modules: true, } }, ], }, ], }, }; 

Nous allons maintenant diffuser les fichiers CSS dans des dossiers contenant des composants. À l'intérieur de chaque composant, nous importons les styles nécessaires.

 project/ components/ CoolComponent/ index.js index.css 

 /* components/CoolComponent/index.css */ .contentWrapper { padding: 8px 16px; background-color: rgba(45, 45, 45, .3); } .title { font-size: 14px; font-weight: bold; } .text { font-size: 12px; } 

 /* components/CoolComponent/index.js */ import React from 'react'; import styles from './index.css'; export default ({ text }) => ( <div className={styles.contentWrapper}> <div className={styles.title}> Weird title </div> <div className={styles.text}> {text} </div> </div> ); 

Maintenant que nous avons cassé les fichiers CSS, le rechargement à chaud ne traitera que les modifications apportées à un seul fichier. Problème numéro 1 résolu, bravo!

Divisez le CSS en morceaux


Lorsqu'un projet comporte de nombreuses pages et que le client n'a besoin que d'une seule, il est inutile de pomper toutes les données. React dispose d'une excellente bibliothèque téléchargeable pour cela. Il vous permet de créer un composant qui télécharge dynamiquement le fichier dont nous avons besoin, si nécessaire.

 /* AsyncCoolComponent.js */ import Loadable from 'react-loadable'; import Loading from 'path/to/Loading'; export default Loadable({ loader: () => import(/* webpackChunkName: 'CoolComponent' */'path/to/CoolComponent'), loading: Loading, }); 

Webpack transformera le composant CoolComponent en un fichier JS (bloc) distinct, qui sera téléchargé lorsque AsyncCoolComponent sera rendu.

Dans le même temps, CoolComponent contient ses propres styles. CSS s'y trouve jusqu'à présent sous la forme d'une chaîne JS et est inséré en tant que style à l'aide du chargeur de style. Mais pourquoi ne coupons-nous pas les styles dans un fichier séparé?

Nous créerons notre propre fichier CSS pour le fichier principal et pour chacun des morceaux.

Installez mini-css-extract-plugin et évoquez la configuration du webpack:

 /* webpack.config.js */ const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const isDev = process.env.NODE_ENV === 'development'; module.exports = { /* ... */ module: { rules: [ { /* ... */ test: /\.css$/, use: [ (isDev ? 'style-loader' : MiniCssExtractPlugin.loader), { loader: 'css-loader', options: { modules: true, }, }, ], }, ], }, plugins: [ /* ... */ ...(isDev ? [] : [ new MiniCssExtractPlugin({ filename: '[name].[contenthash].css', chunkFilename: '[name].[contenthash].css', }), ]), ], }; 

C'est tout! Assemblons le projet en mode production, ouvrons le navigateur et voyons l'onglet réseau:

 //    GET /main.aff4f72df3711744eabe.css GET /main.43ed5fc03ceb844eab53.js //  CoolComponent ,   JS  CSS GET /CoolComponent.3eaa4773dca4fffe0956.css GET /CoolComponent.2462bbdbafd820781fae.js 

Le problème numéro 2 est terminé.

Nous minimisons les classes CSS


Css-loader modifie les noms de classe à l'intérieur et renvoie une variable avec le mappage des noms de classe locaux à global.

Après notre configuration de base, css-loader génère un long hachage basé sur le nom et l'emplacement du fichier.

Dans le navigateur, notre CoolComponent ressemble maintenant Ă  ceci:

 <div class="rs2inRqijrGnbl0txTQ8v"> <div class="_2AU-QBWt5K2v7J1vRT0hgn"> Weird title </div> <div class="_1DaTAH8Hgn0BQ4H13yRwQ0"> Lorem ipsum dolor sit amet consectetur. </div> </div> 

Bien sûr, cela ne nous suffit pas.

Il est nécessaire que pendant le développement, il y ait des noms pour trouver le style original. Et en mode production, les noms de classe doivent être minifiés.

Css-loader vous permet de personnaliser le changement de nom de classe via les options localIdentName et getLocalIdent. En mode développement, nous définirons le descriptif localIdentName - '[chemin] _ [nom] _ [local]', et pour le mode production, nous créerons une fonction qui minimisera les noms de classe:

 /* webpack.config.js */ const getScopedName = require('path/to/getScopedName'); const isDev = process.env.NODE_ENV === 'development'; /* ... */ module.exports = { /* ... */ module: { rules: [ /* ... */ { test: /\.css$/, use: [ (isDev ? 'style-loader' : MiniCssExtractPlugin.loader), { loader: 'css-loader', options: { modules: true, ...(isDev ? { localIdentName: '[path]_[name]_[local]', } : { getLocalIdent: (context, localIdentName, localName) => ( getScopedName(localName, context.resourcePath) ), }), }, }, ], }, ], }, }; 

 /* getScopedName.js */ /*   ,        CSS      */ //      const incstr = require('incstr'); const createUniqueIdGenerator = () => { const uniqIds = {}; const generateNextId = incstr.idGenerator({ //  d ,    ad, //      Adblock alphabet: 'abcefghijklmnopqrstuvwxyzABCEFGHJKLMNOPQRSTUVWXYZ', }); //       return (name) => { if (!uniqIds[name]) { uniqIds[name] = generateNextId(); } return uniqIds[name]; }; }; const localNameIdGenerator = createUniqueIdGenerator(); const componentNameIdGenerator = createUniqueIdGenerator(); module.exports = (localName, resourcePath) => { //   ,     index.css const componentName = resourcePath .split('/') .slice(-2, -1)[0]; const localId = localNameIdGenerator(localName); const componentId = componentNameIdGenerator(componentName); return `${componentId}_${localId}`; }; 

Et nous avons ici dans le développement de beaux noms visuels:

 <div class="src-components-ErrorNotification-_index_content-wrapper"> <div class="src-components-ErrorNotification-_index_title"> Weird title </div> <div class="src-components-ErrorNotification-_index_text"> Lorem ipsum dolor sit amet consectetur. </div> </div> 

Et dans les classes de production minifiées:

 <div class="e_f"> <div class="e_g"> Weird title </div> <div class="e_h"> Lorem ipsum dolor sit amet consectetur. </div> </div> 

Le troisième problème est surmonté.

Supprimer l'invalidation inutile du cache


En utilisant la technique de minification de classe décrite ci-dessus, essayez de construire le projet plusieurs fois. Faites attention aux caches de fichiers:

 /*   */ app.bf70bcf8d769b1a17df1.js app.db3d0bd894d38d036117.css /*   */ app.1f296b75295ada5a7223.js app.eb2519491a5121158bd2.css 

Il semble qu'après chaque nouvelle version, nous ayons invalidé les caches. Comment ça?

Le problème est que webpack ne garantit pas l'ordre dans lequel les fichiers sont traités. Autrement dit, les fichiers CSS seront traités dans un ordre imprévisible, différents noms minifiés seront générés pour le même nom de classe avec différents assemblys.

Pour surmonter ce problème, enregistrons les données sur les noms de classe générés entre les assemblys. Mettez à jour un peu le fichier getScopedName.js:

 /* getScopedName.js */ const incstr = require('incstr'); //     const { getGeneratorData, saveGeneratorData, } = require('./generatorHelpers'); const createUniqueIdGenerator = (generatorIdentifier) => { //    const uniqIds = getGeneratorData(generatorIdentifier); const generateNextId = incstr.idGenerator({ alphabet: 'abcefghijklmnopqrstuvwxyzABCEFGHJKLMNOPQRSTUVWXYZ', }); return (name) => { if (!uniqIds[name]) { uniqIds[name] = generateNextId(); //    , //      // (   debounce  ) saveGeneratorData(generatorIdentifier, uniqIds); } return uniqIds[name]; }; }; //     , //          const localNameIdGenerator = createUniqueIdGenerator('localName'); const componentNameIdGenerator = createUniqueIdGenerator('componentName'); module.exports = (localName, resourcePath) => { const componentName = resourcePath .split('/') .slice(-2, -1)[0]; const localId = localNameIdGenerator(localName); const componentId = componentNameIdGenerator(componentName); return `${componentId}_${localId}`; }; 

L'implémentation du fichier generatorHelpers.js n'a pas vraiment d'importance, mais si vous êtes intéressé, voici le mien:

generatorHelpers.js
 const fs = require('fs'); const path = require('path'); const getGeneratorDataPath = generatorIdentifier => ( path.resolve(__dirname, `meta/${generatorIdentifier}.json`) ); const getGeneratorData = (generatorIdentifier) => { const path = getGeneratorDataPath(generatorIdentifier); if (fs.existsSync(path)) { return require(path); } return {}; }; const saveGeneratorData = (generatorIdentifier, uniqIds) => { const path = getGeneratorDataPath(generatorIdentifier); const data = JSON.stringify(uniqIds, null, 2); fs.writeFileSync(path, data, 'utf-8'); }; module.exports = { getGeneratorData, saveGeneratorData, }; 


Les caches sont devenus les mĂŞmes entre les builds, tout va bien. Un autre point en notre faveur!

Supprimer la variable d'exécution


Puisque j'ai décidé de prendre une meilleure décision, il serait bien de supprimer cette variable avec le mappage des classes, car nous avons toutes les données nécessaires au stade de la compilation.

Babel-plugin-react-css-modules nous aidera avec cela. Au moment de la compilation, il:

  1. Trouvera l'importation CSS dans le fichier.
  2. Il ouvrira ce fichier CSS et changera les noms des classes CSS comme le fait css-loader.
  3. Il trouvera les nœuds JSX avec l'attribut styleName.
  4. Remplace les noms de classe locaux de styleName par des noms globaux.

Configurez ce plugin. Jouons avec la configuration babel:

 /* .babelrc.js */ //   ,     const getScopedName = require('path/to/getScopedName'); const isDev = process.env.NODE_ENV === 'development'; module.exports = { /* ... */ plugins: [ /* ... */ ['react-css-modules', { generateScopedName: isDev ? '[path]_[name]_[local]' : getScopedName, }], ], }; 

Mettez Ă  jour nos fichiers JSX:

 /* CoolComponent/index.js */ import React from 'react'; import './index.css'; export default ({ text }) => ( <div styleName="content-wrapper"> <div styleName="title"> Weird title </div> <div styleName="text"> {text} </div> </div> ); 

Et donc nous avons cessé d'utiliser la variable avec l'affichage des noms de style, maintenant nous ne l'avons pas!

... Ou est-il?

Nous collecterons le projet et étudierons les sources:
 /* main.24436cbf94546057cae3.js */ /* … */ function(e, t, n) { e.exports = { "content-wrapper": "e_f", title: "e_g", text: "e_h" } } /* … */ 

Il semble que la variable soit toujours là, bien qu'elle ne soit utilisée nulle part. Pourquoi est-ce arrivé?

Le webpack prend en charge plusieurs types de structure modulaire, les plus populaires sont ES2015 (import) et commonJS (requis).

Les modules ES2015, contrairement Ă  commonJS, prennent en charge le tremblement d'arbre en raison de leur structure statique.

Mais le chargeur css et le chargeur mini-css-extract-plugin utilisent la syntaxe commonJS pour exporter les noms de classe, de sorte que les données exportées ne sont pas supprimées de la génération.

Nous allons écrire notre petit chargeur et supprimer les données supplémentaires en mode production:

 /* webpack.config.js */ const path = require('path'); const resolve = relativePath => path.resolve(__dirname, relativePath); const isDev = process.env.NODE_ENV === 'development'; module.exports = { /* ... */ module: { rules: [ /* ... */ { test: /\.css$/, use: [ ...(isDev ? ['style-loader'] : [ resolve('path/to/webpack-loaders/nullLoader'), MiniCssExtractPlugin.loader, ]), { loader: 'css-loader', /* ... */ }, ], }, ], }, }; 

 /* nullLoader.js */ //     ,   module.exports = () => '// empty'; 

Vérifiez à nouveau le fichier assemblé:

 /* main.35f6b05f0496bff2048a.js */ /* … */ function(e, t, n) {} /* … */ 

Vous pouvez expirer avec soulagement, tout a fonctionné.

Impossible de supprimer la variable de mappage de classe
Au début, il m'a paru plus évident d'utiliser le package null-loader déjà existant.

Mais tout s'est avéré pas si simple:

 /*  null-loader */ export default function() { return '// empty (null-loader)'; } export function pitch() { return '// empty (null-loader)'; } 

Comme vous pouvez le voir, en plus de la fonction principale, null-loader exporte également la fonction pitch. J'ai appris de la documentation que les méthodes de pitch sont appelées plus tôt que les autres et peuvent annuler tous les chargeurs suivants s'ils retournent des données de cette méthode.

Avec un chargeur nul, la séquence de production CSS commence à ressembler à ceci:

  • La mĂ©thode pitch de null-loader est appelĂ©e, ce qui renvoie une chaĂ®ne vide.
  • En raison de la valeur de retour de la mĂ©thode de pas, tous les chargeurs suivants ne sont pas appelĂ©s.

Je ne voyais plus les solutions et j'ai décidé de fabriquer mon propre chargeur.

Utiliser avec Vue.js
Si vous n'avez qu'un seul Vue.js à portée de main, mais que vous voulez vraiment compresser les noms de classe et supprimer la variable d'exécution, alors j'ai un bon hack!

Tout ce dont nous avons besoin, ce sont deux plugins: babel-plugin-transform-vue-jsx et babel-plugin-react-css-modules. Nous aurons besoin du premier pour écrire JSX dans les fonctions de rendu, et le second, comme vous le savez déjà, pour générer des noms au stade de la compilation.

 /* .babelrc.js */ module.exports = { plugins: [ 'transform-vue-jsx', ['react-css-modules', { //    attributeNames: { styleName: 'class', }, }], ], }; 

 /*   */ import './index.css'; const TextComponent = { render(h) { return( <div styleName="text"> Lorem ipsum dolor. </div> ); }, mounted() { console.log('I\'m mounted!'); }, }; export default TextComponent; 



Compressez le CSS au maximum


Imaginez que le CSS suivant apparaisse dans le projet:
 /*    */ .component1__title { color: red; } /*    */ .component2__title { color: green; } .component2__title_red { color: red; } 

Vous ĂŞtes un minifieur CSS. Comment le serriez-vous?

Je pense que votre réponse est quelque chose comme ceci:

 .component2__title{color:green} .component2__title_red, .component1__title{color:red} 

Nous allons maintenant vérifier ce que feront les minificateurs habituels. Mettez notre morceau de code dans un minifieur en ligne :

 .component1__title{color:red} .component2__title{color:green} .component2__title_red{color:red} 

Pourquoi ne pouvait-il pas?

Le minifieur a peur qu'en raison d'un changement dans l'ordre de la déclaration des styles, quelque chose se casse. Par exemple, si le projet a ce code:

 <div class="component1__title component2__title">Some weird title</div> 

À cause de vous, le titre deviendra rouge et le minificateur en ligne laissera l'ordre de déclaration de style correct et deviendra vert. Bien sûr, vous savez qu'il n'y aura jamais d'intersection entre component1__title et component2__title, car ils sont dans des composants différents. Mais comment dire cela au minificateur?

Après avoir recherché la documentation, j'ai trouvé la possibilité de spécifier le contexte pour utiliser les classes uniquement avec csso . Et il n'a pas de solution pratique pour le webpack prêt à l'emploi. Pour aller plus loin, nous avons besoin d'un petit vélo.

Vous devez combiner les noms de classe de chaque composant dans des tableaux séparés et les insérer dans csso. Un peu plus tôt, nous avons généré des noms de classe minifiés selon ce modèle: '[componentId] _ [classNameId]'. Ainsi, les noms de classe peuvent être combinés simplement par la première partie du nom!

Attachez vos ceintures et écrivez votre plugin:

 /* webpack.config.js */ const cssoLoader = require('path/to/cssoLoader'); /* ... */ module.exports = { /* ... */ plugins: [ /* ... */ new cssoLoader(), ], }; 

 /* cssoLoader.js */ const csso = require('csso'); const RawSource = require('webpack-sources/lib/RawSource'); const getScopes = require('./helpers/getScopes'); const isCssFilename = filename => /\.css$/.test(filename); module.exports = class cssoPlugin { apply(compiler) { compiler.hooks.compilation.tap('csso-plugin', (compilation) => { compilation.hooks.optimizeChunkAssets.tapAsync('csso-plugin', (chunks, callback) => { chunks.forEach((chunk) => { //    CSS  chunk.files.forEach((filename) => { if (!isCssFilename(filename)) { return; } const asset = compilation.assets[filename]; const source = asset.source(); //  ast  CSS  const ast = csso.syntax.parse(source); //        const scopes = getScopes(ast); //  ast const { ast: compressedAst } = csso.compress(ast, { usage: { scopes, }, }); const minifiedCss = csso.syntax.generate(compressedAst); compilation.assets[filename] = new RawSource(minifiedCss); }); }); callback(); }); }); } } /*    sourceMap,     ,       https://github.com/zoobestik/csso-webpack-plugin" */ 

 /* getScopes.js */ /*   ,          ,     */ const csso = require('csso'); const getComponentId = (className) => { const tokens = className.split('_'); //   ,   //   [componentId]_[classNameId], //     if (tokens.length !== 2) { return 'default'; } return tokens[0]; }; module.exports = (ast) => { const scopes = {}; //      csso.syntax.walk(ast, (node) => { if (node.type !== 'ClassSelector') { return; } const componentId = getComponentId(node.name); if (!scopes[componentId]) { scopes[componentId] = []; } if (!scopes[componentId].includes(node.name)) { scopes[componentId].push(node.name); } }); return Object.values(scopes); }; 

Et ce n'était pas si difficile, non? Habituellement, cette minification comprime en outre CSS de 3 à 6%.

Cela en valait-il la peine?


Bien sûr.

Dans mes applications, un rechargement rapide à chaud est finalement apparu, et CSS a commencé à se diviser en morceaux et à peser en moyenne 40% de moins.

Cela accélérera le chargement du site et réduira le temps d'analyse des styles, ce qui affectera non seulement les utilisateurs, mais également les PDG.

L'article a considérablement augmenté, mais je suis heureux que quelqu'un ait pu le faire défiler jusqu'à la fin. Merci pour votre temps!


Matériaux utilisés


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


All Articles