Привет!
Не так давно я понял, что работа с CSS во всех моих приложениях — это боль для разработчика и пользователя.
Под катом лежат мои проблемы, куча странного кода и подводные камни на пути к правильной работе со стилями.
Проблемный 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 в 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
Меня порядком достали все эти проблемы, я наконец решил покончить с ними и добиться идеального результата.
Выбор решения
Для избавления от всех вышеперечисленных проблем я нашел два варианта решения: 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 не имеет большого значения, но если интересно, вот моя:
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. Во время компиляции он:
- Найдет в файле импортирование CSS.
- Откроет этот CSS файл и изменит имена CSS классов также, как это делает css-loader.
- Найдет JSX узлы с аттрибутом styleName.
- Заменит локальные имена классов из 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 */
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. Возвращает пустую строку.
Решений я больше не увидел и решил сделать свой лоадер.
Все, что нам понадобится — это два плагина: 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% меньше.
Это ускорит загрузку сайта и уменьшит время парсинга стилей, что окажет влияние не только на пользователей, но и на СЕО.
Статья сильно разрослась, но я рад, что кто-то смог доскроллить ее до конца. Спасибо, что уделили время!
Использованные материалы
Автор: Андрей Аникин