Cocinar CSS perfecto

Hola Habr!

No hace mucho tiempo, me di cuenta de que trabajar con CSS en todas mis aplicaciones es un dolor para el desarrollador y el usuario.

Debajo del corte están mis problemas, un montón de código extraño y dificultades en el camino para trabajar con estilos correctamente.


Problema de CSS


En los proyectos React y Vue que hice, el enfoque de los estilos era más o menos el mismo. El proyecto es ensamblado por webpack, se importa un archivo CSS desde el punto de entrada principal. Este archivo importa dentro de sí mismo el resto de los archivos CSS que usan BEM para nombrar clases.

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

¿Eso es familiar? Usé esta implementación en casi todas partes. Y todo estuvo bien hasta que uno de los sitios creció a tal estado que los problemas con los estilos comenzaron a irritarme mucho los ojos.

1. El problema de la recarga en caliente
La importación de estilos entre sí se produjo a través del complemento postcss o el cargador de stylus.
El truco es este:

Cuando resolvemos las importaciones a través del plugin postcss o stylus-loader, la salida es un archivo CSS grande. Ahora, incluso con un ligero cambio en una de las hojas de estilo, todos los archivos CSS se procesarán nuevamente.

Realmente mata la velocidad de la recarga en caliente: se tarda unos 4 segundos en procesar ~ 950 Kbytes de archivos de stylus.

Nota sobre css-loader
Si la importación de archivos CSS se resolvió a través de css-loader, tal problema no habría surgido:
css-loader convierte CSS en JavaScript. Reemplazará todas las importaciones de estilo con require. Luego, cambiar un archivo CSS no afectará a otros archivos y la recarga en caliente ocurrirá rápidamente.

Para css-loader

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

