Статті
Патерни архітектури для кількох макетів і тем
Один фронтенд має підтримувати понад 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, і ніхто не відповідає за її перевірку.
- Одна й та сама відмінність пишеться окремо в різних компонентах, до того ж по-різному.
- Коли додається нова тема, ніхто не може бути певен, що «все, що слід було додати, додано».
Отже, треба вирішити не просто зміну кольору, а таке: дозволити кожній темі відрізнятися за виглядом і за продуктом, водночас зберігаючи всю родину тем цілісною та передбачуваною.
Спершу розрізніть: макет і тема — це різні речі#
Найлегше зрозуміти на прикладі будинку:
- Структура й комунікації: колони, електрика та водопостачання. Однакові для кожної квартири, і їх не слід змінювати через вподобання мешканців.
- Макет: планування. Дві кімнати чи три, де вітальня. Визначає, «як речі розташовані».
- Тема: оздоблення. Колір стін, матеріали, розмір заокруглень. Визначає, «як речі виглядають».
У фронтенді ці три речі відповідають різним шарам. Якщо їх змішати, виникають ситуації на кшталт «щоб змінити колір, довелося чіпати потік даних».
Чотири шари#
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 віддає намір, view передає його на обробку 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 id теми рендерить відповідний 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 у кожній конфігурації теми має збігатися з її key у таблиці конфігурацій; шлях до 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 header усе одно визначають швидкість при повторному візиті.
Коли це варте зусиль#
Якщо ваш продукт має лише світлий і темний режими, вистачить CSS-змінних, і весь цей набір не потрібен.
Варто розглянути його, коли ви починаєте стикатися з таким:
- Тем або клієнтських версій понад десять, і їх ще побільшає.
- Відмінності між темами не лише в кольорі, а й у розкладці, перемикачах функцій, текстах.
- Зміна за вимогою однієї теми часто несподівано зачіпає іншу.
- Під час додавання теми ніхто не може бути певен, що ще треба доповнити.
Межі доказів#
Для production-цифр у цій статті слід чітко окреслити межі:
- 23 рядки — це один показовий повний theme leaf після рефакторингу; це не означає, що кожен leaf чи кожен файл має 23 рядки.
- Та міграція впровадила 70 модулів нової архітектури (24 facade, 10 data service, 10 конфігурацій тем, 26 shared view). Це обсяг перебудови, а не результат у продуктивності чи скороченні коду.
- 2,4 МБ → 17 КБ — це вимірювання в production. Три малі CSS у публічному демо цього числа не відтворять.
- У тому ж проєкті з якості Lighthouse Performance ключових виміряних сторінок зріс з 50+ до 90+, але це сума кількох покращень, а не заслуга лише завантаження CSS на вимогу.
Публічний repo доводить саме архітектурний механізм: типізовані контракти, runtime-перемикання, тести цілісності родини, CSS на вимогу та CI, що запускає ці перевірки. Повну таблицю відповідності тверджень наведено в repo в аудиті меж доказів.
Щоб побачити результат напряму, відкрийте онлайн-демо, перемикайте три теми, мови та світлий/темний режим, а потім звіртеся з вихідним кодом на GitHub.