Mga Artikulo
Pattern ng disenyo ng arkitektura para sa maraming layout at theme
Paano bubuuin ang isang frontend na susuporta sa 100+ theme at maraming layout nang hindi kumakalat ang custom na lohika sa mga shared component? Ipinaliliwanag ng artikulong ito ang paghahati sa mga layer na core / view / theme leaf, mga type-safe na setting, mga pagsubok sa integridad ng theme family, at on-demand na pag-load ng theme CSS, pati ang mga trade-off sa bawat layer.
· Na-update · 10 minutong basa
Minsan akong namahala sa muling pag-aayos ng arkitektura ng isang malaking Nx / Angular frontend platform. Kailangan nitong sumuporta sa mahigit 100 theme at ilang uri ng layout. Madalas ding may mga miyembrong gumagawa ng mga subtask para sa multi-layout at multi-theme ayon sa sarili nilang nakasanayang paraan ng pag-develop. Sa paglipas ng panahon, naiipon ito bilang technical debt: iisang requirement, pero sabay-sabay na umiiral ang maraming magkakaibang implementasyon.
Nililinaw ng artikulong ito ang pangunahing disenyo noon: paano paghatiin sa mga layer, paano tiyaking kumpleto ang mahigit isang daang theme, at paano tiyaking CSS lang na kailangan ang ida-download ng browser. Hindi maaaring ilathala ang orihinal na production code, kaya muli ko itong ipinatupad gamit ang orihinal na code at tatlong kathang-isip na theme sa frontend-engineering-case-studies, at mayroon ding online Demo na puwede mong subukan. Lahat ng code sa artikulong ito ay mula sa pampublikong bersiyong iyon.
Una ang konklusyon: Paghiwalayin ang “hitsura” sa “ginagawa,” at hayaang panatilihin ng compiler at mga test ang pagkakapare-pareho ng mahigit isang daang theme.
Hindi lang pagpapalit ng kulay ang problema#
Ang pinakakaraniwang panimulang hakbang para suportahan ang maraming tema ay magdagdag ng mga kondisyon sa loob ng shared component. Halimbawa, kailangan ng temang A ng isa pang button, habang kailangang mag-redirect ang temang B sa ibang page habang may campaign:
if (theme === 'a') {
showExtraButton = true;
} else if (theme === 'b' && isCampaign) {
redirectTo = '/campaign';
}
Mabilis gawin ito kapag tatlo lang ang theme. Pero kapag naging isang daan ang mga theme, pasan na ng bawat shared component ang maraming sangay na “para lang sa isang partikular na theme.” Ang mga resulta nito:
- Maaaring hindi sinasadyang masira ng requirement para sa Theme A ang Theme B, nang walang sinumang nakatalagang mag-check sa B.
- Isinusulat nang magkakahiwalay at magkakaiba ang iisang uri ng pagkakaiba sa iba't ibang component.
- Kapag nagdagdag ng theme, walang makatitiyak na “kumpleto ang lahat ng kailangang dagdagan.”
Kaya hindi lang pagpapalit ng kulay ang kailangang lutasin: bigyang-daan ang bawat theme na magkaiba ang hitsura at produkto, habang nananatiling kumpleto at nahuhulaan ang buong theme family.
Linawin muna: magkaibang bagay ang layout at theme#
Pinakamadaling unawain ito kung bahay ang gagamiting paghahambing:
- Estruktura at mga linya ng kuryente at tubig: mga poste, kable at tubo. Pare-pareho ang mga ito sa bawat unit, at hindi dapat baguhin ayon sa gusto ng nakatira.
- Layout: ayos ng mga kuwarto. Dalawang kuwarto ba o tatlo, at nasaan ang sala? Tinutukoy nito kung “paano nakaayos ang mga bagay.”
- Theme: interior design. Kulay at materyal ng mga dingding, laki ng mga bilog na sulok. Tinutukoy nito kung “ano ang hitsura ng mga bagay.”
Sa frontend, tumutugma ang tatlong ito sa magkakaibang layer. Kapag pinagsama ang mga ito, nagkakaroon ng mga sitwasyong “binabago ang daloy ng data para lang magpalit ng kulay.”
Apat na layer#
Runtime theme id
│
▼
selector ──► theme leaf (complete typed config)
│
▼
shared view ◄── data service
│ └──► actions facade
▼
pure core
Tingnan mula sa ibaba pataas:
core: pagguhit lang ng UI ang responsibilidad nito#
Tumatanggap lang ang mga core component ng data na “nakahanda na at puwede nang direktang i-render,” at inilalabas lang nila ang mga intent ng user. Hindi nila alam kung anong theme ang ginagamit, hindi nila hinahawakan ang routing o format ng tugon ng API, at hindi rin sila nagre-refer ng mga asset ng brand.
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>();
}
Ginagamit ng lahat ng theme ang core, kaya pinakamalaki ang saklaw ng epekto nito at pinakamahigpit ang mga patakaran: mga kinakailangang input lang, at mga intent lang ang output.
view: responsibilidad nitong mag-ugnay#
Tagapamagitan ang view. Kinukuha nito ang data mula sa data service at inaayos ito sa format na kailangan ng core; kapag naglabas ng intent ang core, ipinapasa ito sa 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 { /* ... */ }
May malinaw na hangganan kung saan nanggagaling ang data (data service) at kung ano ang gagawin kapag pinindot (actions facade), kaya hindi nakakalat ang mga ito sa mga UI component.
Dito nararapat ilagay ang layout. Maaaring magkakaibang shared view ang mga layout. Iisa ang data service at facade na ginagamit nila; pinagkakaiba lang kung paano nila binubuo ang core sa magkakaibang ayos. Isang shared view lang ang nasa pampublikong Demo para mapanatili itong simple; 26 naman ang ipinakilala sa muling pag-aayos ng production.
theme leaf: tig-isang leaf node para sa bawat theme#
Isang manipis na component ang bawat theme. Iisa lang ang ginagawa nito: pumili ng kumpletong setting ng hitsura at ipasa ito sa 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;
}
Wala itong anumang business condition. Sa production, 23 linya ang isang kumpletong theme leaf na kumakatawan sa dati naming implementasyon matapos itong i-refactor; ilang daang linya naman ang dating implementasyon para sa parehong daloy.
selector: tahasang inililista ang mga miyembro ng theme family#
Batay sa theme id ng runtime, nire-render ng selector ang katumbas na leaf:
@switch (theme()) {
@case ('aurora') { <case-study-theme-aurora /> }
@case ('summit') { <case-study-theme-summit /> }
@default { <case-study-theme-default /> }
}
Karaniwan lang ang itsura nito. Ang mahalaga ay tahasan ang pagkakalahad: malinaw na makikita kung anu-anong theme ang mayroon at kung aling leaf ang katapat ng bawat isa, at puwedeng suriin ang mga ito isa-isa gamit ang mga test.
Ipaubaya sa compiler ang pagtukoy sa kulang#
Kapag dumami ang theme, ang pinakakinatatakutan ay may makalimutan. May idaragdag na theme pero malilimutan ang setting; babaguhin ang format ng setting pero may ilang lumang theme na hindi maa-update.
Isulat muna ang listahan ng mga theme bilang iisang source of truth, at gamitin ang satisfies ng TypeScript para hingin ang kumpletong setting para sa bawat theme:
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>;
Kung magdagdag ng theme sa DEMO_THEMES pero hindi ito maidagdag sa THEME_APPEARANCES, mabibigo ang compilation. Ganoon din kapag mali ang baybay ng field o ang format ng CSS path.
Ang bentahe ng satisfies kumpara sa direktang paglalagay ng type ay sinusuri nito ang hugis ng data habang pinananatili ang partikular na type ng bawat value. Kaya hindi magiging pangkalahatang ThemeAppearance ang THEME_APPEARANCES.aurora sa susunod na paggamit nito.
Gumamit ng mga test para tiyaking kumpleto ang buong theme family#
Kayang suriin ng compiler ang mga setting, pero hindi nito matitiyak na “talagang narere-render ang theme na ito.” Para rito ang theme family integrity test, na isa-isang naglilista sa bawat idineklarang theme:
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();
});
May katumbas ding test para sa mga setting: kailangang kapareho ng key nito sa settings table ang value ng field na id sa bawat theme setting; kailangan ding sumunod ang CSS path sa mga tuntunin ng theme family.
Kapag pinagsama ang dalawang uri ng pagsusuring ito, nagiging mga error na haharangin ng CI ang gawaing dating umaasa sa manual review para malaman kung may nakaligtaang “sibling theme.” Hindi kayang suriin ng mata ng tao ang mahigit isang daang theme.
I-download lang ang CSS ng theme na ginagamit mo#
Nilulutas ng paghahati sa mga layer ang problema sa maintenance, pero may direktang gastos din sa user ang maraming theme: CSS.
Dati, pinagsasama sa iisang bundle ang lahat ng 136 theme stylesheet. Anuman ang theme na ginagamit ng user, kailangan niyang i-download at iproseso ang lahat ng ito. Matapos lumipat sa on-demand na pag-load, bumaba ang resource ng iisang theme mula humigit-kumulang 2.4 MB tungo sa 17 KB—mga 99% na bawas.
Simple lang ang mismong mekanismo: idinedeklara ng bawat theme setting ang sarili nitong stylesheetHref, at isang nag-iisa na loader ang namamahala sa nag-iisang theme <link> sa page:
@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);
}
}
Tinatawag ito ng platform kapag nagpapalit ng theme:
effect(() => {
const appearance = THEME_APPEARANCES[this.selectedTheme()];
this.#stylesheetLoader.load(appearance.stylesheetHref);
});
Mahalaga ang “nag-iisa.” Kung ang bawat bahagi ay maglalagay ng sarili nitong <link>, maaaring manatili ang mga style ng lumang theme pagkatapos ng paulit-ulit na pagpapalit, o magsapawan ang dalawang CSS. Tinitiyak ng mga test na iisa lang ang stylesheet na pinamamahalaan sa page kahit ilang beses magpalit ng theme.
Mga design token (CSS variable) lang ang tinutukoy ng theme CSS; ang mga style ng core ay nagbabasa lang ng mga variable na ito:
:root {
--demo-surface: #fffaf1;
--demo-text: #3b2620;
--demo-accent: #c65367;
--demo-radius: 30px;
}
Kaya variable table lang ang pinapalitan kapag nagpalit ng theme; hindi kailangang galawin ang mga style ng core. Nasa iisang file ang isa pang hanay ng mga variable para sa light at dark mode.
Mga trade-off#
Hindi libre ang disenyong ito:
- Mas maraming file. Isang leaf para sa bawat theme at isang view para sa bawat layout, kaya dumarami ang maliliit na file. Kapalit nito, malinaw agad ang responsibilidad ng bawat file at nasusuri ang pagiging kumpleto.
- Hindi dapat maging programming language ang config. Mainam ang config para ilarawan ang hitsura at mga pagkakaibang may malinaw na hangganan. Kapag nagsimula ka nang magsulat ng mga kondisyon at daloy sa config, inililipat mo lang sa ibang lugar ang dating if/else.
- Mas matitibay na contract test ang kailangan ng core. Ginagamit ito ng lahat ng theme kaya nakaaapekto sa mahigit isang daang theme ang isang pagbabago.
- Kailangan ng iisang owner ang runtime CSS switching. Maaaring magdulot ng pagkislap o naiwan na style ang mga stylesheet request na walang koordinasyon; ito rin ang dahilan kung bakit dapat nag-iisa ang loader.
- Mahalaga pa rin ang cache. Kahit iisang theme lang ang nilo-load, nakasalalay pa rin sa matatag na filename at cache header ang bilis sa susunod na pagbisita.
Kailan sulit itong gawin#
Kung light at dark mode lang ang mayroon ang produkto mo, sapat na ang CSS variable; hindi mo kailangan ang buong sistemang ito.
Kapag nararanasan mo na ang mga sumusunod, sulit na itong pag-isipan:
- Mahigit sampu na ang theme o bersiyon para sa mga customer, at patuloy pa itong dumarami.
- Hindi lang kulay ang pinagkaiba ng mga theme kundi pati layout, feature toggle at mga salita sa UI.
- Madalas na hindi sinasadyang maapektuhan ang ibang theme ng requirement para sa isang theme.
- Walang makatitiyak kung aling mga bahagi ang kailangang dagdagan tuwing may bagong theme.
Hangganan ng ebidensya#
Kailangang malinaw ang saklaw ng mga production number na binanggit sa artikulong ito:
- 23 linya ang isang kumpletong theme leaf na kumakatawan sa resulta matapos ang refactor; hindi ibig sabihin nito na 23 linya ang bawat leaf o bawat file.
- Nagdagdag ang migration na iyon ng 70 module sa bagong arkitektura (24 facade, 10 data service, 10 theme config at 26 shared view). Saklaw ito ng pagbabago, hindi resulta sa performance o pagbawas ng code.
- 2.4 MB → 17 KB ang aktuwal na sukat sa production. Hindi mauulit ang resultang ito sa tatlong maliliit na CSS file ng pampublikong Demo.
- Sa parehong quality project, tumaas mula 50+ tungo sa 90+ ang Lighthouse Performance ng mahahalagang sinukat na page, pero pinagsamang resulta iyon ng maraming pagpapahusay at hindi lang ng on-demand na pag-load ng CSS.
Ang pinatutunayan ng pampublikong repo ay ang mismong mekanismo ng arkitektura: type-safe na kontrata, runtime switching, mga test sa integridad ng theme family, on-demand na CSS, at ang CI na nagpapatakbo sa mga pagsusuring ito. Nasa audit ng hangganan ng ebidensya sa repo ang kumpletong talahanayan ng paghahambing ng mga pahayag.
Para makita mismo kung paano ito gumagana, buksan ang online Demo, magpalipat-lipat sa tatlong theme, wika at light/dark mode, at pagkatapos ay tingnan ang source code sa GitHub.