Cozinhando CSS Perfeito

Olá Habr!

Há pouco tempo, percebi que trabalhar com CSS em todos os meus aplicativos é uma dor para o desenvolvedor e o usuário.

Sob o corte estão meus problemas, um monte de códigos estranhos e armadilhas no caminho para trabalhar com estilos corretamente.


Problema de CSS


Nos projetos React e Vue que eu fiz, a abordagem dos estilos era aproximadamente a mesma. O projeto é montado pelo webpack, um arquivo CSS é importado do ponto de entrada principal. Esse arquivo importa dentro de si o restante dos arquivos CSS que usam o BEM para nomear classes.

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

Isso é familiar? Eu usei essa implementação em quase todos os lugares. E tudo correu bem até que um dos sites cresceu a tal ponto que os problemas com os estilos começaram a irritar muito meus olhos.

1. O problema do hot-reload
A importação de estilos um do outro ocorreu através do plug-in postcss ou do carregador de pontas.
O problema é este:

Quando resolvemos as importações por meio do plug-in postcss ou do stylus-loader, a saída é um arquivo CSS grande. Agora, mesmo com uma pequena alteração em uma das folhas de estilo, todos os arquivos CSS serão processados ​​novamente.

Realmente reduz a velocidade de recarga a quente: leva cerca de 4 segundos para processar ~ 950 Kbytes de arquivos de caneta.

Nota sobre o css-loader
Se a importação de arquivos CSS fosse resolvida através do css-loader, esse problema não teria surgido:
css-loader transforma CSS em JavaScript. Ele substituirá todas as importações de estilo por exigir. A alteração de um arquivo CSS não afetará outros arquivos e o hot-reload ocorrerá rapidamente.

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

Depois

 /* 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. O problema da divisão de código

Quando os estilos são carregados de uma pasta separada, não conhecemos o contexto de uso de cada um deles. Com essa abordagem, não é possível dividir o CSS em várias partes e carregá-lo conforme necessário.

3. Grandes nomes de classes CSS

Cada nome de classe BEM se parece com: block-name__element-name. Um nome tão longo afeta fortemente o tamanho final do arquivo CSS: no site da Habr, por exemplo, os nomes de classes CSS ocupam 36% do tamanho do arquivo de estilo.

O Google está ciente desse problema e há muito tempo usa a minificação de nomes em todos os seus projetos:

Um pedaço de google.com

Um pedaço de google.com

Todos esses problemas me colocaram em ordem, finalmente decidi encerrá-los e alcançar o resultado perfeito.

Seleção de decisão


Para me livrar de todos os problemas acima, encontrei duas soluções: CSS nos módulos JS (styled-components) e CSS.

Não vi falhas críticas nessas soluções, mas no final minha escolha caiu nos Módulos CSS devido a várias razões:

  • Você pode colocar CSS em um arquivo separado para armazenar em cache separado JS e CSS.
  • Mais opções para estilos de aglomeração.
  • É mais comum trabalhar com arquivos CSS.

A escolha é feita, é hora de começar a cozinhar!

Configuração básica


Configure um pouco o webpack. Adicione o css-loader e ative os Módulos CSS nele:

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

Agora vamos espalhar arquivos CSS em pastas com componentes. Dentro de cada componente, importamos os estilos necessários.

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

Agora que quebramos os arquivos CSS, o hot-reload processará apenas as alterações em um arquivo. Problema número 1 resolvido, felicidades!

Divida CSS em pedaços


Quando um projeto tem muitas páginas e o cliente precisa de apenas uma delas, não faz sentido distribuir todos os dados. O React possui uma excelente biblioteca carregável por reagentes para isso. Permite criar um componente que baixa dinamicamente o arquivo que precisamos, se necessário.

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

O Webpack transformará o componente CoolComponent em um arquivo JS separado (bloco), que será baixado quando o AsyncCoolComponent for renderizado.

Ao mesmo tempo, CoolComponent contém seus próprios estilos. Até agora, o CSS está como uma string JS e é inserido como um estilo usando o carregador de estilos. Mas por que não cortamos os estilos em um arquivo separado?

Criaremos nosso próprio arquivo CSS para o arquivo principal e para cada um dos blocos.

Instale mini-css-extract-plugin e conjure com a configuração do 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', }), ]), ], }; 

Isso é tudo! Vamos montar o projeto no modo de produção, abrir o navegador e ver a guia de rede:

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

O problema número 2 acabou.

Minificamos classes CSS


O Css-loader altera os nomes das classes dentro e retorna uma variável com o mapeamento dos nomes das classes locais para global.

Após nossa configuração básica, o css-loader gera um hash longo com base no nome e no local do arquivo.

No navegador, nosso CoolComponent agora fica assim:

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

Claro, isso não é suficiente para nós.

É necessário que, durante o desenvolvimento, haja nomes para encontrar o estilo original. E no modo de produção, os nomes das classes devem ser minificados.

O Css-loader permite personalizar a alteração dos nomes das classes através das opções localIdentName e getLocalIdent. No modo de desenvolvimento, definiremos o localIdentName descritivo - '[caminho] _ [nome] _ [local]' e, para o modo de produção, criaremos uma função que reduzirá os nomes das classes:

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

E aqui temos o desenvolvimento de belos nomes visuais:

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

E nas classes minificadas de produção:

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

O terceiro problema foi superado.

Remova a invalidação desnecessária do cache


Usando a técnica de redução de classe descrita acima, tente criar o projeto várias vezes. Preste atenção aos caches de arquivos:

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

Parece que após cada nova compilação, invalidamos os caches. Como assim?

O problema é que o webpack não garante a ordem em que os arquivos são processados. Ou seja, os arquivos CSS serão processados ​​em uma ordem imprevisível, diferentes nomes minificados serão gerados para o mesmo nome de classe com diferentes assemblies.

Para superar esse problema, vamos salvar os dados sobre os nomes de classe gerados entre assemblies. Atualize um pouco o arquivo 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}`; }; 

A implementação do arquivo generatorHelpers.js realmente não importa, mas se você estiver interessado, aqui está o meu:

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


Os caches se tornaram os mesmos entre as compilações, está tudo bem. Outro ponto a nosso favor!

Remova a variável de tempo de execução


Como decidi tomar uma decisão melhor, seria bom remover essa variável com o mapeamento de classes, porque temos todos os dados necessários no estágio de compilação.

Babel-plugin-react-css-modules nos ajudará com isso. Em tempo de compilação, ele:

  1. Encontrará a importação de CSS no arquivo.
  2. Ele abrirá esse arquivo CSS e alterará os nomes das classes CSS, exatamente como o css-loader faz.
  3. Ele encontrará nós JSX com o atributo styleName.
  4. Substitui nomes de classes locais de styleName por nomes globais.

Configure este plugin. Vamos brincar com a configuração 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, }], ], }; 

Atualize nossos arquivos 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> ); 

Então, paramos de usar a variável com a exibição de nomes de estilos, agora não temos!

... Ou existe?

Vamos coletar o projeto e estudar as fontes:
 /* main.24436cbf94546057cae3.js */ /* … */ function(e, t, n) { e.exports = { "content-wrapper": "e_f", title: "e_g", text: "e_h" } } /* … */ 

