Как стать автором
Обновить

Atomic CSS Deep Dive

Уровень сложностиСложный
Время на прочтение24 мин
Количество просмотров6.8K

Здравствуйте, товарищи! Меня зовут Валик и сегодня мы поговорим про подход Atomic CSS в верстке, разработку инструментов и смежные темы.

Кратко вспомним базу - почему Atomic CSS. Рассмотрим популярные решения для работы в этом подходе и сравним их с моим изобретением - mlut. Разберем проблемы известных инструментов и посмотрим, как я решил их в своем. Будут интересные архитектурные решения, технические детали и немного хардкора.

Те, кто занимается версткой, смогут по-другому взглянуть на Atomic CSS и, возможно, взять в работу новый инструмент. А те, кто пишет системный код и тулинг - получить вдохновение и перенять нестандартный опыт.

Это расшифровка моего доклада с HolyJS Spring 2024. Можете глянуть запись, а можете почитать эту статью с некоторыми дополнениями и более выверенными формулировками.

Пару слов о себе

Я разработчик, в IT больше 8 лет. Последние 2, в основном занимаюсь бэкендом на Node.js и тулингом, а до этого, больше работал с фронтом. Делаю свой open source проект. Выступаю на IT-мероприятиях и веду местное IT-сообщество в Питере на 500+ человек.

Почему именно я буду рассказывать про Atomic CSS?

  • В теме с 2018, когда Tailwind еще был noname библиотекой

  • Смотрел все релевантные инструменты, у которых больше 20 звезд на гитхабе

  • 3 года карьеры много верстал

  • В разработку своего инструмента вложил уже сильно больше 1000 часов

База про Atomic CSS

Напомню, что Atomic CSS - это методология верстки, в которой мы используем маленькие атомарные CSS-правила, каждое из которых делает одно действие. Эти классы еще называют утилитами. Часто они применяет одно CSS-свойство (например, меняет цвет текста), но не обязательно одно. В коде выглядит это примерно так:

Верстка в Atomic CSS
Верстка в Atomic CSS

Основные преимущества подхода

В сравнении с рукописным CSS:

  • Тратим меньше мыслетоплива. Не нужно думать про уникальные названия сущностей, БЭМ-блок это или БЭМ-элемент, какую делать структуру каталогов и etc

  • Меньше CSS на клиенте. С определенного момента разработки, стили перестают добавляться. Мы постоянно реиспользуем одни и те же утилиты

  • Быстрее пишем стили. Особенно, если используем короткие названия утилит. Плюс, нам сильно меньше нужно переключаться между файлами

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

Мифы об Atomic CSS
Мифы об Atomic CSS

State of Atomic CSS

Разберем текущую ситуацию на рынке. Возьмем 3 актуальных и достаточно популярных инструмента для работы в Atomic CSS:

  • Tailwindcss - многим известный и самый популярный

  • UnoCSS - не просто фреймворк, а движок для создания своего фреймворка

  • Atomizer - старый добрый инструмент от Yahoo, которому есть чем похвастаться

Несмотря на то, что у нас есть, как минимум 3 инструмента, актуальными остаются следующие проблемы:

  • Неконсистентный нейминг

  • Неудобно писать сложные утилиты

  • Связь с рукописным CSS

  • Неудобно расширять

Далее мы рассмотрим эти проблемы подробнее

Неконсистентный нейминг

Несколько примеров утилит из популярных библиотек

  • flex => display: flex, но flex-auto => flex: 1 1 auto

  • tracking-wide => letter-spacing: 0.025em

  • normal: line-height, font-weight или letter-spacing?

Сложные утилиты

Примерно так нам предлагают писать нестандартные media-выражения:

[@media(any-hover:hover){&:hover}]:opacity-100

Превращается это в следующий CSS:

@media(any-hover:hover) {
  .\[\@media\(any-hover\:hover\)\{\&\:hover\}\]\:opacity-100:hover {
    opacity: 1;
  }
}

Со сложными селекторами тоже не все гладко:

[&:not(:first-child)]:rounded-full 
.\[\&\:not\(\:first-child\)\]\:rounded-full:not(:first-child) {
  border-radius: 9999px;
}

Различные at-rules также оставляют желать лучшего:

supports-[margin:1svw]:ml-[1svw] 
@supports (margin:1svw) {
  .supports-\[margin\:1svw\]\:ml-\[1svw\] {
    margin-left: 1svw;
  }
}

Связь с рукописным CSS

Тут стоит раскрыть страшную тайну Atomic CSS:

В большинстве проектов, какую-то часть CSS вам придется написать руками!

И это нормально, поскольку такого кода будет в предела 10%, как показывает практика.

Теперь о самой проблеме. Посмотрим на следующий пример кода на Tailwind:

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer components { /* #5 */
  .card {
    background-color: theme(colors.white); /* #7 */
    border-radius: 5px / theme(borderRadius.lg);
    padding: 1rem theme(spacing[2.5]); /* #9 */
  }
}

