После взлёта тайпскрипта (извини, flow) нетипизированные области фронтенда стали мозолить глаза гораздо сильнее. Логика уже давно на TS, вёрстка, при необходимости, на TSX, а вот у CSS ситуация посложнее.

Можешь использовать CSS файлы (с диалектами и модулями по вкусу) и указывать классы в вёрстке руками - но типизация здесь на уровне "препроцессор может сгенерировать тайпинги со списком классов прямо в рабочем дереве", да и в общем интеграция с рантаймом никакая. При этом гибкость диалекта достигается с помощью плагинов - которые, в общем случае, друг с другом (и, тем более, с IDE) могут и не дружить.

Либо бери любое из CSS-in-JS решений, предоставляющих полную типизацию и интеграцию с остальным кодом, но готовься платить ощутимое пенальти в рантайме - всё же парсинг объектов/строк со стилями занимает ощутимое время. Гибкость при этом, разумеется, максимальная.

Где-то в промежутке между ними находятся проекты вроде linaria или astroturf, которые предполагают парсинг CSS-in-JS на этапе компиляции (примерно как с graphql-tag). Типизация на уровне, производительность в рантайме - тоже, однако это всё ещё препроцессоры строк, пусть и более умные, так что расширяемость оставляет желать лучшего.

Вот тут-то в дело и вступает vanilla-extract. Пару месяцев назад Mark Dalgleish (один из создателей CSS модулей, кстати) решил узнать что получится, если использовать для препроцессинга стилей... сам тайпскрипт!

Спойлер: получилось очень хорошо. Впрочем, обо всём по порядку.

Пример использования

И сразу код (поиграться можно в codesandbox).

Почему не под спойлером?

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

//App.css.ts

//весь этот файл скомпилируется в нативный css
//и маленькую js обвязку с экспортами!

import {
  createTheme,
  style,
  globalStyle,
  composeStyles
} from "@vanilla-extract/css";

//а это уже third-party библиотека
//версия classnames, заточенная под специфику vanilla-extract
import { vcn } from "vanilla-classnames";

//создаём тему и её "контракт"
export const [lightTheme, vars] = createTheme({
  color: {
    body: "white",
    text: "black",
    inactive: "gray",
    active: "red"
  }
});

//добавляем новую тему, используя контракт из предыдущей
export const darkTheme = createTheme(vars, {
  color: {
    body: "black",
    text: "white",
    inactive: "gray",
    active: "blue"
  }
});

//глобальный стиль
globalStyle("body", {
  //это будет "ссылкой" на переменную
  //background-color: var(--color-body__1bu5mlq1);
  backgroundColor: vars.color.body
});

//обычный scoped стиль
export const switchButton = style({
  marginBottom: "20px"
});

//scoped стиль
const common = style({
  display: "flex",
  justifyContent: "center",
  alignItems: "center",
  userSelect: "none",
  cursor: "pointer",
  color: vars.color.text
});

const size = style({
  width: "150px",
  height: "150px",
  transition: "width 1s, height 1s",

  //сразу с дополнительным селектором
  ":hover": {
    width: "200px",
    height: "200px"
  }
});

//toggle - это функция, принимающая что-то вроде
// {active: someCondition} и возвращающая строку классов
export const toggle = vcn(composeStyles(common, size), {
  active: [
    //стиль если переключатель активен
    style({
      background: vars.color.active
    }),
    //и если неактивен
    style({
      background: vars.color.inactive
    })
  ]
});
//App.tsx
import React, { useEffect } from "react";
//импорт из предыдущего файла
import * as S from "./App.css";

//функции для работы с темами
function cleanThemes() {
  document.body.classList.remove(S.lightTheme);
  document.body.classList.remove(S.darkTheme);
}

function light() {
  cleanThemes();
  document.body.classList.add(S.lightTheme);
}

function dark() {
  cleanThemes();
  document.body.classList.add(S.darkTheme);
}

function switchTheme() {
  if (document.body.classList.contains(S.lightTheme)) dark();
  else light();
}

//наш react компонент. Можно было бы и document.write использовать при желании

export const App = () => {
  const [active, setActive] = React.useState(false);
  useEffect(() => {
    light();
    return cleanThemes;
  }, []);

  return (
    <>
      <button className={S.switchButton} onClick={switchTheme}>
        Switch theme
      </button>
      <div
				//компактненько, да?
        className={S.toggle({ active })}
        onClick={() => setActive((r) => !r)}
      >
        <h2>Click me!</h2>
      </div>
    </>
  );
};

А теперь, имея пример перед глазами, можно уже и обсудить различные нюансы.

Предупреждение

Из репозитория
Из репозитория

Начнём с того, что сейчас (май 2021) это альфа-версия (v0.4.3). Какие-то API могут ещё меняться, интеграции работать криво, а крайние случаи - не отрабатываться. В общем, классический набор для новых технологий.

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

