Статьи
Паттерны архитектуры для нескольких макетов и тем
Как построить фронтенд, который держит 100+ тем и несколько макетов, не пуская кастомную логику в общие компоненты? В статье разбираем слои core / view / theme leaf, типизированную конфигурацию, тесты целостности семейства и загрузку CSS тем по требованию, а также компромиссы каждого слоя.
· Обновлено · 2 мин чтения
Когда-то я отвечал за реструктуризацию архитектуры большой фронтенд-платформы на Nx / Angular. Она должна была поддерживать более 100 тем и несколько макетов, а другие участники команды часто выполняли подзадачи по нескольким макетам и темам так, как удобно им самим. Со временем это накапливалось в технический долг: у одного и того же требования одновременно существовало несколько реализаций.
В этой статье я подробно разбираю основные идеи тогдашнего решения: как разделить слои, как сохранять целостность сотни с лишним тем и как сделать так, чтобы браузер скачивал только нужный CSS. Исходный production-код публиковать нельзя, поэтому я заново реализовал всё на оригинальном коде и трёх вымышленных темах и выложил в frontend-engineering-case-studies; там же есть онлайн-демо, с которым можно взаимодействовать. Весь код в статье взят из этой публичной версии.
Сначала вывод: разделите «как это выглядит» и «что это делает», а затем пусть компилятор и тесты следят за согласованностью сотни с лишним тем.
Проблема не в смене цветов#
Самое частое начало мультитемности — условия в общих компонентах. Например, теме A нужна лишняя кнопка, а теме B на время акции нужно другое перенаправление:
if (theme === 'a') {
showExtraButton = true;
} else if (theme === 'b' && isCampaign) {
redirectTo = '/campaign';
}
Пока тем три, так писать быстро. Когда тем становится сто, каждый общий компонент тащит на себе кучу веток, которые «верны только для одной темы». Последствия:
- Изменение требования для темы A может незаметно сломать тему B, и никто не отвечает за проверку B.
- Одно и то же различие в разных компонентах пишется заново, причём по-разному.
- При добавлении новой темы никто не может быть уверен, что «всё, что нужно, добавлено».
Поэтому решать нужно не только смену цветов, а вот что: позволить каждой теме отличаться по внешнему виду и по продукту, при этом сохраняя целостность и предсказуемость всего семейства тем.
Сначала разделим: макет и тема — разные вещи#
Проще всего объяснить на примере дома:
- Структура и коммуникации: несущие колонны, электрика и сантехника. Одинаковы для каждой квартиры и не должны меняться из-за вкусов жильцов.
- Макет: планировка. Две комнаты или три, где гостиная. Определяет, «как всё расставлено».
- Тема: отделка. Цвет стен, материалы, радиус скруглений. Определяет, «как всё выглядит».
Во фронтенде эти три вещи соответствуют разным слоям. Если их смешать, возникает ситуация «чтобы поменять цвет, пришлось править поток данных».
Четыре слоя#
Runtime theme id
│
▼
selector ──► theme leaf (complete typed config)
│
▼
shared view ◄── data service
│ └──► actions facade
▼
pure core
Читаем снизу вверх:
core: отвечает только за отрисовку#
Компонент core получает только «уже подготовленные данные, которые можно сразу отрисовывать» и отдаёт наружу лишь намерения (intent) пользователя. Он не знает, какая сейчас тема, не трогает routing и формат ответов API и не ссылается ни на какие брендовые материалы.
export class ThemeCardCoreComponent {
readonly appearance = input.required<ThemeAppearance>();
readonly content = input.required<ThemeContent>();
readonly items = input.required<readonly ShowcaseItem[]>();
readonly selectedId = input<string | null>(null);
readonly itemSelected = output<ShowcaseItem>();
readonly selectionCleared = output<void>();
}
Core общий для всех тем, поэтому его влияние самое широкое, а правила самые строгие: на входе только необходимое, на выходе только намерения.
view: отвечает за координацию#
View — посредник. Он берёт данные у data service и приводит их к форме, нужной core; когда core отдаёт намерение, передаёт его в actions facade.
@Component({
selector: 'case-study-theme-card-view',
providers: [FeatureActionsFacade, FeatureDataService],
template: `
<case-study-theme-card-core
[appearance]="appearance()"
[content]="themeContent()"
[items]="data.items()"
[selectedId]="actions.selectedId()"
(itemSelected)="select($event)"
(selectionCleared)="actions.reset()"
/>
`,
})
export class ThemeCardViewComponent { /* ... */ }
Откуда берутся данные (data service) и что происходит по нажатию (actions facade) — у каждого свои чёткие границы, они не разбросаны по компонентам экрана.
Макеты уместно размещать именно на этом слое. Разные планировки могут быть разными shared view: они подключаются к одному и тому же data service и facade и лишь собирают core в разную компоновку. Для простоты публичное демо содержит только один shared view; в production при той реструктуризации их было внедрено 26.
theme leaf: по одному листовому узлу на каждую тему#
Каждая тема — очень тонкий компонент, который делает ровно одно: выбирает полную конфигурацию внешнего вида и передаёт её shared view.
@Component({
selector: 'case-study-theme-aurora',
imports: [ThemeCardViewComponent],
template:
'<case-study-theme-card-view [appearance]="appearance" data-theme-leaf="aurora" />',
})
export class ThemeAuroraComponent {
protected readonly appearance = THEME_APPEARANCES.aurora;
}
Никаких бизнес-условий внутри нет. В production один показательный полный theme leaf после рефакторинга занимает 23 строки; тот же путь в старой реализации занимал несколько сотен строк.
selector: явно перечисляет членов семейства#
Selector по runtime-идентификатору темы отрисовывает соответствующий leaf:
@switch (theme()) {
@case ('aurora') { <case-study-theme-aurora /> }
@case ('summit') { <case-study-theme-summit /> }
@default { <case-study-theme-default /> }
}
Выглядит он заурядно, но суть в «явности»: какие есть темы и какому leaf каждая соответствует — видно с первого взгляда, и это можно проверять тестами по одной.
Пусть компилятор проводит перекличку#
Когда тем много, больше всего боишься пропуска. Добавили тему и забыли описать конфигурацию; изменили формат конфигурации, а несколько старых тем не обновились.
Решение: сначала записать список тем как единый источник, а затем с помощью satisfies в TypeScript потребовать для каждой темы полную конфигурацию:
export const DEMO_THEMES = ['default', 'aurora', 'summit'] as const;
export type DemoTheme = (typeof DEMO_THEMES)[number];
export interface ThemeAppearance {
readonly density: 'comfortable' | 'compact';
readonly id: DemoTheme;
readonly stylesheetHref: `themes/${string}.css`;
}
export const THEME_APPEARANCES = {
default: { id: 'default', density: 'comfortable', stylesheetHref: 'themes/default.css' },
aurora: { id: 'aurora', density: 'comfortable', stylesheetHref: 'themes/aurora.css' },
summit: { id: 'summit', density: 'compact', stylesheetHref: 'themes/summit.css' },
} satisfies Record<DemoTheme, ThemeAppearance>;
Если в DEMO_THEMES добавлена тема, а в THEME_APPEARANCES она не описана, компиляция завершится ошибкой. То же касается опечаток в полях и неверного формата пути к CSS.
Преимущество satisfies перед прямым указанием типа в том, что он проверяет форму, но сохраняет конкретный тип каждого значения, поэтому при обращении к THEME_APPEARANCES.aurora тип не расширяется до общего ThemeAppearance.
Пусть тесты охраняют всё семейство#
Компилятор отвечает за конфигурацию, но не за то, что «эта тема действительно отрисовывается». Это поручается тесту целостности семейства, который перебирает каждую объявленную тему:
it.each(DEMO_THEMES)('renders the %s family leaf', (theme) => {
const fixture = TestBed.createComponent(ThemeSelectorComponent);
fixture.componentRef.setInput('theme', theme);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(`[data-theme-leaf="${theme}"]`),
).toBeTruthy();
});
Для стороны конфигурации тоже есть соответствующий тест: поле id каждой конфигурации темы должно совпадать с её ключом в таблице конфигураций, а путь к CSS — соответствовать правилам семейства.
Вместе эти две проверки превращают работу, которая раньше держалась на ручном review («не забыли ли sibling-темы»), в ошибку, которую останавливает CI. При масштабе в сотню с лишним тем человеческого глаза не хватит.
Скачиваем только CSS используемой темы#
Слои решают проблему сопровождения, но у мультитемности есть и цена, которая бьёт прямо по пользователю: CSS.
Раньше все 136 stylesheet тем собирались в один бандл, и пользователь, какую бы тему ни использовал, был вынужден скачивать и разбирать их все. После перехода на загрузку по требованию ресурсы одной темы сократились примерно с 2,4 МБ до 17 КБ, то есть почти на 99%.
Сам механизм прост: каждая конфигурация темы объявляет свой stylesheetHref, а единственный loader управляет на странице единственным <link> темы:
@Injectable()
export class ThemeStylesheetLoader {
readonly #document = inject(DOCUMENT);
load(href: ThemeAppearance['stylesheetHref']): void {
const id = 'case-study-theme-stylesheet';
const existing = this.#document.getElementById(id) as HTMLLinkElement | null;
const link = existing ?? this.#document.createElement('link');
link.id = id;
link.rel = 'stylesheet';
if (link.getAttribute('href') !== href) link.setAttribute('href', href);
if (!existing) this.#document.head.append(link);
}
}
Платформа вызывает его при переключении темы:
effect(() => {
const appearance = THEME_APPEARANCES[this.selectedTheme()];
this.#stylesheetLoader.load(appearance.stylesheetHref);
});
Ключевое слово здесь «единственный». Если каждое место вставляет свой <link>, то после многократных переключений остаются стили старой темы или две копии CSS начинают перекрывать друг друга. Тест проверяет, что и после нескольких переключений на странице остаётся ровно один управляемый stylesheet.
Сам CSS темы определяет только design token (CSS-переменные), а стили core лишь читают эти переменные:
:root {
--demo-surface: #fffaf1;
--demo-text: #3b2620;
--demo-accent: #c65367;
--demo-radius: 30px;
}
Поэтому смена темы — это просто смена таблицы переменных, стили core трогать совсем не нужно. Светлая и тёмная схемы — это другой набор переменных в том же файле.
Компромиссы#
Этот дизайн не бесплатен:
- Файлов становится больше. Один leaf на тему, один view на макет — число мелких файлов растёт. Взамен ответственность каждого файла видна сразу, а целостность можно проверять.
- Конфигурация не должна превращаться в язык программирования. Config подходит для описания внешнего вида и ограниченных различий. Как только в конфигурации появляются условия и процессы, вы просто прячете прежние if/else в другом месте.
- Для core нужны более сильные контрактные тесты. Он общий для всех тем, и одно изменение затрагивает сотню с лишним тем.
- Для runtime-переключения CSS нужен единственный владелец. Некоординированные запросы stylesheet приводят к мерцанию или остаточным стилям, поэтому loader и должен быть единственным.
- Кэширование по-прежнему важно. Даже если загружается только одна тема, стабильные имена файлов и cache-заголовки определяют скорость при повторном визите.
Когда это того стоит#
Если у вашего продукта только два режима, светлый и тёмный, достаточно CSS-переменных, и весь этот набор не нужен.
Задуматься стоит, когда вы сталкиваетесь со следующим:
- Тем или клиентских версий больше десяти, и их число растёт.
- Различия между темами не только в цвете, но и в компоновке, переключателях функций, текстах.
- Изменение требования для одной темы часто неожиданно затрагивает другую.
- При добавлении новой темы никто не может быть уверен, что именно нужно дополнить.
Границы доказательств#
Про production-цифры из этой статьи нужно чётко сказать, к чему они относятся:
- 23 строки — это один показательный полный theme leaf после рефакторинга, а не то, что каждый leaf и каждый файл занимает 23 строки.
- В той миграции было внедрено 70 модулей новой архитектуры (24 facade, 10 data service, 10 theme config, 26 shared view). Это масштаб переделки, а не результат по производительности или сокращению кода.
- 2,4 МБ → 17 КБ — реальный замер в production. Три небольших CSS в публичном демо эту цифру не воспроизведут.
- В том же проекте по качеству Lighthouse Performance ключевых измеренных страниц вырос с 50+ до 90+, но это сумма многих улучшений, а не результат одной только загрузки CSS по требованию.
Публичный репозиторий доказывает сам архитектурный механизм: типизированные контракты, runtime-переключение, тесты целостности семейства, CSS по требованию и CI, который запускает эти проверки. Полная таблица соответствия заявлений — в аудите границ доказательств в репозитории.
Чтобы увидеть эффект своими глазами, откройте онлайн-демо, переключите три темы, язык и светлую или тёмную схему, а затем сверьтесь с исходным кодом на GitHub.