Numl – Альтернативный язык разметки и стилизации для веб

Всем привет! Меня зовут Андрей, я профессионально разрабатываю веб-интерфейсы уже больше 11 лет и последний год развиваю проект Numl, который можно назвать языком разметки и стилизации для веб. В этой статье я расскажу, как в попытке перебороть ряд особенностей CSS и упростить вёрстку веб-проектов получился целый язык, который не только удовлетворил все наши потребности в стилизации, но также позволил уменьшить кол-во JS-кода и улучшить доступность.

-7dawwmgg3grcy_b98f46y3ktgm.jpeg

Для начала, коротко про Numl и чем он может быть интересен разработчикам.

Numl это язык разметки, который объединяет в себе функции CSS-фреймворка, JS-фреймворка без композиции и Дизайн-системы, и предоставляет набор готовых элементов, каждый из которых имеет обширный набор свойств для кастомизации. Язык основывается на нативном браузерном API Custom Elements из спецификации Web Components, и совместим с популярными JS-фреймворками, такими как Vue, Svelte, Angular и React. Отличительной (и я бы даже сказал «уникальной») чертой Numl является то, что все стили для интерфейса он генерирует в runtime, что позволяет выжать максимум из CSS и добиться огромной гибкости в стилизации и кастомизации элементов. Эта статья — ответ на вопрос, как так получилось и почему такой подход заслуживает право на жизнь.

На прошлой неделе, 4-го июля, проекту исполнился ровно год и он уже давно прошёл стадию proof of concept. На нём написан крупный проект Sellerscale и браузерное расширение от Sellerscale. Также с помощью Numl создано еще несколько сайтов, включая собственный лэндинг и Storybook. Полный набор ссылок будет в конце.

krtehs8pqsq2nxxnq-xvum7-uqq.png
Дашборд Sellerscale. Стэк: VueJS, Numl

wjv1drxwfo9bodek-c6xj6-8veg.png
Расширение Sellerscale. Стэк: Svelte, Numl

Numl сам по себе может быть интересен всем, кто знаком с основами HTML/CSS и хочет создавать качественные, доступные и красивые веб-интерфейсы, без глубокого погружения в тонкости CSS и ARIA. Однако данная статья выходит за рамки базовых знаний и больше подойдёт для людей, которые разбираются в различных CSS методологиях, много верстают, пишут свои инструменты для стилизации или же интересуются необычными инструментами из мира фронтенд-разработки. Используете Utility-First CSS? Тогда вам определённо стоит дочитать до конца.

Для тех, кому просто хочется узнать про Numl, я предлагаю посетить сайт numl.design, полистать Storybook с кучей примеров, почитать статью про базовый синтаксис в гайде и попробовать Numl прямо в браузере с помощью REPL.


В поисках идеальной методологии вёрстки

Программировать я начал примерно 22 года назад, и уже тогда, используя Turbo Pascal, создавал различные оконные интерфейсы с кнопочками, окнами, инпутами и прочим. Двумя годами позднее, изучив веб-платформу я принялся за разработку сайтов и с тех пор моё хобби стало плавно превращаться в профессию. Многие веб-разработчики овладев HTML/CSS, погружаются в JS-экосистему и постепенно перестают верстать. Но мне всегда хотелось создавать качественные интерфейсы, а это невозможно без вёрстки как активного навыка. Поэтому, если было время, я старался верстать проекты самостоятельно, применять новые методологии, новые CSS-свойства, новые хаки.

В основном я использовал методологию, очень похожую на БЭМ, только без Modifier Value (впрочем почти все его так и используют). Это не было идеальным, но позволяло верстать качественно и с относительно большой скоростью, так как стили имели чёткую структуру. Можно было располагать куски кода в нужном месте, не сильно задумываясь.

