ข้ามไปยังเนื้อหา

A Shared Primitive

<m-price cents="1299"> — Web Component ที่ format ราคาแล้ว render ไว้ใน shadow root พร้อม style ของตัวเอง นี่เป็นชิ้นแรกของ design system ที่ share กันใน Mosaic และเพราะเป็น custom element ทุก framework ในโปรเจกต์จึงใช้ได้ด้วยการเขียน tag เดียวกัน React, Svelte, และ Astro ทั้งหมด render <m-price cents="…"> แล้วได้ราคาที่เหมือนกันและสม่ำเสมอ

บทก่อน ใช้ custom element เพื่อ mount ทั้ง app บทนี้ใช้ standard เดียวกันในสเกลตรงข้าม — UI atom ที่ใช้ซ้ำได้เล็ก ๆ — เพื่อแสดงว่า boundary ทำงานได้ทั้งสองทาง

micro-frontend ทำให้ UI แตกเป็นเสี่ยง: สาม framework, สามทีม, สามโอกาสที่ราคาหรือปุ่ม “อันเดียวกัน” จะดูต่างกันนิด ๆ design system ที่ share กันคือยาแก้ — แต่ วิธี share ข้าม framework คือส่วนที่ยาก ship เป็น React component แล้ว Svelte ใช้ไม่ได้ ship สามชุดแล้วก็เลื่อนไหลออกจากกัน

Web Component ship ครั้งเดียว แล้ว consume ทุกที่ เพราะ tag เป็น browser primitive <m-price> encapsulate logic การ format และ style ของตัวเองไว้หลัง shadow root จึงดูเหมือนกันไม่ว่าจะไปตกอยู่บนหน้าของ framework ไหน และไม่มี host CSS ตัวไหน restyle ได้โดยบังเอิญ ทีม design-system เป็นเจ้าของ implementation เดียว ส่วน consumer แค่เขียน tag

ประเด็นเรื่อง format-ที่-เดียวสำคัญเกินกว่าเรื่องหน้าตา: เงินเป็นสิ่งที่คุณไม่มีวันอยากให้สามทีมต่างคนต่างปัดเศษเอง ย้ายเรื่องนี้ไปไว้ใน primitive แล้วทุก framework ก็ได้คำตอบเดียวกันฟรี ๆ

A design-system primitive as a Web Component vs. a per-framework component library

  • Pros: implementation เดียว consume โดย React, Svelte, และ Astro ด้วย markup เดียวกัน shadow-DOM encapsulation รับประกัน rendering ที่สม่ำเสมอข้าม host framework ใหม่ ๆ ได้ design system ฟรี
  • Cons: rich typed props และ ergonomics แบบ framework-native (slots-as-children, event typing) ลื่นกว่าใน native component attribute เป็น string ดังนั้นคุณต้อง serialize/parse ที่ boundary และแต่ละ framework มี quirk เล็ก ๆ ในการ bind กับ custom element

Reflecting an attribute (cents="1299") vs. setting a DOM property (el.cents = 1299)

  • Pros (attribute): ทำงานแบบ declarative จาก HTML, SSR, และ template ของทุก framework — <m-price cents="1299"> ไม่ต้องใช้ JavaScript ต่อสายเลย
  • Cons (attribute): attribute เป็น string เสมอและพา primitive ได้เท่านั้น ดังนั้น data ซับซ้อน (object, array) ต้องผ่าน property หรือ JSON สำหรับตัวเลขเดียวอย่าง cents attribute คือทางเลือกที่ถูกและง่ายที่สุด — จึงมี observedAttributes

autonomous custom element ตัวนี้ observe attribute cents, re-render เมื่อค่าเปลี่ยน, และเก็บ markup กับ CSS ไว้ใน shadow root เพื่อไม่ให้อะไรข้างนอก restyle ได้

class MPrice extends HTMLElement {
static get observedAttributes() {
return ['cents'];
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
this.render();
}
private render() {
const cents = Number(this.getAttribute('cents') ?? '0');
const formatted = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(cents / 100);
this.shadowRoot!.innerHTML = `
<style>
:host { font: inherit; font-variant-numeric: tabular-nums; }
.amount { font-weight: 600; color: var(--m-color-price, currentColor); }
</style>
<span class="amount">${formatted}</span>
`;
}
}
if (!customElements.get('m-price')) {
customElements.define('m-price', MPrice);
}

เรา import design system หนึ่งครั้งเพื่อให้ element ถูก register แล้วเขียน tag สลับการ format ด้วย toFixed ที่เขียนมือใน catalog เป็น shared primitive แทน

import '@mosaic/design-system/m-price';
// inside the product card, replacing the manual price paragraph:
<m-price cents={String(product.priceCents)} />

สอน JSX ให้รู้จัก tag (ครั้งเดียว ใน remotes.d.ts ของ shell หรือ catalog):

declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'm-price': { cents: string };
}
}
}

tag เดียวกัน ไม่ต้องมี wrapper Svelte กับ Astro render custom element ได้ในตัว:

<!-- Svelte (the cart) -->
<script>
import '@mosaic/design-system/m-price';
</script>
<m-price cents={String(item.priceCents)} />
---
// Astro (the content site) — import so the element is defined client-side
import '@mosaic/design-system/m-price';
---
<m-price cents="1299" />

การเช็คที่เร็วที่สุดไม่ต้องใช้ framework เลย — Web Component ก็แค่ HTML วางลงในหน้าทดสอบ:

<script type="module" src="/packages/design-system/src/m-price.ts"></script>
<m-price cents="1299"></m-price>
<!-- renders: $12.99 -->

ใน browser console ยืนยันว่า register แล้วและ reactive จริง:

customElements.get('m-price');
// class MPrice extends HTMLElement — registered.
const el = document.querySelector('m-price');
el.setAttribute('cents', '8900');
// The rendered text updates to $89.00 — attributeChangedCallback re-rendered it.

จากนั้นยืนยันว่า tag เดียวกัน render ใน catalog (React) และในภายหลัง cart (Svelte) โดยได้ output เหมือนกัน และ design-system package build ผ่าน:

Terminal window
pnpm --filter @mosaic/design-system build
# builds the primitive; catalog and cart import it and show the same price.

ตรวจสอบความเข้าใจ:

  1. ทำไม Web Component ตัวเดียวถึง consume ได้โดย React, Svelte, และ Astro ทั้งที่ React component ทำไม่ได้?
  2. shadow root ให้อะไรกับ <m-price> และทำไมสิ่งนั้นถึงสำคัญข้าม micro-frontend ที่ style อิสระจากกัน?
  3. ทำไม observedAttributes ถึงลิสต์ cents และอะไรจะ update ไม่ได้ถ้าละไว้?
  4. เมื่อไหร่คุณจะส่ง data เป็น DOM property แทน attribute และทำไม cents ถึงโอเคเป็น attribute?

<m-price> คือ implementation เดียวของราคา ship ครั้งเดียวและ render โดยทุก framework ด้วย tag เดียวกัน — style ที่ encapsulate ไว้ ที่เดียวสำหรับการ format เงิน นั่นคือรากฐานของ design system (Module 10 ขยายเป็น <m-button>, <m-card>, และ tokens) ตอนนี้คุณเห็น custom-element boundary ทั้งสองสเกลแล้ว: ทั้ง app และ primitive เดี่ยว ต่อไป เอาไปใช้จริงบน framework อีกตัว — Svelte remote ที่ mount ผ่าน web component: Cart Remote (Svelte) →