Что мы здесь видим:

  • Конфликты с CSS. Не так давно в CSS появились каскадные слои, которые объявляются через at-rule @layer. А в Tailwind есть свой @layer (строка #5), который работает как-то по своему

  • Структура файлов. По умолчанию в Tailwind можно работать только в одном CSS-файле. Чтобы работать с несколькими, потребуется подключить PostCSS плагин

  • Использование значений утилит. Если вы хотите получить значения утилит для использования в каком-нибудь свойстве, вам понадобится специальная функция theme (строка #7). При этом, вам надо будет знать, как в словаре темы до нужного значения добраться, и это не всегда очевидно (строка #9)

  • Нет фич препроцессоров. Это скорее минус со звездочкой, поскольку фичи эти не всем нужны, да и часть из них можно получить с помощью PostCSS

Еще один интересный момент в эту тему. Одно время был такой клон Tailwind - Windi CSS. Там ребята для решения вопроса с рукописным CSS начали делать свой, то ли язык, то ли препроцессор. Вот тут можно ознакомиться, выглядит оно забавно

Windi Lang Draft
Windi Lang Draft

Неудобно расширять

Добавить относительно простую утилиту нам предлагают следующим образом:

module.exports = {
  theme: {
    tabSize: {
	  // map with values
    }
  },
  plugins: [
    plugin(function({ matchUtilities, theme }) {
      matchUtilities(
        {
          tab: (value) => ({
            tabSize: value
          }),
        },
        { values: theme('tabSize') }
      )
    })
  ]
}

А для добавления своего variant (модификатора, чтобы утилита работала по hover, например), надо написать что-то такое:

variants: [
  // hover:
  (matcher) => {
    if (!matcher.startsWith('hover:'))
      return matcher
    return {
      matcher: matcher.slice(6),
      selector: s => `${s}:hover`,
    }
  },
],

Актуальное решение

В качестве решения вышеперечисленных проблем я предлагаю вашему вниманию свой инструмент: mlut

mlut - аббревиатура от My Little UI Toolkit
mlut - аббревиатура от My Little UI Toolkit

Atomic CSS toolkit with Sass and ergonomics for creating styles of any complexity

В этом посыле каждое слово имеет значение, но сейчас объясню, почему выделен именно Sass

Нет, это не Tailwind на Sass
Нет, это не Tailwind на Sass

Кто-то может подумать: "Sass - это уже легаси технология, ванильный CSS уже ого-го: кастомные свойства, каскадные слои etc". Но я бы не торопился его хоронить.

Да, с развитием CSS, некоторые его фичи стали менее актуальны, но несмотря на это он стабильно развивается, а загрузок в неделю на npm у него больше, чем у того же Tailwind. И Sass не просто мейнтейнится, а в него прямо фичи добавляются: за последние полгода было, как миниму 4 минорных релиза!

Статистика релизов Sass
Статистика релизов Sass

Далее перейдем к технической части, приготовьтесь)

Как устроены утилиты

Мы достаточно много будем говорить про устройство утилит, поэтому начну с общей схемы их устройства

Схема устройства утилит
Схема устройства утилит

Сейчас она вам мало понятна, но далее мы разберем ее подробнее. А пока она будет сопровождать нас как эдакая мини-карта, которая поможет сориентироваться: на какой стадии разбора утилит мы находимся. И первым делом мы поговорим про нейминг

Нейминг

Рассмотрим, как обстоят дела у популярных инструментов

Tailwind

Какого-то согласованного нейминга здесь нет. Утилиты имеют opinionated названия, созвучные с CSS-свойствами или значениями. Рассмотрим пару примеров:

  • justify-*: content, items, self?

  • bg-none - убрать весь background? Нет, только background-image

  • flex => display: flex, но flex-auto => flex: 1 1 auto

UnoCSS

Напомню, что UnoCSS - это движок, а не просто фреймворк. Он позволяет собрать свой фреймворк из так называемых пресетов. Есть уже много готовых пресетов, либо можно написать свой. В частности, подключив один из таких пресетов можно использовать синтаксис некоторых популярных библиотек. Чаще всего используется Tailwind

Но в этом примере мы возьмем пресет с Tachyons - некогда популярной библиотекой. Правда здесь с неймингом все еще более плачевно:

br-0 => border-right-width: 0, но br1 => border-radius:.125rem
b: bottom, border, display: block? Нет, это font-weight:bold!
normal: line-height, font-weight, letter-spacing?

Atomizer

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

Js(c) => justify-self: center
Bg(n) => background: none
Bgbm(c) => background-blend-mode: color

mlut

В mlut используется единый алгоритм для всех сокращений. Несколько примеров:

Js-c => justify-self: center
Bdr => border-right: 1px solid
Bdrd1 => border-radius: 1px

Я прекрасно понимаю, что сокращения - это спорная тема. У них есть свои плюсы и минусы

Плюсы

Минусы

Лаконичный код

Есть порог входа

Удобнее писать

Подойдут не всем

Чуть меньше вес кода

Лаконичный код, удобнее писать...
Лаконичный код, удобнее писать...

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

  • В языках: const, int, char

  • В утилитах: cd, pwd, ls

  • Low-level: Ldar, Star, SubSmi

Вопрос со *

Напишите в комментах, кто узнал последние сокращения

Зачем алгоритм сокращений?

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

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

Читатель может сказать, что есть уже готовые сокращения Emmet, которые кто-то даже успели выучить) Да, они неплохие, но их проблема в том, что там нет четкого алгоритма. Это подтвердил Сергей Чикуенок - создатель Emmet (спрашивал его об этом). Так что первую проблему они не решают.

Как это было

Мало кто знает, но есть такой npm-пакет: mdn-data. В нем лежат несколько больших JSON'ин, которые содержат в себе данные почти обо всем CSS. О свойствах, их синтаксисах, медиа-фичах и многом другом. К нему я постоянно обращался во время ресерча.

JSON со всеми CSS-свойствами
JSON со всеми CSS-свойствами