Despues

 /* 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. El problema de la división de código

Cuando los estilos se cargan desde una carpeta separada, no conocemos el contexto de uso de cada uno de ellos. Con este enfoque, no es posible dividir CSS en varias partes y cargarlas según sea necesario.

3. Grandes nombres de clases CSS

Cada nombre de clase BEM se ve así: block-name__element-name. Un nombre tan largo afecta fuertemente el tamaño final del archivo CSS: en el sitio web de Habr, por ejemplo, los nombres de clase CSS ocupan el 36% del tamaño del archivo de estilo.

Google es consciente de este problema y ha usado durante mucho tiempo la minificación de nombres en todos sus proyectos:

Un pedazo de google.com

Un pedazo de google.com

Todos estos problemas me pusieron en orden, finalmente decidí terminarlos y lograr el resultado perfecto.

Selección de decisiones


Para deshacerme de todos los problemas anteriores, encontré dos soluciones: CSS en JS (componentes con estilo) y módulos CSS.

No vi fallas críticas en estas soluciones, pero al final mi elección recayó en los módulos CSS debido a varias razones:

  • Puede poner CSS en un archivo separado para el almacenamiento en caché por separado de JS y CSS.
  • Más opciones para estilos de lintering.
  • Es más común trabajar con archivos CSS.

La elección está hecha, ¡es hora de comenzar a cocinar!

Ajuste básico


Configurar webpack un poco. Agregue css-loader y habilite los módulos CSS en él:

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

Ahora difundiremos los archivos CSS en carpetas con componentes. Dentro de cada componente importamos los estilos necesarios.

 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> ); 

Ahora que hemos roto los archivos CSS, la recarga en caliente solo procesará los cambios en un archivo. Problema número 1 resuelto, ¡salud!

Divide CSS en trozos


Cuando un proyecto tiene muchas páginas y el cliente solo necesita una de ellas, no tiene sentido extraer todos los datos. React tiene una gran biblioteca cargable de reacción para esto. Le permite crear un componente que descarga dinámicamente el archivo que necesitamos si es necesario.

 /* 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 convertirá el componente CoolComponent en un archivo JS separado (fragmento), que se descargará cuando se represente AsyncCoolComponent.

Al mismo tiempo, CoolComponent contiene sus propios estilos. CSS se encuentra en él como una cadena JS hasta ahora y se inserta como un estilo usando style-loader. Pero, ¿por qué no cortamos los estilos en un archivo separado?

Haremos nuestro propio archivo CSS tanto para el archivo principal como para cada uno de los fragmentos.

Instale el mini-css-extract-plugin y conjure con la configuración del paquete web:

 /* 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', }), ]), ], }; 

Eso es todo! Armemos el proyecto en modo de producción, abra el navegador y vea la pestaña de red:

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

El problema número 2 ha terminado.

Minimizamos las clases de CSS


Css-loader cambia los nombres de clase dentro y devuelve una variable con la asignación de nombres de clase locales a globales.

Después de nuestra configuración básica, css-loader genera un hash largo basado en el nombre y la ubicación del archivo.

En el navegador, nuestro CoolComponent ahora se ve así:

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

Por supuesto, esto no es suficiente para nosotros.

Es necesario que durante el desarrollo haya nombres para encontrar el estilo original. Y en el modo de producción, los nombres de clase deben ser minificados.

Css-loader le permite personalizar el cambio de nombres de clase a través de las opciones localIdentName y getLocalIdent. En el modo de desarrollo, configuraremos el localIdentName descriptivo - '[ruta] _ [nombre] _ [local]', y para el modo de producción haremos una función que minimizará los nombres de clase:

 /* 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}`; }; 

Y aquí tenemos en el desarrollo de hermosos nombres visuales:

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

Y en las clases de producción minimizadas:

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

El tercer problema está superado.

Eliminar la invalidación de caché innecesaria


Usando la técnica de minificación de clase descrita anteriormente, intente construir el proyecto varias veces. Presta atención a las cachés de archivos:

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

Parece que después de cada nueva compilación hemos invalidado cachés. ¿Cómo es eso?

El problema es que webpack no garantiza el orden en que se procesan los archivos. Es decir, los archivos CSS se procesarán en un orden impredecible, se generarán diferentes nombres minificados para el mismo nombre de clase con diferentes ensamblados.

Para superar este problema, guardemos los datos sobre los nombres de clase generados entre ensamblados. Actualice un poco el archivo 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}`; }; 

La implementación del archivo generatorHelpers.js realmente no importa, pero si está interesado, aquí está el mío:

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, }; 


Los cachés se han vuelto iguales entre las compilaciones, todo está bien. ¡Otro punto a nuestro favor!

Eliminar la variable de tiempo de ejecución


Como decidí tomar una mejor decisión, sería bueno eliminar esta variable con la asignación de clases, porque tenemos todos los datos necesarios en la etapa de compilación.

Babel-plugin-react-css-modules nos ayudará con esto. En tiempo de compilación:

  1. Encontrará la importación de CSS en el archivo.
  2. Abrirá este archivo CSS y cambiará los nombres de las clases CSS al igual que css-loader.
  3. Encontrará nodos JSX con el atributo styleName.
  4. Reemplaza los nombres de clase locales de styleName con nombres globales.

Configura este complemento. Juguemos con la configuración de 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, }], ], }; 

Actualice nuestros archivos 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> ); 

Y entonces dejamos de usar la variable con la visualización de nombres de estilo, ¡ahora no la tenemos!

... o hay?

Recopilaremos el proyecto y estudiaremos las fuentes:
 /* main.24436cbf94546057cae3.js */ /* … */ function(e, t, n) { e.exports = { "content-wrapper": "e_f", title: "e_g", text: "e_h" } } /* … */ 

Parece que la variable todavía está allí, aunque no se usa en ningún lado. ¿Por qué sucedió esto?

El paquete web admite varios tipos de estructura modular, los más populares son ES2015 (importación) y commonJS (requerido).

