История архитектурной ошибки, её последствия, и три правила, благодаря которым вы сможете держать исходный код в порядке и снизить стоимость внесения изменений.

Предыстория

В 2014 году в компании начали редизайн проекта и в основу вёрстки мы положили свежий на тот момент Bootstrap 3.0.1. Использовали мы его не как отдельную стороннюю библиотеку, а тесно заинтегрировали с нашим собственным кодом: отредактировали переменные под наш дизайн и компилировали кастомизированный Бутстрап из Less исходников самостоятельно. Проект оброс собственными модулями, которые использовали бутстраповские переменные и добавляли в файл с настройками свои новые переменные.

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

Через год с небольшим редизайн закончился, проект вышел в продакшн, и мы взялись за технический долг. При попытке обновить Бутстрап до версии 3.6.x выяснилось, что смержить новый variables.less с нашим файлом настроек будет нелегко. В Бутстрапе переименовали или убрали часть переменных, добавили новые. Собственные компоненты Бутстрапа обновились без проблем, а вот наши компоненты падали при компиляции из-за этих изменений.

Проблемы

Мы проанализировали ситуацию и сформулировали проблемы.

  1. Слишком связанный код.

    Сами компоненты лежали в отдельных файлах. Это создавало иллюзию, что компоненты независимые. Но компоненты использовали переменные, объявленные в отдельном файле variables.less, и без этого файла не работали. Нельзя было просто так взять и переместить компонент в другой проект. Приходилось тянуть за собой файл с настройками, который со временем превратился в помойку.

  2. Слишком много глобальных переменных.

    У Бутстрапа было ≈400 переменных. Мы отключили неиспользуемые компоненты Бутстрапа, но переменные оставили в конфиге на случай, если снова понадобятся. Еще мы добавили сотню или полторы своих переменных. Все названия не запомнить, трудно быстро находить нужные. Даже с правилами именования и комментариями ориентироваться в конфиге из 500+ переменных тяжело.

  3. Имена глобальных переменных вышли из-под контроля.

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

Как решать

Я придумал три правила, которые помогли побороть наши проблемы:

  1. Переменная используется только в том файле, в котором объявлена.

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

  2. Все переменные внутри компонента — локальные.

    Раз у каждого компонента свои переменные, пусть они будут локальными. Это избавит от проблем с именованием: в компонентах можно использовать переменные с одинаковыми именами и разными значениями — они не будут конфликтовать друг с другом.

  3. Глобальные переменные используются только внутри файла настроек.

    Благодаря первым двум правилам мы сильно сократим количество глобальных переменных, но они всё равно нужны. Глобальные переменные объявляются в главном файле проекта или в файле типа config.less. К ним тоже применяется правило №1 — переменные не используются за пределами своего файла. Это значит, что нельзя использовать глобальные переменные внутри файлов компонентов. Но существует способ не нарушая первого правила прокинуть значение глобальной переменной внутрь компонента. Как это сделать мы рассмотрим на примерах далее.

Третье правило избыточное. Соблюдая первые два, вы автоматически соблюдаете третье. Но в реальной жизни есть очень большой соблазн пойти по простейшему пути, не создавать локальную переменную, а по-быстрому использовать глобальную. Третье правило напоминает, что так делать вредно, что это создаёт ненужные зависимости и приводит к связыванию кода.

Применим правила на практике.

Реализация на Less

Представим простейший компонент для стилизации страницы. Согласно правилу №1 создадим переменные внутри файла компонента.

/* page.css */

.page {
    padding: 40px;
    color: #000;
    background-color: #fff;
}
Было. Пример кода компонента.

// page.less v0.1

@padding: 40px;
@txt-color: #000;
@bg-color: #fff;

.page {
    padding: @padding;
    color: @txt-color;
    background-color: @bg-color;
}
Стало. Переменные объявлены в глобальной области видимости и у них слишком общие имена. Это плохо.

