Artikel
Pola desain arsitektur untuk banyak layout dan banyak tema
Bagaimana merancang frontend yang mendukung 100+ tema dan beragam layout tanpa membiarkan logika kustom merembes ke komponen bersama? Artikel ini menguraikan pembagian lapisan core / view / theme leaf, konfigurasi bertipe, pengujian kelengkapan keluarga, dan pemuatan CSS tema sesuai kebutuhan, beserta kompromi di setiap lapisan.
· Diperbarui · 9 menit baca
Saya pernah bertanggung jawab atas perombakan arsitektur platform frontend Nx / Angular berskala besar. Platform itu harus mendukung lebih dari 100 tema dan beberapa layout. Anggota tim lain sering menyelesaikan sub-tugas terkait banyak layout dan tema dengan cara yang sesuai kebiasaan pengembangan masing-masing. Lama-kelamaan, kebiasaan ini menumpuk menjadi utang teknis: satu kebutuhan yang sama memiliki beberapa logika implementasi sekaligus.
Artikel ini menjelaskan rancangan inti yang saya terapkan saat itu: cara membagi lapisan, menjaga kelengkapan lebih dari seratus tema, dan memastikan browser hanya mengunduh CSS yang diperlukan. Kode production aslinya tidak dapat dipublikasikan, jadi saya membuat ulang implementasinya dengan kode orisinal dan tiga tema fiktif, lalu menaruhnya di frontend-engineering-case-studies. Tersedia juga Demo online yang bisa langsung dicoba. Semua kode dalam artikel ini berasal dari versi publik tersebut.
Kesimpulannya: pisahkan “tampilannya seperti apa” dari “apa yang dikerjakannya”, lalu biarkan compiler dan pengujian menjaga konsistensi lebih dari seratus tema.
Masalahnya Bukan Sekadar Mengganti Warna#
Titik awal yang paling umum untuk mendukung banyak tema adalah menambahkan kondisi di dalam komponen bersama. Misalnya, tema A membutuhkan satu tombol tambahan, sedangkan tema B perlu mengarahkan pengguna ke halaman lain selama kampanye:
if (theme === 'a') {
showExtraButton = true;
} else if (theme === 'b' && isCampaign) {
redirectTo = '/campaign';
}
Saat tema hanya ada tiga, cara ini cepat. Namun, ketika jumlahnya mencapai seratus, setiap komponen bersama harus menanggung banyak cabang yang “hanya berlaku untuk tema tertentu”. Dampaknya:
- Perubahan untuk tema A bisa diam-diam merusak tema B, tanpa ada yang bertanggung jawab memeriksa B.
- Perbedaan yang sama ditulis ulang di komponen berbeda, dengan cara yang juga berbeda.
- Saat menambahkan tema baru, tidak ada yang bisa memastikan bahwa “semua bagian yang perlu dilengkapi sudah beres”.
Jadi, yang perlu diselesaikan bukan sekadar mengganti warna, melainkan: mengizinkan tiap tema memiliki perbedaan tampilan dan produk, sembari menjaga seluruh keluarga tema tetap lengkap dan mudah diprediksi.
Bedakan Dulu: Layout dan Tema adalah Dua Hal Berbeda#
Analogi rumah paling mudah dipahami:
- Struktur dan instalasi: tiang, listrik, dan pipa. Sama di setiap unit, dan tidak seharusnya diubah sesuai selera penghuninya.
- Layout: denah ruang. Dua kamar atau tiga kamar, letak ruang tamu. Menentukan “bagaimana berbagai elemen ditata”.
- Tema: dekorasi. Warna dan bahan dinding, ukuran sudut membulat. Menentukan “seperti apa tampilan berbagai elemen”.
Di frontend, ketiga hal ini masing-masing berada di lapisan berbeda. Jika dicampur, kita bisa berakhir “mengubah aliran data hanya untuk mengganti warna”.
Struktur Empat Lapisan#
Runtime theme id
│
▼
selector ──► theme leaf (complete typed config)
│
▼
shared view ◄── data service
│ └──► actions facade
▼
pure core
Dari bawah ke atas:
core: hanya bertugas merender#
Komponen core hanya menerima data yang “sudah disiapkan dan siap dirender”, lalu mengeluarkan intent pengguna. Komponen ini tidak mengetahui tema yang sedang digunakan, tidak menangani routing atau format respons API, dan tidak merujuk aset merek apa pun.
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 digunakan bersama oleh semua tema sehingga dampaknya paling luas. Karena itu, aturannya paling ketat: hanya menerima input yang diperlukan dan hanya mengeluarkan intent.
view: bertugas mengoordinasikan#
view bertindak sebagai perantara. Lapisan ini mengambil data dari data service, mengubahnya ke bentuk yang dibutuhkan core, lalu meneruskan intent dari core untuk ditangani 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 { /* ... */ }
Asal data (data service) dan tindakan saat pengguna menekan sesuatu (actions facade) memiliki batas yang jelas. Keduanya tidak tersebar di komponen tampilan.
Layout cocok ditempatkan di lapisan ini. Denah yang berbeda dapat dibuat sebagai shared view yang berbeda. Semuanya menggunakan data service dan facade yang sama, tetapi menyusun core dengan cara berbeda. Demo publik hanya memiliki satu shared view agar tetap sederhana; perombakan production saat itu memperkenalkan 26 shared view.
theme leaf: satu leaf untuk setiap tema#
Setiap tema adalah komponen tipis yang hanya melakukan satu hal: memilih konfigurasi tampilan yang lengkap, lalu memberikannya kepada 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;
}
Tidak ada logika bisnis di dalamnya. Dalam production, setelah refactor, sebuah theme leaf lengkap yang representatif terdiri dari 23 baris; implementasi lama untuk alur yang sama terdiri dari beberapa ratus baris.
selector: mencantumkan anggota keluarga secara eksplisit#
selector merender leaf yang sesuai berdasarkan theme id saat runtime:
@switch (theme()) {
@case ('aurora') { <case-study-theme-aurora /> }
@case ('summit') { <case-study-theme-summit /> }
@default { <case-study-theme-default /> }
}
Kode ini tampak sederhana. Yang penting adalah kejelasannya: tema apa saja yang tersedia dan leaf mana yang terkait dengan tiap tema terlihat sekilas, serta dapat diperiksa satu per satu lewat pengujian.
Biarkan Compiler Memastikan Semuanya Lengkap#
Semakin banyak tema, semakin besar risiko ada yang terlewat. Tema baru ditambahkan tanpa melengkapi konfigurasinya, atau format konfigurasi berubah tetapi beberapa tema lama belum diperbarui.
Caranya, tulis daftar tema sebagai satu-satunya sumber, lalu gunakan satisfies dari TypeScript untuk mewajibkan konfigurasi lengkap bagi setiap tema:
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>;
Tambahkan tema ke DEMO_THEMES tanpa melengkapi THEME_APPEARANCES, kompilasi akan gagal. Salah ketik nama field atau format path CSS yang keliru juga akan menyebabkan kegagalan.
Keunggulan satisfies dibanding menetapkan tipe secara langsung adalah: ia memeriksa bentuknya, tetapi mempertahankan tipe spesifik setiap nilai, sehingga saat menggunakan THEME_APPEARANCES.aurora di bagian selanjutnya, tipenya tidak diperlebar menjadi ThemeAppearance umum.
Gunakan Pengujian untuk Menjaga Seluruh Keluarga#
Compiler dapat memeriksa konfigurasi, tetapi tidak dapat memastikan bahwa tema tersebut benar-benar bisa dirender. Untuk itu, gunakan pengujian kelengkapan keluarga yang memeriksa satu per satu setiap tema yang telah dideklarasikan:
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();
});
Ada pengujian terkait untuk konfigurasi: field id pada setiap konfigurasi tema harus sama dengan key-nya di tabel konfigurasi; path CSS juga harus mengikuti aturan keluarga.
Kedua pemeriksaan ini mengubah pekerjaan yang tadinya mengandalkan review manual untuk memastikan tidak ada tema saudara yang terlewat menjadi kesalahan yang akan dihentikan oleh CI. Pada skala lebih dari seratus tema, semuanya tidak mungkin diperiksa dengan mata.
Unduh CSS Hanya untuk Tema yang Sedang Digunakan#
Pembagian lapisan mengatasi masalah pemeliharaan, tetapi banyak tema juga menimbulkan biaya langsung bagi pengguna: CSS.
Awalnya, semua 136 stylesheet tema dibundel menjadi satu. Apa pun temanya, pengguna harus mengunduh dan memproses semuanya. Setelah diubah menjadi pemuatan sesuai kebutuhan, ukuran resource untuk satu tema turun dari sekitar 2.4 MB menjadi 17 KB, berkurang sekitar 99%.
Mekanismenya sederhana: setiap konfigurasi tema mendeklarasikan stylesheetHref sendiri, lalu satu-satunya loader mengelola <link> tema di halaman:
@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);
}
}
Platform memanggilnya saat tema diganti:
effect(() => {
const appearance = THEME_APPEARANCES[this.selectedTheme()];
this.#stylesheetLoader.load(appearance.stylesheetHref);
});
Kata “satu-satunya” adalah kuncinya. Jika setiap bagian menyisipkan <link> sendiri, setelah tema diganti berulang kali, gaya tema lama bisa tertinggal atau dua file CSS saling menimpa. Pengujian memastikan bahwa setelah beberapa kali pergantian, halaman tetap hanya memiliki satu stylesheet yang dikelola.
CSS tema hanya mendefinisikan design token (variabel CSS), sedangkan gaya core hanya membaca variabel tersebut:
:root {
--demo-surface: #fffaf1;
--demo-text: #3b2620;
--demo-accent: #c65367;
--demo-radius: 30px;
}
Jadi, mengganti tema hanya mengganti satu set variabel; gaya core sama sekali tidak perlu diubah. Mode terang dan gelap menggunakan set variabel lain dalam file yang sama.
Kompromi#
Rancangan ini tidak tanpa biaya:
- Jumlah file bertambah. Ada satu leaf untuk setiap tema dan satu view untuk setiap layout, sehingga jumlah file kecil meningkat. Sebagai gantinya, tanggung jawab tiap file langsung terlihat dan kelengkapannya bisa diperiksa.
- config tidak boleh berubah menjadi bahasa pemrograman. config cocok untuk menjelaskan tampilan dan perbedaan dengan batas yang jelas. Begitu kondisi dan alur ditulis di dalam konfigurasi, kita hanya memindahkan if/else ke tempat lain.
- core memerlukan pengujian kontrak yang lebih kuat. core digunakan bersama oleh semua tema, sehingga satu perubahan berdampak pada lebih dari seratus tema.
- Pergantian CSS saat runtime memerlukan satu owner. Permintaan stylesheet tanpa koordinasi bisa menyebabkan kedipan atau gaya lama tertinggal. Itulah sebabnya hanya boleh ada satu loader.
- Cache tetap penting. Meski hanya satu tema yang dimuat, nama file yang stabil dan cache header tetap menentukan kecepatan saat pengguna kembali.
Kapan Rancangan Ini Layak Digunakan#
Jika produk Anda hanya punya mode terang dan gelap, variabel CSS saja sudah cukup; Anda tidak memerlukan keseluruhan rancangan ini.
Rancangan ini layak dipertimbangkan ketika Anda mulai menghadapi situasi berikut:
- Jumlah tema atau versi pelanggan sudah lebih dari sepuluh dan terus bertambah.
- Perbedaan antartema bukan hanya warna, tetapi juga tata letak, fitur yang diaktifkan, dan teks.
- Permintaan perubahan untuk satu tema sering tanpa sengaja memengaruhi tema lain.
- Saat menambahkan tema, tidak ada yang bisa memastikan bagian mana saja yang harus dilengkapi.
Batas Bukti#
Cakupan angka production yang disebutkan dalam artikel ini perlu dijelaskan:
- 23 baris adalah satu theme leaf lengkap yang representatif setelah refactor. Bukan berarti setiap leaf atau file terdiri dari 23 baris.
- Migration tersebut memperkenalkan 70 modul dengan arsitektur baru (24 facade, 10 data service, 10 konfigurasi tema, 26 shared view). Angka ini menunjukkan cakupan perombakan, bukan hasil peningkatan performa atau pengurangan kode.
- 2.4 MB → 17 KB adalah hasil pengukuran production. Tiga file CSS kecil di Demo publik tidak akan menghasilkan angka yang sama.
- Dalam proyek peningkatan kualitas yang sama, skor Lighthouse Performance pada halaman pengukuran utama naik dari 50+ menjadi 90+, tetapi kenaikan itu merupakan hasil gabungan berbagai perbaikan, bukan hanya pemuatan CSS sesuai kebutuhan.
Repo publik membuktikan mekanisme arsitekturnya: kontrak bertipe, pergantian saat runtime, pengujian kelengkapan keluarga, CSS sesuai kebutuhan, dan CI untuk menjalankan pemeriksaan tersebut. Tabel lengkap yang membandingkan klaim dengan bukti tersedia di repo, dalam audit batas bukti.
Untuk melihat hasilnya langsung, buka Demo online dan coba ganti tiga tema, bahasa, serta mode terang dan gelap. Lalu bandingkan dengan kode sumber di GitHub.