Конечно же, я изучал спеки CSS. Много спек: как стабильных, так и черновиков.

Часть спек, которые я смотрел
Часть спек, которые я смотрел

Еще мне очень помогли данные из Chrome platform status. Это статистика частоты использования CSS-свойств в интернете. Да, такое тоже есть.

Chrome platform status
Chrome platform status

В результате ресерча, у меня появилась вот такая табличка на все свойства, где они разбиты на группы и большинству присвоен рейтинг по популярности (это уже готовая таблица с сокращениями):

Таблица CSS-свойств и их сокращений
Таблица CSS-свойств и их сокращений

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

Так родился алгоритм сокращений, который мы сейчас разберем

Общий алгоритм сокращений

Целиком он расписан в документации, а здесь мы рассмотрим его по верхам:

  1. Находим свойства, которые начинаются с одинаковой буквы

  2. Составляем их рейтинг по популярности (в основном, но не только)

  3. Выделяем группы с одинаковым первым словом

  4. Составляем сокращения внутри групп

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

Алгоритм сокращения одной сущности

I. Название сокращаем до первой буквы свойства/значения: color => C

II. Если название из нескольких слов, то берется первая буква из каждого слова: color-adjust => Ca

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

  1. color => C

  2. cursor => Cs

IV. Если название из N слов, то буква добавляется в соответствующем по порядку слове

  1. color => C

  2. cursor => Cs

  3. color-scheme => Csc

Порядок добавления буквы

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

I. Согласная следующего слога: cursor => Cs

Если следующий слог начинается на гласную, то берется ближайшая предыдущая согласная от нее

II. Следующая согласная

  1. content => Сt

  2. contain => Cn

III. Следующая гласная (без перескока через согласную)

  1. content => Сt

  2. counter-increment => Coi

А теперь давай поупражняемся! Мы же не зря столько разбирали алгоритм сокращений. Ниже будет несколько спойлеров: в title - сокращение, а внутри - свойство, которое ему соответствует. Попробуйте прокрутить их по алгоритму сокращения сущности, учитывая порядок добавления букв

Ps

position

Fnw

font-weight

Tf

transform

Flg

flex-grow

Слабые места

  • Популярность свойств меняется. Вполне возможен случай, что появится какое-то новое CSS-свойство и резко станет популярным. Тогда при составлении его сокращения в голове могут возникать ошибки, поскольку выведенное сокращение уже будет занято каким-то старым свойством. Хотя этот минус будет больше актуален для новых пользователей mlut

  • Редкие длинные свойства может быть сложно вспомнить

  • Возможны спорные ситуации, по мере развития CSS. Алгоритм достаточно формален, чтобы написать программу, которая могла бы из JSON превращать свойства в сокращения. Но проблема в том, что первоисточник здесь не JSON, а спеки, и там все не так однозначно. Надо следить за их развитием, смотреть, куда движутся те или иные свойства/фичи. И уже на основе этих вводных использовать алгоритм. Но пока спорных ситуаций почти не было

Синтаксис

Посмотрим, что нам предлагают популярные инструменты

Tailwind

Здесь нет даже какого-то подобия спецификации. По большей части, синтаксис является набором ad-hoc решений с костылями в виде arbitrary частей. Кратко пройдемся по нему.

Утилита и значение:

  • util-value - просто значение

  • -util-2 - отрицательные значения

  • util-[42px] - произвольные значения

  • [css-prop:value] - произвольное CSS свойство и значение

Variants:

  • variant:util-value - селекторы и некоторые at-rules

  • group/name:util-value - группы с именами

  • @md:util-value - container queries

Arbitrary variants

  • variant-[.class]:util - произвольные значения variant

  • [&:nth-child(3)]:util - произвольный variant

  • @[17.5rem]:util - container queries

UnoCSS

Эту часть мы пропускаем, поскольку в Uno чаще всего используется синтаксис Tailwind. По крайне мере, он самый продвинутый из имеющихся там вариантов.

Atomizer

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

[<context>[:<pseudo-class>]<combinator>]<Style>[(<value>,<value>?,...)][<!>][:<pseudo-class>][::<pseudo-element>][--<breakpoint_identifier>]

Разбирать ее сейчас мы конечно же не будем. Я просто вставил ее, дабы показать, что она в принципе есть. Для сравнения: в Tailwind мне пришлось пробежать по всей доки в поисках вариантов синтаксиса. Здесь же я зашел на одну страницу, разобрал эту спеку и уже имею представление, какие могут быть утилиты и на что они в принципе способны.

mlut

В mlut реализован, так называемый Components Syntax, благодаря которому, компактные утилиты мы можем разворачивать в сложные CSS-правила

@:ah_O1_h =>

@media (any-hover) {
  .\@\:ah_O1_h:hover {
    opacity: 1
  }
}

Понимаю, что сейчас это было больше похоже на мем "Как нарисовать сову", но не беспокойтесь: далее мы разберем этот же самый пример

Как нарисовать сову
Как нарисовать сову

Зачем проектировать синтаксис?

Основная цель - концептуальная близость с CSS для органичного развития вместе с ним

Less opinions, more standards! (c) Я

Хорошо спроектированный синтаксис позволяет нам:

  • Меньше учить "фантазийных" сущностей

  • Избежать (минимизировать) конфликты с CSS

  • Сохранить удобство написания

  • Получить высокую выразительность для реализации наибольшего количества фич CSS