В примере выше мы объявили переменные в глобальной области видимости и из-за этого они подвержены конфликту имён. Если в соседнем компоненте появится переменная с таким же именем, то компоненты поломают друг друга.

Локальные переменные

Область видимости — это «пространство» между фигурными скобками селектора: { и } . Объявленные внутри фигурных скобок переменные работают только внутри этих скобок и внутри дочерних скобок, но их нельзя использовать снаружи.

Если скобок вокруг нет, то это самый верхний уровень — глобальная область видимости.

У переменной из дочерней области видимости приоритет выше, чем у одноименной переменной из родительской области видимости. У глобальных переменных самый низкий приоритет при совпадении имён.

По правилу №2 сделаем переменные локальными — переместим их внутрь селектора.

// page.less v0.1

@padding: 40px;
@txt-color: #000;
@bg-color: #fff;

.page {
    padding: @padding;
    color: @txt-color;
    background-color: @bg-color;
}
Было. Глобальные переменные.

// page.less v0.2

.page {
    @padding: 40px;
    @txt-color: #000;
    @bg-color: #fff;

    padding: @padding;
    color: @txt-color;
    background-color: @bg-color;
}
Стало. Переменные объявлены внутри селектора и не создают конфликта имён, потому что теперь они локальные.

Теперь конфликта имен с другими компонентами не будет. Мы решили первую и вторую проблемы: получился изолированный сниппет без внешних зависимостей. Но я не знаю способа переопределить локальные переменные компонента в таком виде извне. Поэтому, чтобы кастомизировать компоненты глобальными переменными проекта, придумал другую форму записи.

Миксины как функции

В Less можно использовать миксины как функции. Если внутри миксина создать переменные, а потом вызвать миксин внутри селектора, то переменные будут доступны в области видимости этого селектора.

Вынесем объявление переменных внутрь миксина .page-settings(), а потом вызовем его внутри селектора .page:

// page.less v0.2

.page {
    @padding: 40px;
    @txt-color: #000;
    @bg-color: #fff;

    padding: @padding;
    color: @txt-color;
    background-color: @bg-color;
}
Было. Локальные переменные внутри селектора.

// page.less v0.3

.page-settings() {
    @padding: 40px;
    @txt-color: #000;
    @bg-color: #fff;
}

.page {
    .page-settings();

    padding: @padding;
    color: @txt-color;
    background-color: @bg-color;
}
Стало. Миксин доставляет переменные в зону видимости селектора.

Переменные локализованы внутри глобального миксина. Когда мы вызвали миксин в коде, переменные стали доступны в области видимости селектора .page, но по прежнему остались локальными.

Такой миксин не генерирует CSS код, его единственная задача — доставить переменные в нужную область видимости. Например, если вызвать этот миксин в глобальной области видимости, то и переменные станут глобальными. Но это то же самое, что сразу объявить переменные глобально.

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

Слияние миксинов

В Less существует «ленивое вычисление» переменных: не обязательно объявлять Less-переменную перед её использованием, можно объявить после. В момент компиляции парсер Less найдет определение переменной и отрендерит значение этой переменной в CSS.

Если переопределить переменную, то во всех местах её использования в силу вступит значение из самого последнего определения, так как оно приоритетнее по порядку следования в исходном коде. В этом смысле переменные ведут себя как константы.

Итак, переменные можно объявлять и до, и после использования, а миксины — это разновидность переменных. Если создать два миксина с одним и тем же именем и разным содержанием, то они объединяют свои внутренности. А если внутри миксинов есть переменные с одинаковыми именами, то происходит переопределение. Приоритет выше у последнего миксина.

Рассмотрим три файла:

// projectname.less

@import 'normalize.css';
@import 'typography.less';
@import 'page.less';
// и много других компонентов...

@import 'config.less';
Главный файл.
Импортируем компоненты и конфиг. Конфиг — последним.
// page.less v0.3

.page-settings() {
    @padding: 40px;
    @txt-color: #000;
    @bg-color: #fff;
}