Пока в наличии есть плагины для webpack, snowpack, gatsby, esbuild и vite (последний ещё сырой). Ну и есть плагин для babel, упрощающий отладку стилей - он добавляет человекочитаемые названия в сгенерированные классы.

Однако, даже в таком виде проект буквально за пару месяцев уже набрал две тысячи звёзд на гитхабе (и, надеюсь, хабр подкинет парням ещё, они того заслуживают).

Как работает?

Механизм работы не слишком-то и сложен. Плагин для бандлера использует внутренний пакет для интеграций, который и делает всю полезную работу - прогоняет .css.ts файл через esbuild (который умеет переваривать тайпскрипт), а потом запускает получившийся код как модуль ноды.

Все вызовы style и прочих подобных функций добавляют стили в css-строку, а экспорты из модуля преобразуются в обычный js-файл, который вместе с css возвращается в бандлер.

//было
export const switchButton = style({
  marginBottom: "20px"
});

//стало в js
export var switchButton = 'App-switchButton-some-hash'

//в css
.App-switchButton-some-hash {
  margin-bottom: 20px;
}

Единственный неординарный трюк заключается в специальной обработке экспортируемых функций. vanilla-extract требует чтобы на каждой из них висело специальное свойство __recipe__ в котором описывается как создать эту функцию в рантайме:

//было
export function fn() {}

fn.__recipe__ = {
  importPath: 'some-library',
  importName: 'createFn',
  args: [1, 2, 3] //аргументы должны быть сериализуемы
}

//стало
import {createFn} from 'some-library'

export var fn = createFn(1, 2, 3)

Эта фича предназначена в основном для авторов библиотек и она открывает абсолютно новые возможности по интеграции JS и CSS, прокидывая мост между билдтаймом и рантаймом.

А ещё она даже не задокументирована и может измениться :)

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

Предшественник и особенности

На самом деле vanilla-extract - это развитие проекта treat от тех же авторов. treat основан на таком же принципе, но с несколькими значимыми ограничениями: .treat.ts файлы, которые исполняются в момент билда, не могут импортировать друг друга и нет возможности экспортировать из них функции (что очень важно, как я подчёркивал выше).

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

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

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

Кстати, vanilla-extract слегка opinionated - дополнительные селекторы локального стиля должны быть нацелены на элемент с этим стилем.

const someClass = style({
  background: 'green',
  selectors: {
    //вот так норм
    '.dark &': {
      background: 'blue'
    }
    
    //и так
    '&:hover': {
      color: 'white'
    }
  
    //а вот такой селектор нельзя
    //ссылается на дочерние элементы
    '& a': {
      color: 'red'
    },
  }
})

//а так получится, смысл тот же что в запрещённом селекторе
//но явно видно что это глобальный стиль 
globalStyle(`${someClass} > a`, {
  color: 'red'
})

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

Кстати, вот эти строки селекторов никак не типизированы и даже псевдо-селекторы на корректность не проверяются, но это как раз должно быть исправимо с помощью дополнительных утилит, ждём библиотек - ну, или пишем сами?..

Кстати, про экосистему

В монорепе vanilla-extract живёт несколько дополнительных пакетов. И если про несколько вспомогательных функций для работы с переменными и темами в рантайме говорить особо нечего, то вот sprinkles - это готовый фреймворк для построения своего атомарного CSS.

Красиво ведь
Красиво ведь

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

На основе sprinkes уже сделали компонент Box для реакта - dessert-box

Кстати, библиотека vanilla-classnames, которую привёл в примере - это уже моя разработка, такой вот shameless plug. Подкиньте звёзд, коль не жалко ;)

Немного философии напоследок

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

При этом всё будет типизировано и по необходимости связано с рантаймом сколь угодно сложным образом. Аналог styled-components возможно сделать за десять минут, сохранив все их преимущества и ничего не потеряв в производительности.

В целом, сам подход vanilla-extract - это достаточно новая техника (как минимум для экосистемы TS). Это не назвать традиционным компилятором и даже препроцессором - оно никак не завязано на AST. Больше, наверно, похоже на макросы из того же Rust, хоть и со своей спецификой.

Учитывая гибкость этого подхода и огромную интероперабельность между css и js в нём, осталось лишь подождать когда на его основе наделают множество утилит (или даже целых DSL), которые закроют болевые точки, о которых мы и не подозреваем, пока не увидим их решение.

Как вам, например, строго типизированный z-index (и вообще контекст наложения) во всём приложении? Или возможность рантайм проверок правильной вложенности классов без единой дополнительной строчки кода? Или же вообще полностью новая система стилизации, предназначенная именно для приложений, а не документов, но при этом компилируемая в нативный css и работающая с его скоростью, да ещё и учитывающая современные компонентные подходы? И это всё без нового синтаксиса, замечу, и не влияющая на производительность в рантайме.

Поживём - увидим.