Спустя много лет, пришло понимание, что независимо от методологии, у CSS есть ряд особенностей, которые очень сложно упростить или скрыть за абстракцию. Далее я попробую их перечислить. Хочу сразу отметить, что я питаю огромное уважение к разработчикам веб-стандартов, ценю их огромный труд, и не пытаюсь его обесценить. Веб-стандарты в первую очередь дают нам больше возможностей для решения наших задач, но это не означает, что эти стандарты обязаны быть идеальными для каждого отдельно взятого разработчика, проекта, компании или отдельной задачи. Поэтому это не список «проблем CSS», а список его особенностей в контексте создания больших и сложных интерфейсов:


  • CSS является достаточно низкоуровневым языком. Да, там есть такие крутые высокоуровневые спецификации как Grid, но бОльшая часть языка это примитивы и часто одна задача требует использования нескольких из них одновременно. Например для того, чтобы спозиционировать элемент над другим элементом по середине (тот же тултип), надо использовать одновременно position, top/right/bottom/left и transform, плюс обязательно надо добавить немного JS, чтобы тултип не убежал за экран.
  • В CSS много свойств, которые одновременно могут использоваться для решения разных задач. Например, box-shadow (внутренняя/внешняя тень или красивый бордер), transform (смещение и масштабирование) и т.п. Это может быть также неудобно, как одна JS-функция, которая решает несколько задач.
  • Специфичность селекторов и приоритизация стилей с помощью порядка в коде до сих пор создают проблемы, особенно для новичков. Про !important я промолчу.
  • Привязка свойств к состояниям элемента может быть очень простой, но непредсказуемой. Приоритет стилей для .cls:hover и .cls:focus зависит от порядка и в более сложных случаях от специфичности. Чтобы добавить стиль в состояние, мы должен убедиться, что он не конфликтует со стилями из другого состояния. Но можно писать предсказуемый CSS (.cls:hover:not(:focus) и т.п.), но мы получим очень комплексный синтаксис, в котором легко запутаться, и который потребует огромного рефакторинга в случае добавления нового состояния. В реальных проектах мы обычно сталкиваемся со смешанным подходом, который старается минимизировать кол-во кода, что дополнительно усложняет его поддержку.
  • Из предыдущего пункта также следует, что переопределения набора стилизованных состояний у элемента становится экспоненциально сложной задачей. А это значит, что наследование стилей с помощью добавления класса (.btn.fancy-btn) в общем случае не работает, даже если мы используем «предсказуемый подход». Мы не можем создать и применить универсальный класс, который бы, к примеру, сказал «убери/замени все стили связанные с состоянием hover для этого элемента». Достаточно банальная задача дизайна «замена набора состояний», (например для оптимизации под touch-устройства) не имеет универсального и простого решения через CSS.
  • CSS Media Queries имеют чрезмерно мощный и запутанный (@media not all and (hover: none)) синтаксис для тех задач, для которых мы их используем.
  • Интерфейс создаётся с помощью CSS+HTML, которые в браузере превращаются в сложную связку CCSOM+DOM с кучей правил. Это даёт огромную гибкость, которую мы так любим, но также создаёт простор для ошибок и появления багов, усложняя создание и поддержку кода.
  • В отличие от JS, контролировать качество CSS-кода очень сложно. Его крайне тяжело статически анализировать. А в runtime получение хоть какой-то информации о стилях элемента требует вызова getComputedStyle(), что влияет на производительность.
  • CSS огромен и постоянно развивается. Поддерживать проект в актуальном состоянии может быть очень дорого, потому что внедрение отдельных новшеств требует радикального переписывания кода.

Причём создание обёрток посредством препроцессоров может даже усложнять ситуацию, добавляя в и без того сложную абстракцию дополнительное измерение. А бывает и так, что попытка что-то упростить начинает сильно нас ограничивать. Например, фиксированные breakpoints для адаптивности почти в каждом первом CSS-фреймворке.

Я считаю, что в хорошем инструменте простые задачи должны решаться просто, а сложные — пропорционально сложнее. И CSS прекрасно вписывается в эту концепцию, пока мы создаём относительно простые сайты с небольшим кол-вом требований, но по мере усложнения задач и объёмов проектов, поддержка CSS становится несоизмеримо более затратной.

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


Начнём с малого…

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

Самой первой проблемой стала раскладка страниц. Да, разумеется, можно создать компоненты MyGrid или MyFlex с соответствующим display свойством, что я и сделал, но для задания item-свойств (basis/width/height) нужно было что-то придумать и я решил эту проблему создав Базовый элемент (далее просто БЭ) от которого все остальные компоненты должны были наследоваться. Таким образом каждый компонент получил свойства basis, width, height и другие, что позволило корректировать их размеры и создавать адекватную раскладку.

Также с самого начала были добавлены свойства size и text, которые относились к тексту. size использовался для выставления font-size/line-heigh, а текст накладывал различные модификаторы текста (атомарный css в чистом виде), чтобы можно было легко делать текст жирным, выставлять выравнивание и т.п.

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

После трех месяцев оказалось, что данный подход не только улучшает качество кодовой базы и ускоряет разработку, но и улучшает DX (Developer Experience). Больше не требовалось переключение между контекстами разметка/стили для вёрстки страниц и составных компонентов.

Кол-во свойств БЭ росло, всё больше помогая быстро решать локальные задачи вёрстки. Но появились и первые проблемы.

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

Ура, первый кусочек кода!

Т.е. никакой композиции других элементов внутри нет. Композицией мы занимаемся на верхнем уровне. Пример для наглядности:


    Button
    Popup content

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

В этот момент стало очевидно, что подход очень удобен и надо думать, как его развивать, чтобы добавить адаптивность, контекстные стили, стили для состояний, более удобную работу с цветом (у нас в проекте было много раскрашенных элементов). Примерно в это время я давал мастер-класс по гридам и для упрощения демок создал простенький веб-компонент my-grid, который брал атрибуты и мапил их на стили. Я был поражен насколько хорошо мой подход вписался в концепцию Custom Elements. Ведь, в них по умолчанию нет никакой композиции! Следующие несколько дней я потратил на миграцию элементов нашего проекта на Custom Elements, а сами элементы вынес в отдельный Open Source проект NUDE Elements, название которого позже было сокращено до Numl. Все элементы получили префикс nu-.


Справка: NUDE — это название JS-фреймворка, на котором основан Numl и который был специально для него написан.

Миграция прошла на удивление легко. И началась активная работа над Numl параллельно основному проекту. В первую очередь нужно было избавиться от inline-стилей. Это сильно ограничивало возможности CSS и потребляло лишнюю мощность на маппингах. Таким образом был создан механизм генерации CSS в рантайме. Если упрощенно, то работало это следующим образом: Элемент nu-grid получал в атрибут columns значение 1fr 1fr. Генератор анализирует это, вызывает функцию columnsAttr('1fr 1fr'), которая выглядит следующим образом:

export function columnsAttr(val) {
    return {
        'grid-template-columns': val,
    };
}

Дальше генератор берёт результат и создаёт на его основе CSS:

nu-grid[columns="1fr 1fr"] {
    grid-template-columns: 1fr 1fr;
}

… и вставляет это всё в