Готовим идеальный CSS


Привет Хабр!

Не так давно я понял, что работа с CSS во всех моих приложениях — это боль для разработчика и пользователя.

Под катом лежат мои проблемы, куча странного кода и подводные камни на пути к правильной работе со стилями.

hhut5eodm-tw-e1wdqnecn7dxlu.png


Проблемный CSS


В проектах на React и Vue, которые я делал, подход к стилям был примерно одинаковым. Проект собирается webpack’ом, из главной точки входа импортируется один CSS файл. Этот файл импортирует внутри себя остальные CSS файлы, которые используют БЭМ для наименования классов.

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


Знакомо? Такую реализацию я использовал почти везде. И все было хорошо, пока один из сайтов не разросся до такого состояния, что проблемы со стилями начали сильно мозолить мне глаза.

1. Проблема hot-reload«а
Импортирование стилей друг из друга происходило через плагин postcss или stylus-loader.
Загвоздка вот в чем:

Когда мы решаем импорты через плагин postcss или stylus-loader, на выходе получается один большой CSS файл. Теперь даже при незначительном изменении одного из файлов стилей все CSS файлы будут обработаны заново.

Это здорово убивает скорость hot-reload«a: обработка ~950 Кбайт stylus-файлов занимает у меня около 4 секунд.

Заметка про 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. Проблема code-splitting«а

Когда стили подгружаются из отдельной папки, мы не знаем контекст использования каждого из них. С таким подходом никак не получится разбить CSS на несколько частей и подгружать их по мере необходимости.

3. Большие названия CSS классов

Каждое имя БЭМ класса выглядит вот так: block-name__element-name. Такое длинное имя сильно влияет на финальный размер CSS файла: на сайте Хабра, например, названия CSS классов занимают 36% от размера файла стилей.

Google знает об этой проблеме и во всех своих проектах давно использует минификацию имен:

Кусочек сайта google.com


Кусочек сайта google.com

Меня порядком достали все эти проблемы, я наконец решил покончить с ними и добиться идеального результата.

Выбор решения


Для избавления от всех вышеперечисленных проблем я нашел два варианта решения: CSS In JS (styled-components) и CSS modules.

Критичных недостатков у этих решений я не увидел, но в конце концов мой выбор пал на CSS Modules из-за нескольких причин:

  • Можно вынести CSS в отдельный файл для раздельного кэширования JS и CSS.
  • Больше возможностей для линтеринга стилей.
  • Более привычно работать с CSS файлами.


Выбор сделан, пора начинать готовить!

Базовая настройка


Немного настроим конфигурацию webpack’а. Добавим css-loader и включим у него CSS Modules:

/* webpack.config.js */

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


Теперь раскидаем CSS файлы по папкам с компонентами. Внутри каждого компонента импортируем нужные стили.

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 }) => (
  
Weird title
{text}
);


Теперь, когда мы разбили CSS файлы, hot-reload будет обрабатывать изменения только одного файла. Проблема №1 решена, ура!

Разбиваем CSS по чанкам


Когда в проекте много страниц, а клиенту нужна только одна из них, выкачивать все данные не имеет смысла. Для этого в React’е есть прекрасная библиотека react-loadable. Она позволяет создать компонент, который динамически выкачает нужный нам файл при необходимости.

/* 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 превратит компонент CoolComponent в отдельный JS файл (чанк), который скачается, когда будет отрендерен AsyncCoolComponent.

При этом, CoolComponent содержит свои собственные стили. CSS лежит пока в нем как JS строка и вставляется как стиль с помощью style-loader’a.
 Но почему бы нам не вырезать стили в отдельный файл?

Сделаем так, чтобы и для главного файла, и для каждого из чанков создался свой собственный CSS файл.

Устанавливаем mini-css-extract-plugin и колдуем с конфигурацией 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',
      }),
    ]),
  ],
};


Вот и все! Соберем проект в production режиме, откроем браузер и посмотрим вкладку network:

// Выкачались главные файлы
GET /main.aff4f72df3711744eabe.css
GET /main.43ed5fc03ceb844eab53.js

// Когда CoolComponent понадобился, подгрузился необходимый JS и CSS
GET /CoolComponent.3eaa4773dca4fffe0956.css
GET /CoolComponent.2462bbdbafd820781fae.js


С проблемой №2 покончено.

Минифицируем CSS классы


Css-loader изменяет внутри себя названия классов и возвращает переменную с отображением локальных имен классов в глобальные.

После нашей базовой настройки, css-loader генерирует длинный хеш на основе имени и местоположения файла.

В браузере наш CoolComponent выглядит сейчас так:

Weird title
Lorem ipsum dolor sit amet consectetur.


Конечно, нам этого мало.

Необходимо, чтобы во время разработки были имена, по которым можно найти оригинальный стиль. А в production режиме должны минифицироваться имена классов.

Css-loader дает возможность кастомизировать изменение названий классов через опции localIdentName и getLocalIdent. В режиме разработки зададим описательный localIdentName — '[path]_[name]_[local]', а для production режима сделаем функцию, которая будет минифицировать названия классов:

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


И вот у нас при разработке красивые наглядные имена:

Weird title
Lorem ipsum dolor sit amet consectetur.


А в production минифицированные классы:

Weird title
Lorem ipsum dolor sit amet consectetur.


Третья проблема преодолена.

Убираем ненужную инвалидацию кэшей


Используя технику минификации классов, описанную выше, попробуйте собрать проект несколько раз. Обратите внимание на кэши файлов:

/* Первая сборка */
app.bf70bcf8d769b1a17df1.js
app.db3d0bd894d38d036117.css