Ну мы с вами уже смешарики и знаем, как ресерчить CSS. Поэтому достаем ранее упомянутые инструменты, обмазываемся спеками и вот это вот все

Процесс исследования CSS
Процесс исследования CSS

Правда в этот раз, я изучал не просто черновики спек, а "черновики черновиков". Так можно назвать тематические issues в том самом репозитории csswg, где идет обсуждение спек. Он также есть на скрине выше

Интересный факт

Вы знали, что большую часть спек CSS написали 2 человека? Tab Atkins и Elika Etemad

Первую версию синтаксиса я проектировал около 2 недель и часто был примерно в такой ситуации:

Процесс проектирования синтаксиса mlut
Процесс проектирования синтаксиса mlut

И вот что у меня получилось...

Utility components syntax

Синтаксис, в котором утилита разделяется на компоненты, каждый из которых, соответствует части CSS-правила. Под частями здесь подразумеваются at-rules, селектор, свойства и их значения. А теперь, вернемся к одному из предыдущих примеров и взглянем на него по-другому:

Настало время разобраться со схемой устройства утилит, которая сопровождала нас на протяжении статьи:

  1. CSS at-rule: брейкпоинты, @supports, etc

  2. pre-states - часть селектора перед классом утилиты

  3. Имя

  4. Значение

  5. post-states - часть селектора после класса утилиты

Здесь стоит немного отойти в сторону и ввести такое понятие как конвертация, поскольку дальше оно будет много где упоминаться.

Конвертация - превращение сокращения из названия класса в реальную CSS-сущность. Она встречается почти во все частях утилит: значениях, states, at-rules и т.д.

States

Перед тем, как вернуться к синтаксису утилит, нам надо вспомнить, какие по сложности бывают селекторы в CSS:

  • Simple selector - с одним условием: .class, #id, element

  • (Pseudo-)Compound selector - несколько простых селекторов без комбинаторов: .class[attr], element.class

  • Complex selector - несколько simple/compound c комбинаторами: .class:hover + .item

  • Selector list - comma-separated список из simple, compound или complex: .class, .item + .item, a.active

Так вот, states в mlut - это упрощенный selector list. Это значит, что в них мы можем использовать почти все возможности селекторов CSS с похожим DX. Даже множественный селектор (через ,). Основные отличия синтаксиса в следующем:

  • : - объединение стейтов

  • , - разделение на список

  • <empty>: - пробел в селекторе

Рассмотрим пару примеров

Утилита с post states
Утилита с post states
Утилита с pre states
Утилита с pre states

At-rules

Перед разбором устройства at-rules в нашем синтаксисе, вспомнил их некоторые особенности в CSS. At-rules в CSS также бывают разные по сложности. Есть совсем простые, типа @import и @charset. Есть вложенные, типа @layer. А самые сложные называются conditional at-rules - о них и поговорим далее.

Как устроены conditional at-rules:

  • Conditions - сами условия. Они могут состоять из операторов, скобок и features / queries, в зависимости от спеки: (hover) and (min-width: 20rem)

  • Операторы - логические: and, or, not

  • Features - выражения, функции, etc: (pointer: fine), style(color: green)

В примере ниже мы можем заменить, что у нас есть <supports-condition>, который содержит в себе все остальное:

Синтаксис @supports из спеки
Синтаксис @supports из спеки

Что еще стоит понимать о conditional at-rules:

  • Состав очень разный

  • Можно строить сложные выражения, используя операторы

  • Можно вкладывать друг в друга

Синтаксис @media из спеки
Синтаксис @media из спеки

А теперь об at-rules в mlut. Сюда входят breakpoints и сами at-rules.

Breakpoints имеют отдельный подсинтаксис, поскольку используются намного чаще, чем другие варианты at-rules. При этом, кроме стандартного поведения, когда утилита включается только с определенного размера экрана, нам может понадобиться и такое, чтобы утилита работала только в диапазоне ширин или до какого-то размера. Поэтому здесь и нужен подсинтаксис.

/* sm:md,xl_P2r */

@media (min-width: 520px) and (max-width: 767px), (min-width: 1200px) {
  .sm\:md\,xl_P2r {
    padding: 2rem;
  }
}

Ну и сами rules: @media, @supports и прочие.

Для составления сложных выражений в обоих синтаксисах, используются следующие операторы:

  • : => and

  • , => , (or)

Далее рассмотрим, как работают rules в at-rules) У каждого правила есть:

  • Сокращение: m, s, c - составляется по известному нам алгоритму

  • Конвертер - превращает сокращения в цепочку CSS-выражений

  • Кастомные значения - алиасы для часто используемых цепочек. Их может добавить пользователь через конфиг

Таким образом, синтаксис at-rules в mlut позволяет закрыть весь класс этих фич в CSS. Это означает, что если в CSS добавится новое at-rule, то с большой вероятностью, его можно будет реализовать в mlut, без изменения синтаксиса и доработок в ядре. Далее рассмотрим несколько примеров.

Утилита со сложным @media
Утилита со сложным @media

И да, at-rules в mlut можно комбинировать!

Утилита с несколькими at-rules
Утилита с несколькими at-rules

Да, первая реакция может быть примерно следующая:

Ну нафиг!
Ну нафиг!

Но как по мне, синтаксис получился мощный) И несмотря на это, слабые стороны у него все же имеются:

  • Нельзя (пока) написать произвольный псевдоселектор. Сейчас это сконвертируется вот так: D-f_:pseudo => .D-f pseudo {...}

  • Возможны конфликты пользовательских алиасов в at-rules (-myQuery) с CSS custom media или cutsom selector. Но это не точно, поскольку там еще совсем черновики

