The Custom Element Boundary
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”remote ที่ expose ตัวเองเป็น custom element — <catalog-app> — แทนที่จะเป็น React component remote ship module ./register ที่ define element นั้น host import register แล้ว render tag เหมือน HTML ทั่วไป element จะ mount framework app ของ remote ไว้ในตัวเอง ตอนที่ browser เชื่อมเข้ากับ DOM
นี่คือ pattern <x-app> และเป็นไอเดียที่สำคัญที่สุดใน Mosaic เมื่อ remote เป็น custom element แล้ว host ไม่รู้และไม่สนว่า framework ไหนสร้างขึ้นมา นี่คือ boundary ที่ทำให้ cart ที่เป็น Svelte ถูก mount ใน React shell ได้ใน module ถัดไป
catalog ทำงานได้เพราะ host กับ remote เป็น React ทั้งคู่ และ share React singleton ตัวเดียวกัน นั่นคือความบังเอิญที่มีความสุขจากการที่ทั้งคู่เป็นแบบเดียวกัน วินาทีที่ remote เป็น Svelte, Vue, หรือ Astro “แค่ render component” ก็เลิกทำงานทันที — ไม่มี runtime ที่ share กัน ไม่มี component model ที่ share กัน ไม่มีอะไรที่ React host เรียกได้
custom element คือทางออก และเป็น browser standard ไม่ใช่ feature ของ framework ทุก framework render ลงใน DOM node ได้ และทุก framework render tag ได้ ดังนั้นเราตกลงกันที่สิ่งเดียวที่ทุกฝ่าย share กัน — DOM — แล้วใช้ customElements.define() เป็น contract หน้าที่ของ remote: “เมื่อคุณถูกวางบนหน้า จง mount app ของฉันไว้ในตัวคุณเอง” หน้าที่ของ host: “โหลด definition แล้ววาง tag” ไม่มีฝ่ายไหนต้องรู้จัก framework ของอีกฝ่าย
การกลับด้านนั้น — remote เป็นเจ้าของการ mount ตัวเอง ส่วน host แค่วาง element — คือสิ่งที่ทำให้การประกอบเป็น framework-agnostic ของจริง
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”A custom element boundary vs. exposing a framework component
- Pros: host mount remote ตัวไหนก็ได้ ด้วยวิธีเดียวกันไม่ว่าเป็น framework อะไร — integration path เดียวสำหรับ React, Svelte, Astro, และอะไรก็ตามที่มาต่อไป remote ควบคุม lifecycle และ styling ของตัวเองได้เต็มที่ (จะอยู่หลัง shadow root ก็ได้)
- Cons: คุณเสีย prop/context/Suspense interop ที่ไร้แรงเสียดทานของ component framework เดียวกัน การส่ง data ที่ซับซ้อนตอนนี้แปลว่าต้องใช้ attribute, property, หรือ event แทน typed React prop และแต่ละ framework ต้องมี wrapper
connectedCallback/disconnectedCallbackเล็ก ๆ
Mounting into a shadow root vs. into light DOM
- Pros (shadow): style encapsulation — CSS ของ remote รั่วออกไม่ได้ และของ host รั่วเข้าไม่ได้ ซึ่งสำคัญเมื่อทีมอิสระ ship style อิสระ
- Cons (shadow): design-system token ส่วนกลางและ third-party style บางตัวต้องถูกปล่อยให้เข้ามาอย่างจงใจ และบาง library สมมติว่าเป็น light DOM สำหรับการ mount ระดับ app นั้น light DOM มักจะง่ายกว่า เก็บ shadow root ไว้ให้ primitive ที่เบ็ดเสร็จในตัวเองอย่างตัวในบทถัดไป
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”1. apps/catalog/src/register.ts
หัวข้อที่มีชื่อว่า “1. apps/catalog/src/register.ts”define custom element ที่ mount React <Catalog> ไว้ในตัวเองตอน connect แล้วรื้อทิ้งตอน disconnect guard การเรียก define เพื่อให้การ import register สองครั้งไม่ throw
import { createElement } from 'react';import { createRoot, type Root } from 'react-dom/client';import Catalog from './Catalog';
class CatalogApp extends HTMLElement { private root?: Root;
connectedCallback() { this.root = createRoot(this); this.root.render(createElement(Catalog)); }
disconnectedCallback() { this.root?.unmount(); this.root = undefined; }}
if (!customElements.get('catalog-app')) { customElements.define('catalog-app', CatalogApp);}2. apps/catalog/vite.config.ts
หัวข้อที่มีชื่อว่า “2. apps/catalog/vite.config.ts”expose ./register ควบคู่ (หรือแทนที่) ./Catalog federation config เหมือนเดิม — เปลี่ยนแค่ exposes map
federation({ name: 'catalog', filename: 'remoteEntry.js', exposes: { './Catalog': './src/Catalog.tsx', './register': './src/register.ts', }, shared: ['react', 'react-dom'],});3. apps/shell/src/remotes.d.ts
หัวข้อที่มีชื่อว่า “3. apps/shell/src/remotes.d.ts”declare virtual register module แล้วสอน TypeScript/JSX ให้รู้จัก custom tag เพื่อให้ <catalog-app> type-check ผ่านใน shell
declare module 'catalog/register' { // Importing the module registers <catalog-app> as a side effect.}
declare module 'react' { namespace JSX { interface IntrinsicElements { 'catalog-app': React.HTMLAttributes<HTMLElement>; } }}4. apps/shell/src/CatalogRoute.tsx
หัวข้อที่มีชื่อว่า “4. apps/shell/src/CatalogRoute.tsx”เรา import registrar เป็น side effect แล้ว render tag ไม่มี React.lazy ไม่มี <Suspense> สำหรับตัว component เอง — element mount React tree ของตัวเอง host เป็นกลาง: แค่วาง element บนหน้า
import { useEffect, useState } from 'react';
export function CatalogRoute() { const [ready, setReady] = useState(false);
useEffect(() => { // Side-effect import: defines <catalog-app>. import('catalog/register').then(() => setReady(true)); }, []);
if (!ready) return <p>Loading catalog…</p>; return <catalog-app />;}ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”รัน remote กับ shell ด้วยกันแล้วโหลด catalog page:
pnpm --filter catalog dev # remote on 5001pnpm --filter shell dev # shell on 5000 — open the catalog page# The grid still renders — but now via a <catalog-app> element, not a React child.ยืนยันว่า boundary เป็น custom element จริงใน browser console:
customElements.get('catalog-app');// class CatalogApp extends HTMLElement — the definition is registered.
document.querySelector('catalog-app');// <catalog-app> in the DOM, with the React-rendered grid inside it.พิสูจน์ว่า remote ยัง build ผ่านด้วย expose ตัวใหม่:
pnpm --filter catalog build# vite build completes; remoteEntry.js now exposes both ./Catalog and ./register.สัญญาณว่านี่ได้ผล: shell render catalog โดยมองเป็น HTML ทึบ ๆ สลับไส้ในของ remote เป็น framework อื่น แล้วบรรทัด <catalog-app /> เดิมนี้ก็จะยังทำงานต่อไป
ตรวจสอบความเข้าใจ:
- ทำไม “แค่ render component” ถึงเลิกทำงานทันทีที่ remote สร้างด้วย framework คนละตัวกับ host?
connectedCallbackทำอะไรตรงนี้ และทำไมถึง mount React tree ตรงนั้นแทนที่จะใน constructor?- ทำไม host ถึง import
catalog/registerเพื่อ side effect แทนที่จะ import component? - เมื่อไหร่คุณจะ mount ลง shadow root และแลกอะไรไปสำหรับ remote ระดับ app?
คุณ wrap remote เป็น custom element: remote เป็นเจ้าของการ mount ตัวเองผ่าน connectedCallback ส่วน host แค่ import registrar แล้ววาง <catalog-app> นั่นคือ framework-agnostic boundary ที่ทั้ง architecture พึ่งพา — และเป็นกลไกเป๊ะ ๆ ที่ Svelte cart จะใช้ใน Module 6 ต่อไป เอา standard เดียวกันนี้ไปใช้ ลงล่าง กับ shared UI primitive ตัวเดียว: A Shared Primitive →