Cooking perfect CSS

  • Tutorial
Hi, Habr!

Not so long ago, I realized that working with CSS in all my applications is a pain for the developer and the user.

Under the cut are my problems, a bunch of strange code and pitfalls on the way to the correct work with styles.


Problem CSS


In the projects on React and Vue, which I did, the approach to styles was about the same. The project is assembled by a webpack; a single CSS file is imported from the main entry point. This file imports the rest of the CSS files that BEM uses to name the classes.

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

Familiar? I used this implementation almost everywhere. And everything was fine, until one of the sites had grown to such a state that the problems with the styles started to callore my eyes.

1. The problem of hot-reload
Importing styles from each other was done via postcss or stylus-loader plugin.
The catch is this:

When we decide to import via the postcss plugin or the stylus-loader, the output is one big CSS file. Now, even with a slight change in one of the style files, all CSS files will be processed anew.

This is great killing the speed of hot-reload: processing ~ 950 Kbytes of stylus files takes me about 4 seconds.

Note about css-loader
Если бы импорт CSS файлов решался через css-loader, такой проблемы бы не возникло:
css-loader превращает CSS в JavaScript. Он заменит все импорты стилей на require. Тогда изменение одного CSS файла не будет затрагивать другие файлы и hot-reload произойдет быстро.

До css-loader’a

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

После

/* 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. The problem of code-splitting

when styles are loaded from a separate folder, we do not know the context of using each of them. With this approach, it’s impossible to break the CSS into several parts and load them as needed.

3. Large CSS class names

Each class BEM name looks like this: block-name__element-name. Such a long name greatly influences the final size of the CSS file: on the Habr site, for example, the names of CSS classes occupy 36% of the file size of styles.

Google has been aware of this problem and in all of its projects it has been using name naming for a long time:

A slice of google.com

A piece of the site google.com

I got rid of all these problems, I finally decided to do away with them and achieve the perfect result.

Decision making


To get rid of all the above problems, I found two solutions: CSS In JS (styled-components) and CSS modules.

I did not see critical shortcomings in these solutions, but in the end my choice fell on CSS Modules due to several reasons:

  • You can put the CSS in a separate file for separate caching of JS and CSS.
  • More lintering styles.
  • More familiar with working with CSS files.

The choice is made, it's time to start cooking!

Basic setting


A little configure webpack'a configuration. Add a css-loader and enable CSS Modules for it:

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

Now scatter CSS files in folders with components. Inside each component we import the necessary styles.

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

Now that we’ve split the CSS files, hot-reload will only process changes to one file. Problem number 1 solved, hurray!

We break CSS on chunks


When a project has many pages, and the client only needs one of them, it does not make sense to download all the data. There is an excellent react-loadable library for this in React. It allows you to create a component that dynamically downloads the file we need if necessary.

/* 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 will turn the CoolComponent component into a separate JS file (chunk), which will be downloaded when the AsyncCoolComponent is rendered.

At the same time, CoolComponent contains its own styles. CSS is still in it as a JS string and inserted as a style using the style-loader. But why don't we cut the styles into a separate file?

Let's make it so that both the main file and each chunk create their own CSS file.

Install the mini-css-extract-plugin and conjure with the webpack configuration:

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

That's all! Let's build the project in production mode, open the browser and see the network tab:

// Выкачались главные файлы
GET /main.aff4f72df3711744eabe.css
GET /main.43ed5fc03ceb844eab53.js
// Когда CoolComponent понадобился, подгрузился необходимый JS и CSS
GET /CoolComponent.3eaa4773dca4fffe0956.css
GET /CoolComponent.2462bbdbafd820781fae.js

Problem number 2 is over.

Minify CSS Classes


The Css-loader changes the class names within itself and returns a variable with the display of local class names in global ones.

After our basic configuration, the css-loader generates a long hash based on the name and location of the file.

In the browser, our CoolComponent now looks like this:

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

Of course, this is not enough for us.

It is necessary that during the development were the names by which you can find the original style. And in production mode, class names should be minified.

Css-loader allows you to customize the change of class names through the options localIdentName and getLocalIdent. In development mode, set the descriptive localIdentName - '[path] _ [name] _ [local]', and for the production mode we will create a function that will minify the class names:

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

And here we have in the development of beautiful visual names:

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

And in production minified classes:

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

The third problem is overcome.

We remove unnecessary invalidation of caches.


Using the class minification technique described above, try building a project several times. Pay attention to the file caches:

/* Первая сборка */
app.bf70bcf8d769b1a17df1.js
app.db3d0bd894d38d036117.css
/* Вторая сборка */
app.1f296b75295ada5a7223.js
app.eb2519491a5121158bd2.css

