บทความ
รูปแบบการออกแบบสถาปัตยกรรมสำหรับหลาย layout และหลาย theme
จะออกแบบ frontend อย่างไรให้รองรับ theme มากกว่า 100 แบบและหลาย layout โดยไม่ให้ logic เฉพาะของแต่ละแบบซึมเข้าไปในคอมโพเนนต์ที่ใช้ร่วมกัน? บทความนี้จะอธิบายการแบ่งชั้น core / view / theme leaf, config แบบมี type, การทดสอบความครบถ้วนของทั้ง family และการโหลด theme CSS เมื่อจำเป็น พร้อมข้อแลกเปลี่ยนของแต่ละชั้น
· อัปเดตเมื่อ · อ่าน 3 นาที
ผมเคยรับผิดชอบปรับโครงสร้างสถาปัตยกรรมของแพลตฟอร์ม frontend ขนาดใหญ่ที่ใช้ Nx / Angular แพลตฟอร์มต้องรองรับ theme มากกว่า 100 แบบและหลาย layout อีกทั้งสมาชิกคนอื่น ๆ มักทำงานย่อยสำหรับหลาย layout และหลาย theme โดยใช้แนวทางที่คุ้นเคยกับตัวเอง เมื่อสะสมไปนาน ๆ ก็กลายเป็นหนี้ทางเทคนิค ทำให้ความต้องการเดียวกันมี logic การทำงานหลายแบบอยู่พร้อมกัน
บทความนี้จะอธิบายการออกแบบหลักในตอนนั้นให้ชัดเจน: แบ่งชั้นอย่างไร ทำให้ theme กว่าร้อยแบบยังครบถ้วนได้อย่างไร และทำให้เบราว์เซอร์ดาวน์โหลดเฉพาะ CSS ที่จำเป็นได้อย่างไร เนื่องจากไม่สามารถเปิดเผยโค้ด production ต้นฉบับได้ ผมจึงนำโค้ดที่สร้างขึ้นใหม่มาใช้ reimplement อีกครั้ง พร้อม theme สมมติสามแบบ แล้วเผยแพร่ไว้ที่ frontend-engineering-case-studies และยังมี Demo ออนไลน์ ที่ลองใช้งานได้ทันที โค้ดในบทความมาจากเวอร์ชันสาธารณะนี้ทั้งหมด
ขอสรุปก่อน: แยก “หน้าตาเป็นอย่างไร” ออกจาก “ทำอะไร” แล้วให้ compiler กับการทดสอบช่วยรักษาความสอดคล้องของ theme กว่าร้อยแบบ
ปัญหาไม่ใช่แค่เปลี่ยนสี#
จุดเริ่มต้นที่พบบ่อยที่สุดในการรองรับหลายธีมคือเพิ่มเงื่อนไขไว้ในคอมโพเนนต์ที่ใช้ร่วมกัน เช่น ธีม A ต้องมีปุ่มเพิ่มอีกหนึ่งปุ่ม ส่วนธีม B ต้องเปลี่ยนไปยังอีกหน้าหนึ่งในช่วงแคมเปญ:
if (theme === 'a') {
showExtraButton = true;
} else if (theme === 'b' && isCampaign) {
redirectTo = '/campaign';
}
เมื่อมี theme แค่สามแบบ การเขียนแบบนี้ทำได้รวดเร็ว แต่พอเพิ่มเป็นร้อยแบบ คอมโพเนนต์ที่ใช้ร่วมกันทุกตัวก็ต้องแบกรับเงื่อนไขมากมายที่ “ใช้ได้เฉพาะกับบาง theme เท่านั้น” ผลที่ตามมาคือ:
- การแก้ความต้องการของ theme A อาจทำให้ theme B พังโดยไม่รู้ตัว และไม่มีใครรับผิดชอบตรวจสอบ B
- ความแตกต่างแบบเดียวกันถูกเขียนซ้ำในคอมโพเนนต์ต่าง ๆ แถมยังเขียนไม่เหมือนกัน
- เมื่อต้องเพิ่ม theme ใหม่ ก็ไม่มีใครแน่ใจว่า “เติมครบทุกจุดที่ควรแล้ว”
สิ่งที่ต้องแก้จึงไม่ใช่แค่การเปลี่ยนสี แต่คือ: เปิดให้แต่ละ theme แตกต่างกันได้ทั้งรูปลักษณ์และตัวผลิตภัณฑ์ พร้อมรักษาให้ theme ทั้ง family ครบถ้วนและคาดการณ์ได้
แยกให้ชัดก่อน: layout กับ theme เป็นคนละเรื่อง#
อธิบายด้วยบ้านจะเข้าใจง่ายที่สุด:
- โครงสร้างและระบบสาธารณูปโภค: เสาไฟฟ้า ประปา และสายไฟ เหมือนกันทุกยูนิต และไม่ควรเปลี่ยนไปตามความชอบของผู้อยู่อาศัย
- layout: การจัดผัง ว่าจะมีกี่ห้องนอน ห้องนั่งเล่นอยู่ตรงไหน เป็นตัวกำหนดว่า “สิ่งต่าง ๆ จะจัดวางอย่างไร”
- theme: การตกแต่ง สีผนัง วัสดุ และขนาดมุมโค้ง เป็นตัวกำหนดว่า “สิ่งต่าง ๆ จะดูเป็นอย่างไร”
ใน frontend ทั้งสามอย่างนี้สอดคล้องกับคนละชั้น หากนำมาปะปนกัน ก็จะเกิดสถานการณ์อย่าง “ต้องแก้ data flow เพื่อเปลี่ยนสี”
โครงสร้างสี่ชั้น#
Runtime theme id
│
▼
selector ──► theme leaf (complete typed config)
│
▼
shared view ◄── data service
│ └──► actions facade
▼
pure core
ไล่ดูจากล่างขึ้นบน:
core: มีหน้าที่แสดงผลเท่านั้น#
คอมโพเนนต์ core รับเฉพาะข้อมูลที่ “เตรียมพร้อมและนำไป render ได้ทันที” แล้วส่งออกเฉพาะเจตนาของผู้ใช้ (intent) เท่านั้น โดยไม่รู้ว่าขณะนี้ใช้ theme ใด ไม่ยุ่งกับ routing หรือรูปแบบข้อมูลตอบกลับจาก API และไม่อ้างอิง asset ของแบรนด์ใดเลย
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>();
}
ทุก theme ใช้ core ร่วมกัน ขอบเขตผลกระทบจึงกว้างที่สุด และมีกฎที่เข้มงวดที่สุด: รับเข้าเฉพาะสิ่งที่จำเป็น และส่งออกเฉพาะ intent
view: รับผิดชอบการประสานงาน#
view เป็นตัวกลาง รับข้อมูลจาก data service แล้วแปลงให้อยู่ในรูปแบบที่ core ต้องการ เมื่อ core ส่ง intent ออกมา ก็ส่งต่อให้ 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) ต่างก็มีขอบเขตชัดเจน ไม่กระจัดกระจายอยู่ในคอมโพเนนต์ UI
ชั้นนี้เหมาะกับการวาง layout ผังที่แตกต่างกันอาจใช้ shared view คนละตัว แต่เชื่อมกับ data service และ facade ชุดเดียวกัน เพียงประกอบ core ให้จัดวางต่างกัน Demo สาธารณะมี shared view เพียงตัวเดียวเพื่อให้เรียบง่าย ส่วนการปรับโครงสร้าง production ครั้งนั้นนำมาใช้ทั้งหมด 26 ตัว
theme leaf: หนึ่ง leaf ต่อหนึ่ง theme#
แต่ละ theme เป็นคอมโพเนนต์แบบบาง ทำเพียงอย่างเดียว: เลือก config รูปลักษณ์ที่ครบถ้วนชุดหนึ่ง แล้วส่งให้ 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;
}
ภายในไม่มีเงื่อนไขทางธุรกิจใด ๆ หลัง refactor แล้ว theme leaf ที่ครบถ้วนและเป็นตัวแทนหนึ่งตัวใน production มี 23 บรรทัด ส่วนเส้นทางเดียวกันในรูปแบบเดิมมีหลายร้อยบรรทัด
selector: ระบุสมาชิกใน family อย่างชัดเจน#
selector ใช้ theme id จาก runtime เพื่อ render leaf ที่สอดคล้องกัน:
@switch (theme()) {
@case ('aurora') { <case-study-theme-aurora /> }
@case ('summit') { <case-study-theme-summit /> }
@default { <case-study-theme-default /> }
}
โค้ดดูธรรมดา แต่จุดสำคัญคือความ “ชัดเจน”: มองเห็นได้ทันทีว่ามี theme ใดบ้างและแต่ละ theme เชื่อมกับ leaf ตัวไหน ทั้งยังให้การทดสอบตรวจสอบทีละรายการได้
ให้ compiler ช่วยตรวจรายการ#
เมื่อมี theme จำนวนมาก สิ่งที่น่ากังวลที่สุดคือการตกหล่น เพิ่ม theme ใหม่แล้วลืมเติม config หรือแก้รูปแบบ config แล้วมี theme เก่าบางตัวตามไม่ทัน
แนวทางคือกำหนดรายการ theme ไว้เป็นแหล่งข้อมูลเดียว แล้วใช้ satisfies ของ TypeScript บังคับให้ทุก theme มี config ที่ครบถ้วน:
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>;
เมื่อเพิ่ม theme ใน DEMO_THEMES แต่ไม่ได้เติมใน THEME_APPEARANCES การ compile จะล้มเหลว เช่นเดียวกับกรณีสะกดชื่อ field ผิดหรือรูปแบบ path ของ CSS ไม่ถูกต้อง
ข้อดีของ satisfies เมื่อเทียบกับการระบุ type โดยตรงคือ มันตรวจสอบรูปทรงของข้อมูล แต่ยังคง type เฉพาะของแต่ละค่าไว้ ทำให้เมื่อนำ THEME_APPEARANCES.aurora ไปใช้ภายหลัง จะไม่ถูกขยาย type ให้กว้างเป็น ThemeAppearance ทั่วไป
ใช้การทดสอบรักษาความครบถ้วนของทั้ง family#
compiler ตรวจสอบ config ได้ แต่ตรวจไม่ได้ว่า “theme นี้ render ได้จริงหรือไม่” ส่วนนี้ให้การทดสอบความครบถ้วนของ family ไล่ตรวจ 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();
});
ฝั่ง config ก็มีการทดสอบที่สอดคล้องกัน: field id ของ config แต่ละชุดต้องตรงกับ key ในตาราง config และ path ของ CSS ต้องเป็นไปตามกฎของ family ด้วย
เมื่อนำการตรวจสอบทั้งสองแบบมารวมกัน งานที่เดิมต้องพึ่งการ review ด้วยคนเพื่อดูว่า “มี theme พี่น้องตัวไหนตกหล่นหรือไม่” ก็กลายเป็นข้อผิดพลาดที่ CI จะขัดขวาง ด้วย theme กว่าร้อยแบบ ไม่มีใครตรวจด้วยตาเปล่าได้หมด
ดาวน์โหลดเฉพาะ theme CSS ที่กำลังใช้อยู่#
การแบ่งชั้นช่วยแก้ปัญหาด้านการบำรุงรักษา แต่หลาย theme ยังมีต้นทุนที่กระทบผู้ใช้โดยตรงอีกอย่างหนึ่ง นั่นคือ CSS
วิธีเดิมคือรวม stylesheet ของ theme ทั้ง 136 ชุดไว้ใน bundle เดียว ผู้ใช้ไม่ว่าจะใช้ theme ใดก็ต้องดาวน์โหลดและ parse ทั้งหมด หลังเปลี่ยนเป็นการโหลดเมื่อจำเป็น ทรัพยากรของ theme เดียวลดจากประมาณ 2.4 MB เหลือ 17 KB ลดลงประมาณ 99%
กลไกไม่ซับซ้อน: config ของแต่ละ theme ระบุ stylesheetHref ของตัวเอง แล้วมี loader เพียงตัวเดียว คอยจัดการ theme <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);
}
}
แพลตฟอร์มเรียกใช้เมื่อเปลี่ยน theme:
effect(() => {
const appearance = THEME_APPEARANCES[this.selectedTheme()];
this.#stylesheetLoader.load(appearance.stylesheetHref);
});
คำว่า “เพียงตัวเดียว” คือประเด็นสำคัญ หากแต่ละจุดแทรก <link> ด้วยตัวเอง เมื่อสลับไปมาหลายครั้งก็จะมี style ของ theme เก่าค้างอยู่ หรือ CSS สองชุดเขียนทับกันไปมา การทดสอบจะยืนยันว่าหลังสลับหลายครั้ง หน้าจะยังมี stylesheet ที่ loader จัดการอยู่เพียงชุดเดียว
ตัว theme CSS กำหนดเฉพาะ design token (ตัวแปร CSS) ส่วน style ของ core จะอ่านค่าเหล่านี้เท่านั้น:
:root {
--demo-surface: #fffaf1;
--demo-text: #3b2620;
--demo-accent: #c65367;
--demo-radius: 30px;
}
ดังนั้นการเปลี่ยน theme จึงเป็นเพียงการเปลี่ยนตารางตัวแปร ส่วน style ของ core ไม่ต้องเปลี่ยนเลย โหมดสว่างและมืดใช้ตัวแปรอีกชุดหนึ่งในไฟล์เดียวกัน
ข้อแลกเปลี่ยน#
การออกแบบนี้ไม่ได้มาโดยไม่มีต้นทุน:
- มีไฟล์เพิ่มขึ้น แต่ละ theme มี leaf หนึ่งตัว และแต่ละ layout มี view หนึ่งตัว จำนวนไฟล์ย่อยจึงเพิ่มขึ้น สิ่งที่ได้คือเห็นความรับผิดชอบของแต่ละไฟล์ได้ทันที และตรวจสอบความครบถ้วนได้
- config ไม่ควรกลายเป็นภาษาโปรแกรม config เหมาะสำหรับอธิบายรูปลักษณ์และความแตกต่างที่มีขอบเขตชัดเจน หากเริ่มเขียนเงื่อนไขหรือขั้นตอนการทำงานใน config ก็เป็นเพียงการย้าย if/else ไปซ่อนไว้ที่อื่น
- core ต้องมี contract test ที่เข้มงวดขึ้น เพราะทุก theme ใช้ร่วมกัน การเปลี่ยนแปลงหนึ่งครั้งส่งผลต่อ theme กว่าร้อยแบบ
- การสลับ CSS ใน runtime ต้องมี owner เพียงรายเดียว คำขอ stylesheet ที่ไม่มีการประสานงานอาจทำให้เกิดการกะพริบหรือมี style เก่าค้างอยู่ นี่จึงเป็นเหตุผลที่ loader ต้องมีเพียงตัวเดียว
- แคชยังคงสำคัญ แม้จะโหลดเพียง theme เดียว ชื่อไฟล์ที่คงที่และ cache header ก็ยังเป็นตัวกำหนดความเร็วเมื่อกลับมาใช้อีกครั้ง
เมื่อไรจึงคุ้มที่จะทำแบบนี้#
ถ้าผลิตภัณฑ์ของคุณมีเพียงโหมดสว่างกับมืด ตัวแปร CSS ก็เพียงพอแล้ว ไม่จำเป็นต้องใช้โครงสร้างทั้งหมดนี้
เมื่อเริ่มพบสถานการณ์เหล่านี้ ก็ควรพิจารณาแนวทางนี้:
- มี theme หรือเวอร์ชันลูกค้าเกินสิบแบบและยังเพิ่มขึ้นเรื่อย ๆ
- ความแตกต่างระหว่าง theme ไม่ได้มีแค่สี แต่รวมถึงการจัดวาง ฟีเจอร์ที่เปิดใช้ และข้อความด้วย
- ความต้องการที่แก้ theme หนึ่งมักส่งผลกระทบต่ออีก theme โดยไม่ตั้งใจ
- เมื่อเพิ่ม theme ใหม่ ไม่มีใครแน่ใจว่าต้องเติมตรงไหนบ้าง
ขอบเขตของหลักฐาน#
ต้องระบุขอบเขตของตัวเลข production ที่กล่าวถึงในบทความนี้ให้ชัดเจน:
- 23 บรรทัด คือ theme leaf ที่ครบถ้วนและเป็นตัวแทนหนึ่งตัวหลัง refactor ไม่ได้หมายความว่า leaf หรือไฟล์ทุกตัวมี 23 บรรทัด
- migration ครั้งนั้นนำโมดูลตามสถาปัตยกรรมใหม่เข้ามา 70 โมดูล (facade 24 ตัว, data service 10 ตัว, theme config 10 ชุด และ shared view 26 ตัว) ตัวเลขนี้บอกขอบเขตของการปรับปรุง ไม่ใช่ผลด้านประสิทธิภาพหรือการลดขนาดโค้ด
- 2.4 MB → 17 KB เป็นผลการวัดจริงใน production ส่วน CSS ขนาดเล็กสามชุดใน Demo สาธารณะไม่สามารถแสดงตัวเลขนี้ซ้ำได้
- ในโปรเจกต์ปรับปรุงคุณภาพเดียวกัน Lighthouse Performance ของหน้าทดสอบสำคัญเพิ่มจาก 50+ เป็น 90+ แต่เป็นผลรวมจากการปรับปรุงหลายด้าน ไม่ได้เกิดจากการโหลด CSS เมื่อจำเป็นเพียงอย่างเดียว
สิ่งที่ repo สาธารณะแสดงให้เห็นคือกลไกของสถาปัตยกรรมเอง: contract แบบมี type, การสลับใน runtime, การทดสอบความครบถ้วนของ family, CSS แบบโหลดเมื่อจำเป็น และ CI ที่รันการตรวจสอบเหล่านี้ ตารางเทียบข้อกล่าวอ้างฉบับเต็มอยู่ใน repo ที่ การตรวจสอบขอบเขตหลักฐาน
หากอยากเห็นผลโดยตรง เปิด Demo ออนไลน์ เพื่อสลับสาม theme ภาษา และโหมดสว่างกับมืด แล้วดู ซอร์สโค้ดบน GitHub ประกอบกัน