.page {
    .page-settings();

    padding: @padding;
    color: @txt-color;
    background-color: @bg-color;
}
Компонент.
Все переменные локальные и хранятся в миксине.
// config.less

@glob-text-color: white;
@glob-bg-color: darkblue;

// кастомизация компонентов
.page-settings() {
    @txt-color: @glob-text-color;
    @bg-color: @glob-bg-color;
}
Конфиг проекта.
Переопределяем параметры компонента с помощью миксина настроек.

Всё самое интересное происходит в конфиге. Мы создали глобальные переменные и использовали их в этом же файле для кастомизации компонента.

Миксин .page-settings() объявлен два раза. Первый раз внутри файла page.less с дефолтными значениями, второй раз в файле config.less с новыми значениями. На этапе компиляции миксины склеиваются, новые переменные переопределяют дефолтные и CSS рендерится с новыми значениями из файла конфигурации.

Обратите внимание, что config.less инклюдится последним в списке. Это нужно, чтобы объявление миксина в конфиге имело больший приоритет, чем исходное объявление в файле самого компонента. Настройки не применятся, если поставить config.less до компонента, потому что на миксины тоже действуют правило «последнее определение — самое сильное».

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

  1. переменные использовались только в своём файле, даже глобальные переменные не вызывались за пределами файла config.less;
  2. переменные компонента остались локальными и не засорили глобальную область видимости;
  3. глобальные переменные не использовались внутри компонента напрямую, но значения глобальных переменных хитрым способом попали внутрь компонента.

Ограничения

Нельзя, чтобы имя глобальной переменной совпадало с именем локальной — получим рекурсию и CSS не скомпилируется. Чтобы не ошибиться, лучше все глобальные переменные записывать с префиксом.

// так нельзя

@txt-color: white;

.page-settings() {
    // тут рекурсия и ошибка компиляции
    @txt-color: @txt-color;
}
Неправильно. Рекурсивное определение переменной вызывает ошибку компиляции.

// правильно — с префиксом

@glob-txt-color: white;

.page-settings() {
    // всё в порядке
    @txt-color: @glob-txt-color;
}
Правильно. У глобальных переменных свой префикс glob-, что исключает совпадение имён.

Реализация в Sass

Sass отличается от Less и больше похож на скриптовый язык программирования: нет «ленивых вычислений» и переменная должна быть обязательно объявлена до её использования в коде. Если определить переменную и использовать её в коде, а потом переопределить и использовать ещё раз, то в первый вызов получим исходное значение в CSS, а во второй вызов новое значение. Трюк с миксинами, как в Less, не пройдет. Но есть другие пути решения.

Наборы переменных для настройки компонента удобно хранить в map-объектах. Это массив из пар «ключ: значение». Метод map-get извлекает конкретное значение из массива, метод map-merge объединяет два массива в один, дополняя или переписывая исходный массив.

Простейший компонент без возможности переопределения извне будет выглядеть так:

// page.scss v0.1

$page-settings: (
    padding: 40px,
    bg-color: #fff,
    text-color: #000,
);

.page {
    padding:          map-get($page-settings, padding);
    background-color: map-get($page-settings, bg-color);
    color:            map-get($page-settings, text-color);
}
Настройки хранятся в map-объекте и вызываются в коде с помощью map-get

Чтобы настраивать компонент из другого файла, нужно предусмотреть возможность смержить внешний конфиг с исходными настройками. Рассмотрим три файла:

// projectname.scss

@import: 'config';

@import: 'normalize';
@import: 'typography';
@import: 'page';
// и много других компонентов...
Главный файл.
Сначала импортируем конфиг, потом компоненты.
// config.scss

$glob-text-color: white;
$glob-bg-color: darkblue;

// кастомизируем
$page-settings: (
    bg-color: $glob-bg-color,
    text-color: $glob-text-color,
);
Настройки.
Создаём глобальные переменные и переопределяем параметры компонента.
// page.scss v0.2

$page-override: ( ); // [1]

@if variable-exists(page-settings) {
    $page-override: $page-settings; // [2]
}