It seems that after each new build we have invalidated caches. How so?

The problem is that webpack does not guarantee the order of processing files. That is, CSS files will be processed in an unpredictable order, for the same class name with different assemblies, different minified names will be generated.

To overcome this problem, let's save the data on the generated class names between assemblies. Slightly update the file 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}`;
};

The implementation of the generatorHelpers.js file does not matter much, but if you're interested, here is my:

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


Caches are the same between assemblies, everything is fine. Another point in our favor!

Remove runtime variable


Since I decided to make a better decision, it would be nice to remove this variable with the display of classes, because we have all the necessary data at the compilation stage.

With this, babel-plugin-react-css-modules will help us. At compile time, it:

  1. Finds in the file importing CSS.
  2. It will open this CSS file and change the names of CSS classes just as the css-loader does.
  3. Finds JSX nodes with the styleName attribute.
  4. Replace local class names from styleName with global ones.

Configure this plugin. Let's play with the babel-configuration:

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

Update our JSX files:

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

And so we stopped using the variable with the display of style names, now we don’t have it!

... Or is there?

Let's collect the project and study the source code:
/* main.24436cbf94546057cae3.js */
/* … */
function(e, t, n) {
  e.exports = {
    "content-wrapper": "e_f",
    title: "e_g",
    text: "e_h"
  }
}
/* … */

It seems that the variable is still there, although it is not used anywhere. Why did it happen?

The webpack supports several types of modular structure, the most popular being ES2015 (import) and commonJS (require).

The ES2015 modules, unlike commonJS, support tree-shaking due to their static structure.

But both the css-loader and the mini-css-extract-plugin loader use commonJS syntax to export class names, so the exported data is not removed from the build.

Let's write your little loader and delete the extra data in production mode:

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

Check the collected file again:

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

You can breathe out with relief, everything worked.

Unsuccessful attempt to delete a variable with the display of classes
Вначале наиболее очевидным мне показалось использовать уже существующий пакет null-loader.

Но все оказалось не так просто:

/* Исходники null-loader */
export default function() {
  return '// empty (null-loader)';
}
export function pitch() {
  return '// empty (null-loader)';
}

Как видно, помимо основной функции, null-loader экспортирует еще и функцию pitch. Из документации я узнал, что pitch методы вызываются раньше остальных и могут отменить все последующие лоадеры, если вернут из этого метода какие-то данные.

С null-loader'ом последовательность production процессинга CSS начинает выглядеть так:

  • Вызывается метод pitch у null-loader'a, который возвращает пустую строку.
  • Из-за того, что pitch метод вернул значение, все последующие лоадеры не вызываются.

Решений я больше не увидел и решил сделать свой лоадер.

Use with Vue.js
Если у вас под рукой есть только один Vue.js, но очень хочется сжать названия классов и убрать переменную рантайма, то у меня есть отличный хак!

Все, что нам понадобится — это два плагина: babel-plugin-transform-vue-jsx и babel-plugin-react-css-modules. Первый нам понадобится для того, чтобы писать JSX в рендер функциях, а второй, как вам уже известно — для генерации имен на этапе компиляции.

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



Compress CSS in full


Imagine the project has this CSS:
/* Стили первого компонента */
.component1__title {
    color: red;
}
/* Стили второго компонента */
.component2__title {
    color: green;
}
.component2__title_red {
    color: red;
}

You are a CSS minifier. How would you squeeze it?

I think your answer is something like this:

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

Now check what the regular minifiers will do. Put our piece of code in some online minifikator :

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

Why he could not?

Minifikator is afraid that due to a change in the style declaration mode, something will break in you. For example, if the project will have this code:

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

Because of you, the title will turn red, and the online minifiers will leave the correct order of the styles declared and it will be green. Of course, you know that the intersection of component1__title and component2__title will never happen, because they are in different components. But how to say about it to the minifier?

Poryskav documentation, the ability to specify the context of the use of classes, I found only in csso . And there is no convenient solution for the webpack out of the box. To go further, we need a small bicycle.

It is necessary to combine the class names of each component into separate arrays and give it inside csso. Earlier, we generated minified class names using the following pattern: '[componentId] _ [classNameId]'. So, class names can be combined simply by the first part of the name!

Attach belts and write your 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);
};

And it was not so difficult, right? Usually, such minification additionally compresses CSS by 3-6%.

Was it worth it?


Of course.

In my applications, a quick hot-reload finally appeared, and the CSS began to be broken down by chunks and weigh on average 40% less.

This will speed up the loading of the site and reduce the time of parsing styles, which will have an impact not only on users, but also on SEO.

The article has greatly expanded, but I am glad that someone could scroll it to the end. Thank you for your time!


Used materials



Also popular now: