Привет, Хабр!
Не так давно я понял, что работа с CSS во всех моих приложениях — это боль для разработчика и пользователя.
Под катом лежат мои проблемы, куча странного кода и подводные камни на пути к правильной работе со стилями.
В проектах на React и Vue, которые я делал, подход к стилям был примерно одинаковым. Проект собирается webpack'ом, из главной точки входа импортируется один CSS файл. Этот файл импортирует внутри себя остальные CSS файлы, которые используют БЭМ для наименования классов.
Знакомо? Такую реализацию я использовал почти везде. И все было хорошо, пока один из сайтов не разросся до такого состояния, что проблемы со стилями начали сильно мозолить мне глаза.
1. Проблема hot-reload’а
Импортирование стилей друг из друга происходило через плагин postcss или stylus-loader.
Загвоздка вот в чем:
Когда мы решаем импорты через плагин postcss или stylus-loader, на выходе получается один большой CSS файл. Теперь даже при незначительном изменении одного из файлов стилей все CSS файлы будут обработаны заново.
Это здорово убивает скорость hot-reload’a: обработка ~950 Кбайт stylus-файлов занимает у меня около 4 секунд.
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 из-за нескольких причин:
Выбор сделан, пора начинать готовить!
Немного настроим конфигурацию webpack'а. Добавим css-loader и включим у него CSS Modules:
Теперь раскидаем CSS файлы по папкам с компонентами. Внутри каждого компонента импортируем нужные стили.
Теперь, когда мы разбили CSS файлы, hot-reload будет обрабатывать изменения только одного файла. Проблема №1 решена, ура!
Когда в проекте много страниц, а клиенту нужна только одна из них, выкачивать все данные не имеет смысла. Для этого в React'е есть прекрасная библиотека react-loadable. Она позволяет создать компонент, который динамически выкачает нужный нам файл при необходимости.
Webpack превратит компонент CoolComponent в отдельный JS файл (чанк), который скачается, когда будет отрендерен AsyncCoolComponent.
При этом, CoolComponent содержит свои собственные стили. CSS лежит пока в нем как JS строка и вставляется как стиль с помощью style-loader'a. Но почему бы нам не вырезать стили в отдельный файл?
Сделаем так, чтобы и для главного файла, и для каждого из чанков создался свой собственный CSS файл.
Устанавливаем mini-css-extract-plugin и колдуем с конфигурацией webpack'а:
Вот и все! Соберем проект в production режиме, откроем браузер и посмотрим вкладку network:
С проблемой №2 покончено.
Css-loader изменяет внутри себя названия классов и возвращает переменную с отображением локальных имен классов в глобальные.
После нашей базовой настройки, css-loader генерирует длинный хеш на основе имени и местоположения файла.
В браузере наш CoolComponent выглядит сейчас так:
Конечно, нам этого мало.
Необходимо, чтобы во время разработки были имена, по которым можно найти оригинальный стиль. А в production режиме должны минифицироваться имена классов.
Css-loader дает возможность кастомизировать изменение названий классов через опции localIdentName и getLocalIdent. В режиме разработки зададим описательный localIdentName — '[path]_[name]_[local]', а для production режима сделаем функцию, которая будет минифицировать названия классов:
И вот у нас при разработке красивые наглядные имена:
А в production минифицированные классы:
Третья проблема преодолена.
Используя технику минификации классов, описанную выше, попробуйте собрать проект несколько раз. Обратите внимание на кэши файлов:
Похоже, после каждой новой сборки у нас инвалидируются кэши. Как же так?
Проблема в том, что webpack не гарантирует порядок обработки файлов. То есть CSS файлы будут обработаны в непредсказуемом порядке, для одного и того же имени класса при разных сборках будут сгенерированы разные минифицированные имена.
Чтобы победить эту проблему, давайте сохранять данные о сгенерированных именах классов между сборками. Чуть-чуть обновим файл getScopedName.js:
Реализация файла generatorHelpers.js не имеет большого значения, но если интересно, вот моя:
Кэши стали одинаковыми между сборками, все прекрасно. Еще одно очко в нашу пользу!
Раз уж я решил сделать лучшее решение, было бы неплохо убрать эту переменную с отображением классов, у нас ведь есть все необходимые данные на этапе компиляции.
С этим нам поможет babel-plugin-react-css-modules. Во время компиляции он:
Настроим этот плагин. Поиграемся с babel-конфигурацией:
Обновим наши JSX файлы:
И вот мы перестали использовать переменную с отображением названий стилей, теперь ее у нас нет!
… Или есть?
Соберем проект и изучим исходники:
Похоже, переменная все еще осталась, хотя она нигде не используется. Почему так произошло?
В webpack'е поддерживается несколько видов модульной структуры, самые популярные — это ES2015 (import) и commonJS (require).
Модули ES2015, в отличие от commonJS, поддерживают tree-shaking за счет своей статичной структуры.
Но и css-loader, и лоадер mini-css-extract-plugin используют синтаксис commonJS для экспортирования названий классов, поэтому экспортируемые данные не удаляются из билда.
Напишем свой маленький лоадер и удалим лишние данные в production режиме:
Проверяем собранный файл еще раз:
Можно выдохнуть с облегчением, все сработало.
Представьте, в проекте появился такой CSS:
Вы — CSS минификатор. Как бы вы его сжали?
Я думаю, ваш ответ примерно такой:
Теперь проверим, что сделают обычные минификаторы. Засунем наш кусок кода в какой-нибудь online минификатор:
Почему он не смог?
Минификатор боится, что из-за смены порядка объявления стилей у вас что-то поломается. Например, если в проекте будет такой код:
Из-за вас заголовок станет красным, а онлайн минификатор оставит правильный порядок объявления стилей и у него он будет зеленым. Конечно, вы знаете, что пересечения component1__title и component2__title никогда не будет, они ведь находятся в разных компонентах. Но как сказать об это минификатору?
Порыскав по документациям, возможность указания контекста использования классов я нашел только у csso. Да и у того нет удобного решения для webpack'а из коробки. Чтобы ехать дальше, нам понадобится небольшой велосипед.
Нужно объединить имена классов каждого компонента в отдельные массивы и отдать внутрь csso. Чуть ранее мы генерировали минифицированные названия классов по такому паттерну: '[componentId]_[classNameId]'. А значит, имена классов можно объединить просто по первой части имени!
Пристегиваем ремни и пишем свой плагин:
А это было не так уж и сложно, правда? Обычно, такая минификация дополнительно сжимает CSS на 3-6%.
Конечно.
В моих приложениях наконец появился быстрый hot-reload, а CSS стал разбиваться по чанкам и весить в среднем на 40% меньше.
Это ускорит загрузку сайта и уменьшит время парсинга стилей, что окажет влияние не только на пользователей, но и на СЕО.
Статья сильно разрослась, но я рад, что кто-то смог доскроллить ее до конца. Спасибо, что уделили время!
Не так давно я понял, что работа с 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 файлов решался через css-loader, такой проблемы бы не возникло:
css-loader превращает CSS в JavaScript. Он заменит все импорты стилей на require. Тогда изменение одного CSS файла не будет затрагивать другие файлы и hot-reload произойдет быстро.
До css-loader’a
После
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
Меня порядком достали все эти проблемы, я наконец решил покончить с ними и добиться идеального результата.
Выбор решения
Для избавления от всех вышеперечисленных проблем я нашел два варианта решения: 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. Во время компиляции он:
- Найдет в файле импортирование 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.
Но все оказалось не так просто:
Как видно, помимо основной функции, null-loader экспортирует еще и функцию pitch. Из документации я узнал, что pitch методы вызываются раньше остальных и могут отменить все последующие лоадеры, если вернут из этого метода какие-то данные.
С null-loader'ом последовательность production процессинга CSS начинает выглядеть так:
Решений я больше не увидел и решил сделать свой лоадер.
Но все оказалось не так просто:
/* Исходники 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 метод вернул значение, все последующие лоадеры не вызываются.
Решений я больше не увидел и решил сделать свой лоадер.
Использование со Vue.js
Если у вас под рукой есть только один Vue.js, но очень хочется сжать названия классов и убрать переменную рантайма, то у меня есть отличный хак!
Все, что нам понадобится — это два плагина: babel-plugin-transform-vue-jsx и babel-plugin-react-css-modules. Первый нам понадобится для того, чтобы писать JSX в рендер функциях, а второй, как вам уже известно — для генерации имен на этапе компиляции.
Все, что нам понадобится — это два плагина: 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% меньше.
Это ускорит загрузку сайта и уменьшит время парсинга стилей, что окажет влияние не только на пользователей, но и на СЕО.
Статья сильно разрослась, но я рад, что кто-то смог доскроллить ее до конца. Спасибо, что уделили время!