/* Вторая сборка */
app.1f296b75295ada5a7223.js
app.eb2519491a5121158bd2.css


Похоже, после каждой новой сборки у нас инвалидируются кэши. Как же так?

Проблема в том, что webpack не гарантирует порядок обработки файлов. То есть CSS файлы будут обработаны в непредсказуемом порядке, для одного и того же имени класса при разных сборках будут сгенерированы разные минифицированные имена.

Чтобы победить эту проблему, давайте сохранять данные о сгенерированных именах классов между сборками. Чуть-чуть обновим файл 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}`;
};



Реализация файла generatorHelpers.js не имеет большого значения, но если интересно, вот моя:

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



Кэши стали одинаковыми между сборками, все прекрасно. Еще одно очко в нашу пользу!

Убираем переменную рантайма


Раз уж я решил сделать лучшее решение, было бы неплохо убрать эту переменную с отображением классов, у нас ведь есть все необходимые данные на этапе компиляции.

С этим нам поможет babel-plugin-react-css-modules. Во время компиляции он:

  1. Найдет в файле импортирование CSS.
  2. Откроет этот CSS файл и изменит имена CSS классов также, как это делает css-loader.
  3. Найдет JSX узлы с аттрибутом styleName.
  4. Заменит локальные имена классов из styleName на глобальные.


Настроим этот плагин. Поиграемся с 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,
    }],
  ],
};


Обновим наши JSX файлы:

/* CoolComponent/index.js */

import React from 'react';
import './index.css';

export default ({ text }) => (
  
Weird title
{text}
);


И вот мы перестали использовать переменную с отображением названий стилей, теперь ее у нас нет!

… Или есть?

Соберем проект и изучим исходники:

/* main.24436cbf94546057cae3.js */

/* … */
function(e, t, n) {
  e.exports = {
    "content-wrapper": "e_f",
    title: "e_g",
    text: "e_h"
  }
}
/* … */


Похоже, переменная все еще осталась, хотя она нигде не используется. Почему так произошло?

В webpack’е поддерживается несколько видов модульной структуры, самые популярные — это ES2015 (import) и commonJS (require).

Модули ES2015, в отличие от commonJS, поддерживают tree-shaking за счет своей статичной структуры.

Но и css-loader, и лоадер mini-css-extract-plugin используют синтаксис commonJS для экспортирования названий классов, поэтому экспортируемые данные не удаляются из билда.

Напишем свой маленький лоадер и удалим лишние данные в 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';


Проверяем собранный файл еще раз:

/* main.35f6b05f0496bff2048a.js */

/* … */
function(e, t, n) {}
/* … */


Можно выдохнуть с облегчением, все сработало.

Неудачная попытка удалить переменную с отображением классов
Вначале наиболее очевидным мне показалось использовать уже существующий пакет 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, который превращает CSS файл в пустую строку.
  • Вызывается основной метод css-loader’a. Он не чувствует CSS, на вход ему пришла пустая строка. Отдает дальше пустую строку.
  • Вызывается основной метод лоадера у mini-css-extract-plugin. Ему приходит пустая строка, он не может извлечь для себя никакого CSS. Возвращает дальше пустую строку.
  • Вызывается основной метод null-loader’a. Возвращает пустую строку.

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


Использование со 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(
      
Lorem ipsum dolor.
); }, mounted() { console.log('I\'m mounted!'); }, }; export default TextComponent;

Сжимаем CSS по полной


Представьте, в проекте появился такой CSS:

/* Стили первого компонента */
.component1__title {
    color: red;
}

/* Стили второго компонента */
.component2__title {
    color: green;
}

.component2__title_red {
    color: red;
}


Вы — CSS минификатор. Как бы вы его сжали?

Я думаю, ваш ответ примерно такой:

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


Теперь проверим, что сделают обычные минификаторы. Засунем наш кусок кода в какой-нибудь online минификатор:

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


Почему он не смог?

Минификатор боится, что из-за смены порядка объявления стилей у вас что-то поломается. Например, если в проекте будет такой код:

Some weird title


Из-за вас заголовок станет красным, а онлайн минификатор оставит правильный порядок объявления стилей и у него он будет зеленым. Конечно, вы знаете, что пересечения component1__title и component2__title никогда не будет, они ведь находятся в разных компонентах. Но как сказать об это минификатору?

Порыскав по документациям, возможность указания контекста использования классов я нашел только у csso. Да и у того нет удобного решения для webpack’а из коробки. Чтобы ехать дальше, нам понадобится небольшой велосипед.

Нужно объединить имена классов каждого компонента в отдельные массивы и отдать внутрь csso. Чуть ранее мы генерировали минифицированные названия классов по такому паттерну: '[componentId]_[classNameId]'. А значит, имена классов можно объединить просто по первой части имени!

Пристегиваем ремни и пишем свой плагин:

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


А это было не так уж и сложно, правда? Обычно, такая минификация дополнительно сжимает CSS на 3–6%.

Стоило ли оно того?


Конечно.

В моих приложениях наконец появился быстрый hot-reload, а CSS стал разбиваться по чанкам и весить в среднем на 40% меньше.

Это ускорит загрузку сайта и уменьшит время парсинга стилей, что окажет влияние не только на пользователей, но и на СЕО.

Статья сильно разрослась, но я рад, что кто-то смог доскроллить ее до конца. Спасибо, что уделили время!

cdwmb8ygmq0mysnve4zjina7-iw.png


Использованные материалы


© Habrahabr.ru