Конвертация значений

Мы ранее уже немного касались понятия конвертации. Напомню, что так называется превращение сокращения из названия класса в реальное CSS-значение.

ml-1 => margin-left: 0.5rem
D-f => display: flex

Как обстоят дела у конкурентов других инструментов

Tailwind

Здесь конвертация достаточно скромная. Вот что он умеет:

  • Подстановка значения из словаря в конфиге (theme)

  • Прозрачность цветов: bg-sky-500/75

  • Императивная конвертация, которую мы пишем руками, при добавлении утилиты через плагин

  • Части arbitrary значений, как например: более удобная запись custom properties

UnoCSS

Тут все примерно так же, как в Tailwind

Atomizer

Тут есть пара интересных мест, но тоже ничего особенного:

  • Подстановка значения из словаря + RTL by design

  • Прозрачность цветов: C(#fff.5)

  • Удобный синтаксис для custom properties

  • Множественные значения(!): Bgp(20px,50px)

  • Подстановка пользовательских значения из конфига

mlut

В mlut разработана система конвертации для почти произвольных значений. Пара примеров для затравки:

  • Ml-1/7 => margin-left: -14.3%

  • Bdrd1r;2/5p => border-radius: 1rem 2px / 5%

Зачем система конвертации?

  • Значения свойств CSS - это сложно. Чуть дальше вы в этом убедимся

  • Хотим оставаться ближе к платформе - вспоминаем предыдущие принципы проектирования инструмента

  • Хотим чтобы все это было удобно писать

В чем трудности работы со значениями CSS? Стоит начать с того, что для их описания (и не только для этого) есть специальный Value Definition Syntax! А в самих значениях у нас могут быть: разные типы данных, единицы измерения, функции и еще много чего...

Но мы с вами сложностей не боимся, поэтому идем в спеку - изучаем Value Definition Syntax

Value Definition Syntax в спеке
Value Definition Syntax в спеке

Смотрим доклад Ромы Дворнова с HolyJS Moscow 2019 - закрепляем материал... И теперь, когда мы будем смотреть на описание CSS-свойств в mdn-data, мы поймем, какие значения оно может принимать. А посмотрев на несколько таких свойств, мы начнем видеть закономерности и общие моменты, что поможет спроектировать нашу систему конвертации ближе к реальности.

Интересный момент, что в mdn-data есть еще JSON, куда вынесены синтаксисы, которые реиспользуются в разных свойствах (и не только свойствах). Это хорошо помогло при выявлении каких-то паттернов в значениях.

JSON с синтаксисами CSS
JSON с синтаксисами CSS

Основные понятия конвертации

Конвертер - функция, которая преобразует значение из сокращенного класса в реальное CSS значение.

Трансформер - функция, которая может еще как-то изменить готовое CSS-значение. Указывается в опциях утилиты.

Тип конвертации - список конвертеров, которые применяются к значению утилиты. Есть у каждой утилиты и если не указан явно в опциях утилиты, то применяется дефолтный.

Для дальнейшего понимания, стоит немного разобраться, как представлены утилиты mlut в коде. Все утилиты хранятся в едином реестре. Сейчас немного упростим, но по сути, это большой словарь, где ключи - названия утилит, а значения - опции. В опциях может быть название свойства, тип конвертации и еще много чего.

'Apcr': (
  'properties': aspect-ratio,
  'conversion': 'num-length', /* тип конвертации */
),

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

'conversion-types': (
  /* ... */
  'num-length': ('num-length', 'global-kw', 'cust-prop')
                /* ^цепочка конвертеров */
),

Общая схема работы конвертации

  1. Полное значение утилиты разбивается (по пробелам или разделителю) на простые значения

  2. Каждое простое значение проходит по цепочки конвертеров до тех пор, пока 1 из них не сработает

  3. К CSS значению применяется трансформер

  4. Итоговое значение подставляется в CSS правило

Теперь немного подробнее рассмотрим конвертеры. Это обычные функции со следующей сигнатурой:

@function convert-uv-number($value, $data: ()) {
  /* ... */
  @return $new-value;
}
  • $value - исходное значение

  • $data - словарь с дополнительными данными

Основные особенности конвертеров:

  • Часть имени после convert-uv- используется в типах конвертации

  • Применяются друг за другом

  • Внутри может использовать другие конвертеры

  • Можно написать свои

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

Кейс: утилита для градиентов

Мы разобрали много отдельных концептов, и теперь стоит посмотреть, как это работает все вместе. Возьмем конкретный кейс: утилиту для CSS-градиентов, возможно самую сложную в mlut. Она имеет следующие опции:

'-Gdl': (
  'properties': background-image,
  'transformer': 'gradient',
  'css-function': 'linear-gradient',
  'conversion': 'gradient',
  'multi-list-separator': ',',
  'keywords': ('position', 'gradient'),
),

Как мы видим, тут есть, и специальный тип конвертации, и трансформер. А вот как описан этот тип конвертации в общем конфиге:

'conversion-types': (
  /* ... */
  'gradient': (
    'keyword', 'color', 'cust-prop', 'Pl',
    'number', 'angle', 'global-kw'
  ),
),

Здесь достаточно длинная цепочка конвертеров (плюс пайплайн - продвинутая фича), но примечательно следующее. Чтобы получить достаточно сложную логику конвертации, мне не пришлось писать кучу какого-то императивного кода. Я просто составил из имеющихся конвертеров такую цепочку и получил желаемое поведение. Вот как работает эта утилита:

Утилита для CSS-градиентов
Утилита для CSS-градиентов

Первая реакция может быть примерно такая:

Что ты такое?
Что ты такое?

Но как по мне, утилита получилась шедевральная)