Los módulos ES2015, a diferencia de commonJS, admiten la sacudida de árboles debido a su estructura estática.

Pero tanto el cargador css-como el cargador mini-css-extract-plugin usan la sintaxis commonJS para exportar nombres de clase, por lo que los datos exportados no se eliminan de la compilación.

Escribiremos nuestro pequeño cargador y eliminaremos los datos adicionales en modo producción:

 /* 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'; 

Verifique nuevamente el archivo ensamblado:

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

Puedes exhalar con alivio, todo funcionó.

Error al eliminar la variable de asignación de clase
Al principio, me pareció más obvio usar el paquete de cargador nulo ya existente.

Pero todo resultó no ser tan simple:

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

Como puede ver, además de la función principal, null-loader también exporta la función de tono. Aprendí de la documentación que los métodos de tono se llaman antes que otros y pueden cancelar todos los cargadores posteriores si devuelven algunos datos de este método.

Con un cargador nulo, la secuencia de producción de CSS comienza a verse así:

  • Se llama al método pitch de null-loader, que devuelve una cadena vacía.
  • Debido al valor de retorno del método de tono, no se llaman todos los cargadores posteriores.

Ya no veía las soluciones y decidí hacer mi propio cargador.

Usar con Vue.js
Si solo tienes un Vue.js a tu alcance, pero realmente quieres comprimir los nombres de clase y eliminar la variable de tiempo de ejecución, ¡entonces tengo un gran truco!

Todo lo que necesitamos son dos complementos: babel-plugin-transform-vue-jsx y babel-plugin-react-css-modules. Necesitaremos el primero para escribir JSX en funciones de renderizado y el segundo, como ya sabe, para generar nombres en la etapa de compilación.

 /* .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; 



Comprime CSS al máximo


Imagine que el siguiente CSS apareció en el proyecto:
 /*    */ .component1__title { color: red; } /*    */ .component2__title { color: green; } .component2__title_red { color: red; } 

Eres un minificador de CSS. ¿Cómo lo exprimirías?

Creo que tu respuesta es algo como esto:

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

Ahora comprobaremos qué harán los minificadores habituales. Pon nuestro código en un minificador en línea :

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

¿Por qué no podía él?

El minificador teme que, debido a un cambio en el orden de la declaración de estilos, algo se rompa. Por ejemplo, si el proyecto tiene este código:

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

Gracias a usted, el título se volverá rojo, y el minificador en línea dejará el orden de declaración de estilo correcto y se volverá verde. Por supuesto, usted sabe que nunca habrá la intersección de component1__title y component2__title, porque están en diferentes componentes. Pero, ¿cómo decirle esto al minificador?

Después de buscar en la documentación, encontré la capacidad de especificar el contexto para usar clases solo con csso . Y no tiene una solución conveniente para el paquete web listo para usar. Para ir más lejos, necesitamos una bicicleta pequeña.

Debe combinar los nombres de clase de cada componente en matrices separadas y asignarlo dentro de csso. Un poco antes generamos nombres de clase minificados de acuerdo con este patrón: '[componenteId] _ [classNameId]'. Por lo tanto, los nombres de clase se pueden combinar simplemente por la primera parte del nombre.

Abróchense los cinturones de seguridad y escriba su complemento:

 /* 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); }; 

Y no fue tan difícil, ¿verdad? Por lo general, esta minificación también comprime CSS en un 3-6%.

¿Valió la pena?


Por supuesto

En mis aplicaciones, finalmente apareció una recarga rápida en caliente, y CSS comenzó a dividirse en trozos y pesó en promedio un 40% menos.

Esto acelerará la carga del sitio y reducirá el tiempo para analizar estilos, lo que afectará no solo a los usuarios, sino también a los CEO.

El artículo ha crecido mucho, pero me alegra que alguien haya podido desplazarlo hasta el final. Gracias por tu tiempo!


Materiales utilizados


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


All Articles