Parece que a variável ainda está lá, embora não seja usada em nenhum lugar. Por que isso aconteceu?

O webpack suporta vários tipos de estrutura modular, os mais populares são o ES2015 (importação) e o commonJS (requerido).

Os módulos do ES2015, diferentemente do commonJS, suportam o tremor de árvores devido à sua estrutura estática.

Mas o carregador de css e o carregador de mini-css-extract-plugin usam a sintaxe commonJS para exportar nomes de classes, para que os dados exportados não sejam excluídos da construção.

Escreveremos nosso pequeno carregador e excluiremos os dados extras no modo de produção:

 /* 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 o arquivo montado novamente:

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

Você pode expirar com alívio, tudo funcionou.

Falha ao excluir a variável de mapeamento de classe
No começo, parecia-me mais óbvio usar o pacote de carregador nulo já existente.

Mas tudo acabou não sendo tão simples:

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

Como você pode ver, além da função principal, o null-loader também exporta a função de afinação. Aprendi com a documentação que os métodos de pitch são chamados mais cedo que outros e podem cancelar todos os carregadores subsequentes se eles retornarem alguns dados desse método.

Com um carregador nulo, a sequência de produção CSS começa a ter a seguinte aparência:

  • O método de pitch do null-loader é chamado, que retorna uma string vazia.
  • Devido ao retorno do método de pitch, todos os carregadores subsequentes não são chamados.

Eu não via mais as soluções e decidi fazer meu próprio carregador.

Use com Vue.js
Se você tem apenas um Vue.js na ponta dos dedos, mas realmente deseja compactar os nomes das classes e remover a variável de tempo de execução, então eu tenho um ótimo hack!

Tudo o que precisamos é de dois plugins: babel-plugin-transform-vue-jsx e babel-plugin-react-css-modules. Vamos precisar do primeiro para escrever JSX nas funções de renderização e do segundo, como você já sabe, para gerar nomes no estágio de compilação.

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



Compactar CSS ao máximo


Imagine o seguinte CSS apareceu no projeto:
 /*    */ .component1__title { color: red; } /*    */ .component2__title { color: green; } .component2__title_red { color: red; } 

Você é um minificador de CSS. Como você apertaria?

Eu acho que sua resposta é algo como isto:

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

Agora vamos verificar o que os minificadores comuns farão. Coloque nosso pedaço de código em algum minifier online :

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

Por que ele não podia?

O minificador tem medo de que, devido a uma alteração na ordem da declaração de estilos, algo ocorra. Por exemplo, se o projeto tiver este código:

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

Por sua causa, o título ficará vermelho e o minificador online deixará a ordem correta da declaração de estilo e ficará verde. Obviamente, você sabe que nunca haverá a interseção de component1__title e component2__title, porque eles estão em componentes diferentes. Mas como dizer isso ao minifier?

Depois de pesquisar a documentação, descobri a capacidade de especificar o contexto para o uso de classes apenas com o csso . E ele não tem uma solução conveniente para o webpack pronto para uso. Para ir mais longe, precisamos de uma bicicleta pequena.

Você precisa combinar os nomes das classes de cada componente em matrizes separadas e fornecer dentro do csso. Um pouco antes, geramos nomes de classes minificados de acordo com este padrão: '[componentId] _ [classNameId]'. Assim, os nomes das classes podem ser combinados simplesmente pela primeira parte do nome!

Aperte o cinto e escreva o seu 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); }; 

E não foi tão difícil, certo? Geralmente, essa minificação também comprime CSS em 3-6%.

Valeu a pena?


Claro.

Nos meus aplicativos, finalmente apareceu um hot-recarregamento rápido, e o CSS começou a se fragmentar e pesar em média 40% menos.

Isso agilizará o carregamento do site e reduzirá o tempo de análise de estilos, o que afetará não apenas os usuários, mas também os CEOs.

O artigo cresceu bastante, mas fico feliz que alguém tenha conseguido rolar o texto até o final. Obrigado pelo seu tempo!


Materiais utilizados


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


All Articles