Слабые места

  • (пока) Нет first-class поддержки CSS-функций: calc(), clamp()

  • В будущем, CSS значения могут занять используемые спецсимволы: ;, $, ?

Конфигурация

Под конфигурацией подразумевается настройка инструмента. В частности:

  • Добавление значений: цвета, шрифты, ключевые слова

  • Создание утилит

  • Изменение настроек: брейкпоинты, новые states

Что нам могут предложить известные инструменты?

Tailwind

Добавить значение для утилиты - вроде просто.

module.exports = {
  theme: {
    extend: {
      fontFamily: {
        display: 'Oswald, ui-serif',
      }
    }
  }
}

Но вот чтобы добавить утилиту, надо написать плагин! При этом, утилиты бывают статические и динамические.

Статические утилиты

  • Вручную пишите CSS(-in-JS)-правило

  • Будут доступны variants

module.exports = {
  plugins: [
    plugin(function({ addUtilities }) {
      addUtilities({
        '.content-auto': {
          'content-visibility': 'auto',
        },
        '.content-hidden': {
          'content-visibility': 'hidden',
        },
      })
    })
  ]
}

Динамические утилиты

  • Можно добавить словарь со значениями

  • Будет доступен arbitrary синтаксис

  • Будут доступны variants

module.exports = {
  theme: {
    tabSize: {
	  // map with values
    }
  },
  plugins: [
    plugin(function({ matchUtilities, theme }) {
      matchUtilities(
        {
          tab: (value) => ({
            tabSize: value
          }),
        },
        { values: theme('tabSize') }
      )
    })
  ]
}

UnoCSS

Добавлять значения здесь тоже достаточно просто:

theme: {
  // ...
  colors: {
    'veryCool': '#0000ff', // class="text-very-cool"
  },
}

Но с утилитами ситуация похуже. Здесь для этого есть неплохой и лаконичный api. Самые простые утилиты можно добавить в одну строку! Но для чего-то более сложного придется писать регулярки и императивную конвертацию:

rules: [
  ['m-1', { margin: '0.25rem' }],
  [/^p-(\d+)$/, ([, d]) => ({ padding: `${d / 4}rem` })],
]

mlut

В mlut все расширения делаются в одном конфиге и как правило: парой строк кода.

Как добавить новую утилиту?

@use 'mlut' with (
  $utils-data: (
    'utils': (
      'registry': (
        'Mm': margin-magick,
      ),
    ),
  ),
);

Бойлерплейта чуть больше, но простая утилита так же добавляется в одну строку. При этом, вот что она умеет из коробки, в плане конвертации:

  • Числовые значения: Mm1r => margin-magick: 1rem

  • Глобальные ключевые слова: Mm-ih => inherit

  • Custom properties: Mm-$myCard?200 => var(--ml-myCard, 200px)

  • Несколько значений: Mm10p;1/3 => 10% 33.3333%

Диспетчеризация

Сейчас мы немного отвлечемся и вспомним понятие "диспетчеризация" из программирования. Диспетчеризацией - это поиск и выбор, какая функция будет вызываться для определенного типа данных. Она бывает:

  • Статическая - на этапе компиляции

  • Динамическая - в рантайме

В качестве примера статической диспетчеризации приведу вот такой пример на C++. Да, вот так вот внезапно мы перешли от CSS к плюсам)

struct Calculator {
  int (*operation)(int, int);
}

int add(int a, int b) {
  return a + b;
}

int subtract(int a, int b) {
  return a - b;
}

int main() {
  Calculator calc;

  calc.operation = add;
  printf("5 + 3 = %d\n", calc.operation(5, 3)); // #17

  calc.operation = subtract;
  printf("5 - 3 = %d\n", calc.operation(5, 3));

  return 0;
}

Здесь у нас структура с указателем на функцию. Уже на этапе компиляции будет понятно, какую реализацию функции вызывать на строке #17.

Если вы пишите на динамических языках, то с динамической диспетчеризацией (далее ДД) сталкиваетесь почти каждый день. Здесь можно вспомнить обычный поиск метода по цепочке прототипов в JavaScript. Но бывают разные варианты ДД и еще один из часто встречающихся: ДД на основе виртуальной таблицы. Смысл в том, что у нас в памяти есть некая таблица, в которой прописано, что для такого-то типа данных должна вызываться вот такая-то реализация функции. В этой статье тема разобрана более подробно и есть хорошие примеры. А для общего понимания вопроса, приведу следующий код:

class Toad {
  sleep() {
    // ...
  }
}

class Lizard {
  sleep() {
    // ...
  }
}

function lull(animal) {
  animal.sleep(); // #14
}

const toad = new Toad();
lull(toad);

Представьте, что это не JavaScript, а какой-то другой язык с классами. На строке #14, как понять: какую реализацию метода sleep вызывать?

А теперь вопрос: как мы можем использовать эти концепции при проектировании нашей программы? Рассмотрим пример из mlut, где используется подход похожий на ДД с виртуальной таблицей.

