Хабрахабр

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


Привет Хабр!

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

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

Проблемный CSS

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

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

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

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

Теперь даже при незначительном изменении одного из файлов стилей все CSS файлы будут обработаны заново. Когда мы решаем импорты через плагин postcss или stylus-loader, на выходе получается один большой 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\nbody {\n /* background-color: #a1616e; */\n background-color: red;\n}\n", ""]); // exports

2. Проблема code-splitting’а

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

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

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

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.

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

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

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

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

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

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

Чуть-чуть обновим файл 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, но очень хочется сжать названия классов и убрать переменную рантайма, то у меня есть отличный хак!

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

/* .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 никогда не будет, они ведь находятся в разных компонентах. Но как сказать об это минификатору?

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

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

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

/* 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% меньше.

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

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

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

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»