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

в 7:00, , рубрики: css, javascript, React, ReactJS, vue, vuejs, webpack, webpack 4, Блог компании Constanta, Клиентская оптимизация, минификация


Привет!

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

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

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

Проблемный 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}nnbody {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 }) => (
  <div className={styles.contentWrapper}>
    <div className={styles.title}>
      Weird title
    </div>
    <div className={styles.text}>
      {text}
    </div>
  </div>
);

Теперь, когда мы разбили 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 выглядит сейчас так:

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

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

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

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

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

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

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

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

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

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

/* Первая сборка */
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 }) => (
  <div styleName="content-wrapper">
    <div styleName="title">
      Weird title
    </div>
    <div styleName="text">
      {text}
    </div>
  </div>
);

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

… Или есть?

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

/* 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(
      <div styleName="text">
        Lorem ipsum dolor.
      </div>
    );
  },

  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}

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

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

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

Из-за вас заголовок станет красным, а онлайн минификатор оставит правильный порядок объявления стилей и у него он будет зеленым. Конечно, вы знаете, что пересечения 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% меньше.

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

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

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

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

Автор: Андрей Аникин

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js