/* _at-rules.scss */
$at-rules-db: (
  'media': (
    'alias': 'm',
    'default': true,
  ),
  'supports': (
    'alias': 's',
  ),
  'container': (
    'alias': 'c',
  ),
);

/* _mk-ar.scss */
@mixin -generate-ar($at-rules, $this-util, $ar-list, $cur-index, $last-index) {
  /* ... */

  $converter: map.get(ml.$at-rules-db, $ar-name, 'converter');

  @#{$ar-name} #{meta.call($converter, $ar-str, $this-util)} {
    /* ... */
  }
}

Здесь у нас отрывок из кода, в котором происходит конвертация at-rules. У нас есть $at-rules-db - конфиг с данными обо всех at-rules: их сокращениях, конвертерах etc. Этот конфиг мы можем использовать как подобие виртуальной таблицы. Далее, в миксине -generate-ar мы смотрим, с каким at-rule мы работаем ($ar-name), и на основе этого, достаем из конфига нужный конвертер. Далее, применяем его к цепочке сокращений $ar-str, которая нам пришла.

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

Как-то мне прислали issue с вопросом про поддержку container queries. Я ответил на него сниппетом кода в ~20 строк, которым через конфиг можно было добавить базовую поддержку этой фичи!

Для сравнения: чтобы добавить container queries в Tailwind, ребятам пришлось придумать новый синтаксис и написать плагин на 70 строк.

JIT engine

Напоследок, поговорим про JIT движки в Atomic CSS инструментах. Но для сперва, немного вспомним историю.

Древние CSS-фреймворки
Древние CSS-фреймворки

Как работали инструменты старого поколения (Tailwind v1, Tachyons, etc):

  • Генерируем over9000 утилит на все случаи жизни

  • Используем часть из них в разметке

  • Добавляем в сборку программу, которая смотрит нашу разметку и удаляет неиспользуемый CSS

Какие проблемы есть при таком подходе:

  • Муторно или невозможно использовать произвольные значения утилит

  • Регулярно надо редактировать конфиг для добавления новых значений утилит

  • Большой CSS-бандл в режиме разработки

Для решения этих проблем появился новый подход, который называется JIT мод. Здесь нет ничего общего с JIT-компиляторами, это скорее маркетинговое название. С JIT модом все становится проще:

  • Пишем (почти) произвольные утилиты в разметке

  • JIT-движок смотрит наш код и генерирует только те утилиты, которые мы использовали

Историческая справка

Некоторые считают, что JIT-движок впервые появился в Windi CSS для решения проблем Tailwind v2. Далее, команда Tailwind украла взяла это решение себе на вооружение. Вроде это они тогда и придумали термин JIT-движок

Но мало кто знает, что JIT-движок был еще в первых версиях Atomizer, году в 2015! Зная это, забавно было смотреть на пафосные заявления его вышеупомянутых "переизобретателей", и на рассуждения Anthony Fu по теме

Общая схема работы JIT-движка:

  • Находим файлы с контентом

  • Сканируем их и достаем утилиты

  • Генерируем CSS для найденных утилит

И уже наша постоянная рубрика: разбор актуальных решений)

Tailwind

В основе движка здесь лежит большой PostCSS плагин. Это значит, что мы получаем, как плюсы, так и минусы PostCSS. Легко делать интеграции со сборщиками и другими плагинами из экосистемы. Но нужно работать с AST PostCSS и довольствоваться средним перфомансом.

Хотя в Tailwind v4 планируется новый движок - Oxide, который решит некоторые слабые места текущего

UnoCSS

Здесь используется самописный генератор утилит. Судя по бенчмарку и заявлениям авторов: он самый быстрый и там много оптимизаций. Есть интеграция с основными популярными сборщиками. А также, есть много дополнительных фич, как например: attributify и shortcuts

Atomizer

Тут тоже все хорошо: используется самописный генератор утилит. Он относительно простой, но с legacy-зависимостями, типа lodash. Интеграции также имеются: для большинства сборщиков есть плагины через unplugin, а для некоторых и отдельными пакетами

mlut

mlut и здесь отличился, но уже не в лучшую сторону. Сейчас разберемся почему. Здесь у нас почти как в компиляторах: есть фронтенд и бэкенд

Фронт: TypeScript

Бэк: Sass

CLI / плагин

Генератор утилит и конфиги

JIT движок

CSS библиотека

Компилятор Sass

Основной вопрос здесь в следующем: как связать Sass и JS? Нам ведь как-то надо получиить данные из Sass-конфига, передавать собранные утилиты в генератор etc. Для решения этих задач мы используем подход, который я назвал: Sass in JS

Sass in JS
Sass in JS

Суть его в следующем:

  • Загружаем код нужного Sass-модуля

  • Дописываем в него код

  • Компилируем итоговый скрипт в CSS

  • (при необходимости) Достаем данные из вывода

Как это выглядит в коде

Берем содержимое input Sass-файла (Sass точки входа) пользователя, либо дефолтный конфиг из примера ниже, если input файла нет:

/* default userConfig */
@use "sass:map";
@use "../sass/tools/settings" as ml;

Далее, в конец input файла добавляем определенный Sass-код, в котором выполняем логику. Например, достаем что-то из конфига. Компилируем получившийся код:

const { css } = (await sass.compileStringAsync(
  userConfig + '\n a{ all: map.keys(map.get(ml.$utils-db, "utils", "registry")); }',
  {
    style: 'compressed',
    loadPaths: [ __dirname, 'node_modules' ],
  }
));