$page-settings:  map-merge((
    padding: 40px,
    bg-color: #fff,
    text-color: #000,
), $page-override); // [3]

.page {
    padding:          map-get($page-settings, padding);
    background-color: map-get($page-settings, bg-color);
    color:            map-get($page-settings, text-color);
}
Компонент.
Добавили проверку: а не существуют ли уже настройки, чтобы переопределить компонент?

[1] — В компоненте мы сначала объявили переменную с пустым массивом $page-override.

[2] — Потом проверили, а не существует ли уже переменная $page-settings. И если она уже была объявлена в конфиге, то присвоили её значение переменной $page-override.

[3] — И потом смержили исходный конфиг и $page-override в переменную $page-settings.

Если массив $page-settings был объявлен ранее в глобальном конфиге, то $page-override перепишет настройки при слиянии. Иначе в переменной $page-override будет пустой массив, и в настройках останутся исходные значения.

В результате, мы получаем всё те же преимущества, только в отличие от Less нам приходится переопределять все настройки заранее, перед их использованием в коде.

Выводы

Не важно на чем вы пишете — на Less, Sass, CSS c кастомными свойствами или Javascript — глобальных переменных должно быть как можно меньше.

С CSS препроцессорами используйте три правила, которые помогут избежать хаоса:

  1. Переменная используется только в том файле, в котором объявлена.
  2. Все переменные внутри компонента — локальные.
  3. Глобальные переменные используются только внутри файла настроек.

Чтобы прокинуть глобальные настройки внутрь компонента, собирайте переменные в миксин (Less) или map-объект (Sass).

Переопределяйте настройки в правильном месте: в Less — после инклюда, в Sass — перед инклюдом.

Правила управления переменными в препроцессорах и методика переопределения настроек в одной картинке

Правила управления переменными в препроцессорах и методика переопределения настроек

Реальные примеры

Я сформулировал эту методику в декабре 2015 года для Less и с тех пор применяю её на рабочих и личных проектах.

За полтора года появилось несколько публичных npm-пакетов. Посмотрите исходный код для лучшего понимания, как работает эта методика в реальных ситуациях.

bettertext.css — типографика для сайтов. Настраивается при помощи 11 переменных, остальные 40 вычисляются по формулам. Вычисления идут отдельными миксином, чтобы была возможность переопределять формулы. У компонента нет ни одного класса, все стили применяются к тегам. Чтобы создать локальную область видимости, я поместил все селекторы по тегам в переменную — в Less это называется «detached ruleset».

links.less — стили для ссылок с фокусом, анимацией и бледным подчеркиванием. У компонента кроме миксина с настройками есть дополнительные глобальные миксины для раскрашивания ссылок внутри ваших собственных селекторов.

flxgrid.css — генератор HTML-сеток на флексах. Настраивается при помощи 5 переменных, генерирует классы для адаптивной сетки с любыми брейкпоинтами и любым количеством колонок. В компоненте вычисления и служебные миксины спрятаны внутрь локальной области видимости. Глобально виден только миксин с настройками.

space.less — инструмент для управления отступами в вёрстке. Создан, чтобы работать в паре с сеткой flxgrid.css. Адаптивность у них настраивается одинаково, но space.less использует собственный миксин настройки и собственные локальные переменные — в коде space.less никак не связан с flxgrid.css.

«Бонус-трек»

Если бы мне сейчас понадобилось использовать на новом проекте Bootstrap 3.x.x — тот, который на Less, — я бы все импортируемые модули Бутстрапа завернул в переменную (в «detached ruleset»), а все настройки из файла variables.less в миксин bootsrtap-settings. Глобальные переменные Бутстрапа перестали бы быть глобальными и их невозможно было бы использовать внутри собственного кода. Настройки Бутстрапа я бы кастомизировал по мере необходимости, вызывая миксин bootsrtap-settings в конфиге проекта, так же как в примерах выше. Тогда при обновлениях Бутстрапа пришлось бы поправить только миксин с кастомизированными настройкам.