Bài viết
Mẫu thiết kế kiến trúc đa layout và đa theme
Làm thế nào để một frontend hỗ trợ 100+ theme và nhiều layout mà không để logic tùy biến lọt vào các component dùng chung? Bài viết này phân tích cách phân tầng core / view / theme leaf, cấu hình có kiểu, kiểm thử tính toàn vẹn của cả họ theme và tải CSS theo nhu cầu, cùng những đánh đổi ở từng tầng.
· Cập nhật · 11 phút đọc
Tôi từng phụ trách tái cấu trúc kiến trúc cho một nền tảng frontend Nx / Angular quy mô lớn. Nền tảng cần hỗ trợ 100+ theme và nhiều layout. Các thành viên thường hoàn thành những nhiệm vụ con về đa layout, đa theme theo thói quen phát triển riêng của mình. Tích lũy lâu ngày, điều này trở thành nợ kỹ thuật: cùng một yêu cầu nhưng tồn tại đồng thời nhiều cách triển khai.
Tôi không thể công khai mã production ban đầu, nên đã tự triển khai lại bằng mã mới do mình viết và ba theme hư cấu, đăng trong frontend-engineering-case-studies; ngoài ra còn có Demo trực tuyến để bạn trực tiếp thao tác. Mã trong bài viết đều lấy từ phiên bản công khai này.
Tóm lại: Tách riêng “trông như thế nào” khỏi “làm việc gì”, rồi để compiler và các bài kiểm thử giúp bạn duy trì tính nhất quán của 100+ theme.
Vấn đề không chỉ là đổi màu#
Cách bắt đầu phổ biến nhất khi hỗ trợ nhiều theme là thêm điều kiện vào một component dùng chung. Ví dụ, theme A cần thêm một nút, còn theme B cần chuyển hướng sang một trang khác trong thời gian diễn ra chiến dịch:
if (theme === 'a') {
showExtraButton = true;
} else if (theme === 'b' && isCampaign) {
redirectTo = '/campaign';
}
Khi chỉ có ba theme, cách viết này rất nhanh. Nhưng khi số theme tăng lên 100, mỗi component dùng chung lại phải gánh một đống nhánh “chỉ đúng với một theme cụ thể”. Hậu quả là:
- Thay đổi theo yêu cầu của theme A có thể âm thầm làm hỏng theme B mà không ai chịu trách nhiệm kiểm tra B.
- Cùng một khác biệt được viết riêng trong nhiều component, mỗi nơi lại viết một kiểu.
- Khi thêm theme mới, không ai chắc rằng “đã bổ sung đầy đủ ở mọi nơi cần thiết”.
Vì vậy, bài toán cần giải không chỉ là đổi màu, mà là: cho phép mỗi theme khác nhau về giao diện và sản phẩm, đồng thời giữ cho cả họ theme luôn đầy đủ và dễ dự đoán.
Trước hết cần phân biệt: layout và theme là hai chuyện khác nhau#
Ví dụ ngôi nhà là dễ hiểu nhất:
- Kết cấu và hệ thống đường ống: cột, điện nước. Căn nào cũng giống nhau và cũng không nên thay đổi theo sở thích của người ở.
- Layout: cách bố trí. Hai phòng ngủ hay ba phòng, phòng khách ở đâu. Quyết định “đồ đạc được sắp xếp như thế nào”.
- Theme: trang trí nội thất. Màu tường, chất liệu, độ bo góc. Quyết định “mọi thứ trông ra sao”.
Trong frontend, ba phần này tương ứng với những tầng khác nhau. Trộn chúng vào nhau sẽ dẫn đến tình trạng “chỉ để đổi màu mà phải sửa luồng dữ liệu”.
Cấu trúc bốn tầng#
Runtime theme id
│
▼
selector ──► theme leaf (complete typed config)
│
▼
shared view ◄── data service
│ └──► actions facade
▼
pure core
Nhìn từ dưới lên:
core: chỉ phụ trách hiển thị#
core component chỉ nhận dữ liệu đã được chuẩn bị và có thể render trực tiếp, đồng thời chỉ phát ra intent của người dùng. Nó không biết theme hiện tại là gì, không đụng đến routing hay định dạng phản hồi API, cũng không tham chiếu tài nguyên thương hiệu nào.
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 được mọi theme dùng chung nên phạm vi ảnh hưởng lớn nhất và có quy tắc nghiêm ngặt nhất: đầu vào chỉ gồm những gì cần thiết, đầu ra chỉ là intent.
view: phụ trách điều phối#
view đóng vai trò trung gian. Nó lấy dữ liệu từ data service, chuyển thành dạng core cần; khi core phát ra intent, view chuyển intent cho actions facade xử lý.
@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 { /* ... */ }
Nguồn dữ liệu (data service) và việc cần làm khi người dùng nhấn nút (actions facade) đều có ranh giới rõ ràng, không rải rác trong các component giao diện.
Layout phù hợp để đặt ở tầng này. Những cách bố trí khác nhau có thể được triển khai thành các shared view khác nhau. Chúng dùng chung một bộ data service và facade, chỉ khác cách kết hợp core thành các bố cục. Demo công khai chỉ có một shared view cho đơn giản; đợt tái cấu trúc production đã đưa vào 26 shared view.
theme leaf: mỗi theme là một nút lá#
Mỗi theme là một component mỏng, chỉ làm một việc: chọn một cấu hình giao diện hoàn chỉnh rồi chuyển cho 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;
}
Bên trong không có điều kiện nghiệp vụ nào. Trong production, một theme leaf hoàn chỉnh tiêu biểu dài 23 dòng sau khi tái cấu trúc; cách viết cũ có vài trăm dòng cho cùng luồng đó.
selector: liệt kê rõ các thành viên trong họ#
selector dựa vào theme id trong runtime để render leaf tương ứng:
@switch (theme()) {
@case ('aurora') { <case-study-theme-aurora /> }
@case ('summit') { <case-study-theme-summit /> }
@default { <case-study-theme-default /> }
}
Trông thì khá bình thường; điểm mấu chốt là sự “rõ ràng”: có những theme nào và mỗi theme tương ứng với leaf nào đều thấy ngay, đồng thời có thể được kiểm tra từng mục.
Để compiler chỉ ra những chỗ còn thiếu#
Khi có nhiều theme, điều đáng ngại nhất là bỏ sót. Thêm theme mới nhưng quên bổ sung cấu hình; đổi định dạng config mà một số theme cũ chưa cập nhật theo.
Cách làm là viết danh sách theme từ một nguồn duy nhất, rồi dùng satisfies của TypeScript để buộc mỗi theme phải có cấu hình đầy đủ:
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>;
Thêm một theme vào DEMO_THEMES mà không bổ sung THEME_APPEARANCES thì quá trình biên dịch sẽ thất bại. Viết sai tên trường hoặc sai định dạng đường dẫn CSS cũng vậy.
Điểm hay của satisfies so với việc gán kiểu trực tiếp là nó kiểm tra cấu trúc nhưng vẫn giữ kiểu cụ thể của từng giá trị, nên khi dùng THEME_APPEARANCES.aurora về sau, kiểu dữ liệu sẽ không bị nới thành ThemeAppearance thông thường.
Dùng kiểm thử để bảo vệ cả họ theme#
Compiler kiểm tra được config, nhưng không kiểm tra được “theme này thực sự render được hay không”. Phần đó do kiểm thử tính toàn vẹn của họ đảm nhiệm, bằng cách lần lượt liệt kê mọi theme đã khai báo:
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();
});
Phía cấu hình cũng có bài kiểm thử tương ứng: trường id của mỗi cấu hình theme phải trùng với key của nó trong bảng cấu hình; đường dẫn CSS cũng phải tuân theo quy tắc của cả họ.
Kết hợp lại, hai kiểu kiểm tra này biến công việc review thủ công để tìm xem có bỏ sót “theme cùng cấp” hay không thành lỗi mà CI sẽ chặn. Với quy mô 100+ theme, mắt người không thể kiểm tra hết.
Chỉ tải CSS của theme bạn đang dùng#
Phân tầng giải quyết vấn đề bảo trì, nhưng nhiều theme còn tạo ra một khoản chi phí ảnh hưởng trực tiếp đến người dùng: CSS.
Cách làm ban đầu là đóng gói toàn bộ 136 stylesheet của theme vào cùng một gói. Dùng theme nào thì người dùng cũng phải tải xuống và phân tích tất cả. Sau khi chuyển sang tải theo nhu cầu, tài nguyên của một theme giảm từ khoảng 2.4 MB xuống còn 17 KB, tức giảm khoảng 99%.
Cơ chế khá đơn giản: mỗi cấu hình theme khai báo stylesheetHref của riêng mình; chỉ một loader duy nhất quản lý <link> theme trên trang:
@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);
}
}
Nền tảng gọi loader này khi chuyển theme:
effect(() => {
const appearance = THEME_APPEARANCES[this.selectedTheme()];
this.#stylesheetLoader.load(appearance.stylesheetHref);
});
Điểm mấu chốt là chỉ có “một”. Nếu mỗi nơi tự chèn <link>, sau nhiều lần chuyển theme sẽ còn sót style của theme cũ hoặc hai file CSS ghi đè lẫn nhau. Kiểm thử xác nhận rằng sau nhiều lần chuyển đổi, trên trang vẫn chỉ có một stylesheet được quản lý.
CSS của theme chỉ định nghĩa design token (biến CSS); style của core chỉ đọc các biến này:
:root {
--demo-surface: #fffaf1;
--demo-text: #3b2620;
--demo-accent: #c65367;
--demo-radius: 30px;
}
Vì vậy, đổi theme chỉ cần đổi bảng biến; style của core hoàn toàn không phải thay đổi. Chế độ sáng và tối là một nhóm biến khác trong cùng file.
Đánh đổi#
Thiết kế này cũng có cái giá của nó:
- Số lượng file tăng lên. Mỗi theme có một leaf, mỗi layout có một view nên số lượng file nhỏ sẽ nhiều hơn. Đổi lại, có thể thấy ngay trách nhiệm của từng file và kiểm tra tính toàn vẹn.
- Không nên biến config thành một ngôn ngữ lập trình. config phù hợp để mô tả giao diện và những khác biệt có ranh giới rõ ràng. Một khi bắt đầu viết điều kiện và luồng xử lý trong config, ta chỉ đang giấu các câu lệnh if/else ở một chỗ khác.
- core cần có kiểm thử contract chặt chẽ hơn. Nó được mọi theme dùng chung, nên một thay đổi có thể ảnh hưởng đến 100+ theme.
- Việc chuyển CSS trong runtime cần có một owner duy nhất. Yêu cầu stylesheet không được điều phối sẽ gây nhấp nháy hoặc để lại style cũ; đó cũng là lý do loader phải là duy nhất.
- Cache vẫn quan trọng. Dù chỉ tải một theme, tên file ổn định và cache header vẫn quyết định tốc độ khi người dùng quay lại.
Khi nào đáng áp dụng cách này#
Nếu sản phẩm của bạn chỉ có hai chế độ sáng và tối, CSS biến là đủ; không cần toàn bộ cấu trúc này.
Khi bắt đầu gặp những tình huống sau, bạn nên cân nhắc cách này:
- Có hơn mười theme hoặc phiên bản khách hàng và số lượng vẫn đang tăng.
- Khác biệt giữa các theme không chỉ nằm ở màu sắc mà còn ở bố cục, tính năng bật/tắt và nội dung.
- Thay đổi theo yêu cầu của một theme thường vô tình ảnh hưởng đến theme khác.
- Khi thêm theme mới, không ai chắc cần bổ sung những chỗ nào.
Giới hạn của bằng chứng#
Cần làm rõ phạm vi của các con số production được đề cập trong bài:
- 23 dòng là độ dài của một theme leaf hoàn chỉnh tiêu biểu sau tái cấu trúc; không có nghĩa mọi leaf hay mọi file đều dài 23 dòng.
- Đợt migration đó đưa vào 70 module theo kiến trúc mới (24 facade, 10 data service, 10 theme config, 26 shared view). Đây là phạm vi của đợt cải tổ, không phải kết quả về hiệu năng hay giảm lượng mã.
- 2.4 MB → 17 KB là số liệu đo thực tế trong production. Ba file CSS nhỏ trong Demo công khai sẽ không tái hiện được con số này.
- Trong cùng một dự án cải thiện chất lượng, Lighthouse Performance của trang đo trọng điểm tăng từ 50+ lên 90+, nhưng đó là kết quả tổng hợp của nhiều cải tiến, không chỉ nhờ tải CSS theo nhu cầu.
Repo công khai chứng minh chính các cơ chế kiến trúc: contract có kiểu, chuyển đổi trong runtime, kiểm thử tính toàn vẹn của cả họ, CSS tải theo nhu cầu và CI chạy các kiểm tra này. Bảng đối chiếu đầy đủ các tuyên bố có trong repo tại cuộc kiểm toán giới hạn bằng chứng.
Nếu muốn xem trực tiếp, hãy mở Demo trực tuyến để chuyển đổi giữa ba theme, ngôn ngữ và chế độ sáng/tối, rồi đối chiếu với mã nguồn trên GitHub.