На выходе у нас получается вот такое забавное CSS-правило, где в свойстве all лежит список имен всех утилит из реестра. Далее, мы просто извлекаем из него целевые данные

a {
  all: "Ps", "T", "R", "B", "-X", "-Y", "-I", /* etc */
}

Стоит еще рассказать про несколько интересных особенностей Sass как языка

Нет рантайма. Код просто компилируется и выдается какой-то CSS. Поэтому тут у нас как в классическом PHP: на каждую пересборку стилей "заново создается мир" - загружаются все конфиги, в том числе, реестр утилит на 2к+ строк. Да, это не особо оптимально, но благодаря нативному Dart-компилятору Sass - работает вполне шустро. На небольшой проекте, даже быстрее Tailwind v3, особенно холодный старт. Потому что каждый старт здесь как холодный, хотя в какой-то момент, может включиться JIT в JS движке.

Мало фич в языке. Никаких классов и объектов, даже regexp нет) Но зато есть кое-что из ФП: функции высшего порядка, иммутабельные структуры данных, etc. Это покажется странным, но писать на таком языке - интересный и даже полезный опыт. Возможно, что-то подобное испытывают те, кто работает с Clojure, но это не точно, я пока не пробовал его. Идея в том, что когда в языке мало фич, это заставляет тебя комбинировать небольшое количество базовых элементов для получения сложной логики. А это один из базовых навыков хорошего инженера.

Максимальная интеграция с CSS. Одно время я думал: зачем я продолжаю писать сложную программу на Sass, вместо того, чтобы переписать все на Rust JS. Одним из ответов как раз и была "максимальная интеграция". Там, где авторов других генераторов пришлось писать логику работы с CSS-селекторами, я взял функции из стандартной библиотеки Sass. Там где в JS надо учитывать единицы измерения у чисел (1px, 1rem), в Sass такие значения - first-class citizen. Похожее можно сказать про перевод списков Sass в CSS списки и еще много что.

Заключение

Вот некоторые инсайты, которые получил за время работы над проектом

Не бойтесь безумных идей. Например таких, как: "Написать сложную программу на CSS-препроцессоре". Вполне возможно, что в процессе вы создатите, что-то инновационное и выдающееся. Либо просто получите очень полезный опыт.

Попробуйте пойти до конца, докопаться до сути проблемы. И поняв корень проблемы, придумать такое решение, которое будет устранять истинную причину так, чтобы проблема не возникала бы в принципе. Ведь часто, когда мы решаем некую задачу, мы останавливаемся на лечении какого-то следствия более фундаментальной проблемы или полу-костыльном решении. Обычно бизнес дает нам ограниченные ресурсы и такое вполне можно понять. Но как только появится возможность - попробуйте вышеописанный подход.

Неудача - тоже результат. Как говорил астрофизик Константин Батыгин:

99% процентов работы ученного - это фейлы.

Даже если вам с первой попытки не удалось сделать самый быстрый фреймворк - не стоит расстраиваться. Вы получили полезный опыт, который как-то изменил и прокачал вас. Например, из этого можно сделать хороший доклад и попасть спикером на tier 1 конференцию)

Ну и напоследок, хочу еще раз пояснить, зачем я все это делал.

Я пытался решить проблемы существующих инструментов, которые мы разбирали в начале. Мне хотелось попробовать максимально реализовать потенциал подхода Atomic CSS. Я этим загорелся, поскольку мечтал работать с таким инструментом, но в тот момент, на рынке его не было.

Я хочу показать сообществу все эти нестандартные идеи и интересные технические детали, которые есть в mlut. Я думаю, что это хотя бы немного поможет развитию индустрии. Поэтому прямо сейчас занимаюсь продвижением инструмента и в идеале хочу, чтобы он попал в опрос State of CSS. Так что буду благодарен за любой фидбек и помощь в этом.

У меня есть мечта: я хочу стать фултайм open source разработчиком. Проект я вижу как начало своей карьеры в этом деле и "разминку перед большой игрой". Добавлю, что у меня нет цели "захватить мир" с mlut или "убить" Tailwind. Я прекрасно понимаю, что инструмент скорее нишевый и не имеет такого потенциала by design. Но мне хотелось бы уверенно зайти на рынок, пободаться с топовыми аналогами, найти свою аудитории и принести этим людям пользу!

Под конец, процитирую классика:

Это родилось в борьбе за воплощение мечты
И навсегда сохранит в себе ее черты

На этом все! Подписывайтесь на мой телеграм канал, ставьте звезды на гитхабе mlut, ну и буду рад видеть ваши комменты!

Теги:
Хабы:
Всего голосов 16: ↑13 и ↓3+14
Комментарии20

Публикации

Истории

Работа

Веб дизайнер
50 вакансий
React разработчик
79 вакансий

Ближайшие события

19 августа – 20 октября
RuCode.Финал. Чемпионат по алгоритмическому программированию и ИИ
МоскваНижний НовгородЕкатеринбургСтавропольНовосибрискКалининградПермьВладивостокЧитаКраснорскТомскИжевскПетрозаводскКазаньКурскТюменьВолгоградУфаМурманскБишкекСочиУльяновскСаратовИркутскДолгопрудныйОнлайн
3 – 18 октября
Kokoc Hackathon 2024
Онлайн
24 – 25 октября
One Day Offer для AQA Engineer и Developers
Онлайн
25 октября
Конференция по росту продуктов EGC’24
МоскваОнлайн
26 октября
ProIT Network Fest
Санкт-Петербург
7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань